diff --git a/.env.example b/.env.example index 194d6bab1..b0b033c57 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,5 @@ TWITCH_CLIENT_ID= TWITCH_CLIENT_SECRET= SKALOP_WS_URL=ws://localhost:5900 -SKALOP_SYSTEM_MESSAGE_URL=ws://localhost:5900/system +SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system SKALOP_TOKEN=secret diff --git a/app/components/InfoPopover.tsx b/app/components/InfoPopover.tsx new file mode 100644 index 000000000..fdcb89feb --- /dev/null +++ b/app/components/InfoPopover.tsx @@ -0,0 +1,9 @@ +import { Popover } from "./Popover"; + +export function InfoPopover({ children }: { children: React.ReactNode }) { + return ( + ?} triggerClassName="info-popover__trigger"> + {children} + + ); +} diff --git a/app/components/icons/Eye.tsx b/app/components/icons/Eye.tsx new file mode 100644 index 000000000..fa9e5db8f --- /dev/null +++ b/app/components/icons/Eye.tsx @@ -0,0 +1,23 @@ +export function EyeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/app/components/icons/EyeSlash.tsx b/app/components/icons/EyeSlash.tsx new file mode 100644 index 000000000..18083305a --- /dev/null +++ b/app/components/icons/EyeSlash.tsx @@ -0,0 +1,18 @@ +export function EyeSlashIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/components/icons/Lock.tsx b/app/components/icons/Lock.tsx index 5f03d3fa6..40e6452c8 100644 --- a/app/components/icons/Lock.tsx +++ b/app/components/icons/Lock.tsx @@ -2,16 +2,14 @@ export function LockIcon({ className }: { className?: string }) { return ( ); diff --git a/app/components/icons/Unlock.tsx b/app/components/icons/Unlock.tsx new file mode 100644 index 000000000..9c1574db6 --- /dev/null +++ b/app/components/icons/Unlock.tsx @@ -0,0 +1,12 @@ +export function UnlockIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/app/db/sql.ts b/app/db/sql.ts index 5f3b92d79..4a3a25232 100644 --- a/app/db/sql.ts +++ b/app/db/sql.ts @@ -20,8 +20,8 @@ export const db = new Kysely({ dialect: new SqliteDialect({ database: sql, }), - log: process.env.NODE_ENV === "development" ? ["query"] : undefined, - // uncomment if you want examine the parameters of the queries + // uncomment if you want examine the queries + // log: process.env.NODE_ENV === "development" ? ["query"] : undefined, // log(event): void { // if (event.level === "query") { // console.log(event.query.sql); diff --git a/app/db/tables.ts b/app/db/tables.ts index f4d75c6fb..0a994585f 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -384,12 +384,24 @@ export interface TournamentSettings { teamsPerGroup?: number; } +export interface CastedMatchesInfo { + /** Array for match ID's that are locked because they are pending to be casted */ + lockedMatches: number[]; + /** What matches are streamed currently & where */ + castedMatches: { twitchAccount: string; matchId: number }[]; +} + export interface Tournament { settings: ColumnType; id: GeneratedAlways; mapPickingStyle: TournamentMapPickingStyle; showMapListGenerator: Generated; castTwitchAccounts: ColumnType; + castedMatchesInfo: ColumnType< + CastedMatchesInfo | null, + string | null, + string | null + >; } export interface TournamentBadgeOwner { @@ -406,7 +418,6 @@ export interface TournamentGroup { export interface TournamentMatch { bestOf: Generated<3 | 5 | 7>; chatCode: string | null; - childCount: number; groupId: number; id: GeneratedAlways; number: number; @@ -459,6 +470,8 @@ export interface TournamentStage { settings: string; tournamentId: number; type: "double_elimination" | "single_elimination" | "round_robin"; + // not Generated<> because SQLite doesn't allow altering tables to add columns with default values :( + createdAt: number | null; } export interface TournamentSub { diff --git a/app/db/types.ts b/app/db/types.ts index 605640e8b..0ce8ea646 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -307,8 +307,6 @@ export enum Status { * Participants can be teams or individuals. */ export interface TournamentMatch { id: number; - /** Not used */ - childCount: number; bestOf: 3 | 5 | 7; roundId: number; stageId: number; diff --git a/app/features/tournament-bracket/brackets-viewer.css b/app/features/tournament-bracket/brackets-viewer.css deleted file mode 100644 index bd4fc48fd..000000000 --- a/app/features/tournament-bracket/brackets-viewer.css +++ /dev/null @@ -1,120 +0,0 @@ -.brackets-viewer { - /* Colors */ - --primary-background: var(--bg); - --secondary-background: var(--bg-lightest); - --match-background: var(--bg-lighter); - --font-color: var(--text); - --win-color: #50b649; - --loss-color: #e61a1a; - --label-color: grey; - --hint-color: #a7a7a7; - /* TODO: mimicking border without transparent but not pretty in light mode */ - --connector-color: #1c1b35; - --border-color: var(--primary-background); - --border-hover-color: transparent; - - /* Sizes */ - --text-size: 12px; - --round-margin: 40px; - --match-width: 150px; - --match-horizontal-padding: 8px; - --match-vertical-padding: 6px; - --connector-border-width: 2px; - --match-border-width: 1px; - --match-border-radius: var(--rounded-sm); - - font-family: Lexend, sans-serif !important; - font-weight: var(--semi-bold) !important; - - padding: 10px 0; -} - -.brackets-viewer .opponents.connect-previous::before { - height: 52%; -} - -.brackets-viewer .match.connect-next.straight::after { - top: 1px; -} - -.brackets-viewer h3 { - border-radius: var(--rounded-sm); -} - -.brackets-viewer h1 { - display: none; -} - -.brackets-viewer h2 { - display: none; -} - -.brackets-viewer .opponents > span { - background-color: var(--bg-light-variation); - top: -11px; - color: var(--text-lighter); -} - -/** TODO: handle logic when to show */ -.opponents::after { - display: none; - content: "🔴 Live"; - position: absolute; - top: -11px; - right: 0; - background-color: var(--bg-light-variation); - color: var(--text-lighter); - font-size: 0.8em; - border-radius: 3px; - padding: 0 5px; -} - -.bye { - color: var(--text-lighter); -} - -.brackets-viewer .participant .name > span { - color: var(--theme); - display: none; -} - -.brackets-viewer .participant .name::before { - content: var(--seed); - font-weight: initial; - color: var(--theme); - font-size: var(--fonts-xxs); - margin-inline-end: var(--space-after-seed); -} - -.participant > .name { - color: var(--team-text-color); -} - -.round > h3::after { - content: var(--best-of-text); - margin-inline-start: var(--s-1-5); - font-size: var(--fonts-xxxs); - color: var(--theme); -} - -/** Round robin */ - -.brackets-viewer .round-robin { - margin: 0 auto; -} - -th[title="Forfeits"] { - display: none; -} - -th[title="Draws"] { - display: none; -} - -.group td:nth-child(5) { - display: none; -} - -.group td:nth-child(7) { - display: none; -} diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx new file mode 100644 index 000000000..e8c3b7ee2 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -0,0 +1,171 @@ +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { Bracket as BracketType } from "../../core/Bracket"; +import { Match } from "./Match"; +import { RoundHeader } from "./RoundHeader"; +import clsx from "clsx"; + +interface EliminationBracketSideProps { + bracket: BracketType; + type: "winners" | "losers" | "single"; + isExpanded: boolean; +} + +export function EliminationBracketSide(props: EliminationBracketSideProps) { + const tournament = useTournament(); + const rounds = getRounds(props); + + return ( +
+ {rounds.flatMap((round, roundIdx) => { + const bestOf = tournament.ctx.bestOfs.find( + ({ roundId }) => roundId === round.id, + )?.bestOf; + + const matches = props.bracket.data.match.filter( + (match) => match.round_id === round.id, + ); + + const someMatchOngoing = matches.some( + (match) => + match.opponent1 && + match.opponent2 && + match.opponent1.result !== "win" && + match.opponent2.result !== "win", + ); + + if ( + !props.isExpanded && + // always show at least 2 rounds per side + roundIdx < rounds.length - 2 && + !someMatchOngoing + ) { + return null; + } + + return ( +
+ +
+ {matches.map((match) => ( + + ))} +
+
+ ); + })} +
+ ); +} + +function getRounds(props: EliminationBracketSideProps) { + const groupIds = props.bracket.data.group.flatMap((group) => { + if (props.type === "winners" && group.number === 2) return []; + if (props.type === "losers" && group.number !== 2) return []; + + return group.id; + }); + + let showingBracketReset = true; + const rounds = props.bracket.data.round + .flatMap((round) => { + if ( + typeof round.group_id === "number" && + !groupIds.includes(round.group_id) + ) { + return []; + } + + return round; + }) + .filter((round, i, rounds) => { + const isBracketReset = + props.type === "winners" && i === rounds.length - 1; + const grandFinalsMatch = + props.type === "winners" + ? props.bracket.data.match.find( + (match) => match.round_id === rounds[rounds.length - 2]?.id, + ) + : undefined; + + if (isBracketReset && grandFinalsMatch?.opponent1?.result === "win") { + showingBracketReset = false; + return false; + } + + const matches = props.bracket.data.match.filter( + (match) => match.round_id === round.id, + ); + + const atLeastOneNonByeMatch = matches.some( + (m) => m.opponent1 && m.opponent2, + ); + + return atLeastOneNonByeMatch; + }); + + return rounds.map((round, i) => { + const name = () => { + if ( + showingBracketReset && + props.type === "winners" && + i === rounds.length - 2 + ) { + return "Grand Finals"; + } + if (props.type === "winners" && i === rounds.length - 1) { + return showingBracketReset ? "Bracket Reset" : "Grand Finals"; + } + + const namePrefix = + props.type === "winners" ? "WB " : props.type === "losers" ? "LB " : ""; + + const isFinals = i === rounds.length - (props.type === "winners" ? 3 : 1); + const isSemis = i === rounds.length - (props.type === "winners" ? 4 : 2); + + return `${namePrefix}${ + isFinals ? "Finals" : isSemis ? "Semis" : `Round ${i + 1}` + }`; + }; + + return { + ...round, + name: name(), + }; + }); +} diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx new file mode 100644 index 000000000..7bc71e08c --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -0,0 +1,250 @@ +import type { Unpacked } from "~/utils/types"; +import type { TournamentData } from "../../core/Tournament.server"; +import { + useStreamingParticipants, + useTournament, +} from "~/features/tournament/routes/to.$id"; +import { Link, useFetcher } from "@remix-run/react"; +import { tournamentMatchPage, tournamentStreamsPage } from "~/utils/urls"; +import clsx from "clsx"; +import { useUser } from "~/features/auth/core"; +import type { Bracket } from "../../core/Bracket"; +import { Popover } from "~/components/Popover"; +import * as React from "react"; +import type { TournamentStreamsLoader } from "~/features/tournament/routes/to.$id.streams"; +import { TournamentStream } from "~/features/tournament/components/TournamentStream"; + +interface MatchProps { + match: Unpacked; + isPreview?: boolean; + type?: "winners" | "losers" | "grands" | "groups"; + group?: string; + roundNumber: number; + showSimulation: boolean; + bracket: Bracket; +} + +export function Match(props: MatchProps) { + const isBye = !props.match.opponent1 || !props.match.opponent2; + + if (isBye) { + return
; + } + + return ( +
+ + + +
+ + +
+ ); +} + +function MatchHeader({ match, type, roundNumber, group }: MatchProps) { + const tournament = useTournament(); + const streamingParticipants = useStreamingParticipants(); + + const prefix = () => { + if (type === "winners") return "WB "; + if (type === "losers") return "LB "; + if (type === "grands") return "GF "; + if (type === "groups") return `${group}`; + return ""; + }; + + const isOver = + match.opponent1?.result === "win" || match.opponent2?.result === "win"; + const hasStreams = () => { + if (isOver || !match.opponent1?.id || !match.opponent2?.id) return false; + if ( + tournament.ctx.castedMatchesInfo?.castedMatches.some( + (cm) => cm.matchId === match.id, + ) + ) { + return true; + } + + const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap( + (teamId) => + tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [], + ); + + return streamingParticipants.some((p) => matchParticipants.includes(p)); + }; + const toBeCasted = + !isOver && + tournament.ctx.castedMatchesInfo?.lockedMatches?.includes(match.id); + + return ( +
+
+ {prefix()} + {roundNumber}.{match.number} +
+ {hasStreams() ? ( + 🔴 LIVE} + triggerClassName="bracket__match__header__box bracket__match__header__box__button" + contentClassName="w-max" + placement="top" + > + + + ) : null} + {toBeCasted ? ( + ⚪ CAST} + triggerClassName="bracket__match__header__box bracket__match__header__box__button" + > + Match is scheduled to be casted + + ) : null} +
+ ); +} + +function MatchWrapper({ + match, + isPreview, + children, +}: MatchProps & { children: React.ReactNode }) { + const tournament = useTournament(); + + if (!isPreview) { + return ( + + {children} + + ); + } + + return
{children}
; +} + +function MatchRow({ + match, + side, + isPreview, + showSimulation, + bracket, +}: MatchProps & { side: 1 | 2 }) { + const user = useUser(); + const tournament = useTournament(); + + const opponentKey = `opponent${side}` as const; + const opponent = match[`opponent${side}`]; + + const score = () => { + if (!match.opponent1?.id || !match.opponent2?.id || isPreview) return null; + + return opponent!.score ?? 0; + }; + + const isLoser = opponent?.result === "loss"; + + const { team, simulated } = (() => { + if (opponent?.id) { + return { team: tournament.teamById(opponent.id), simulated: false }; + } + + const simulated = showSimulation + ? bracket.simulatedMatch(match.id) + : undefined; + const simulatedOpponent = simulated?.[opponentKey]; + + return simulatedOpponent?.id + ? { team: tournament.teamById(simulatedOpponent.id), simulated: true } + : { team: null, simulated: true }; + })(); + + const ownTeam = tournament.teamMemberOfByUser(user); + + return ( +
m.discordName).join(", ")} + > +
+ {team?.seed} +
+
+ {team?.name ?? "???"} +
{" "} +
{score()}
+
+ ); +} + +function MatchStreams({ match }: Pick) { + const tournament = useTournament(); + const fetcher = useFetcher(); + + React.useEffect(() => { + if (fetcher.state !== "idle" || fetcher.data) return; + fetcher.load(`/to/${tournament.ctx.id}/streams`); + }, [fetcher, tournament.ctx.id]); + + if (!fetcher.data || !match.opponent1?.id || !match.opponent2?.id) + return ( +
+ Loading streams... +
+ ); + + const castingAccount = tournament.ctx.castedMatchesInfo?.castedMatches.find( + (cm) => cm.matchId === match.id, + )?.twitchAccount; + + const matchParticipants = [match.opponent1.id, match.opponent2.id].flatMap( + (teamId) => tournament.teamById(teamId)?.members.map((m) => m.userId) ?? [], + ); + + const streamsOfThisMatch = fetcher.data.streams.filter( + (stream) => + (stream.userId && matchParticipants.includes(stream.userId)) || + stream.twitchUserName === castingAccount, + ); + + if (streamsOfThisMatch.length === 0) + return ( +
+ After all there seems to be no streams of this match. Check the{" "} + streams page{" "} + for all the available streams. +
+ ); + + return ( +
+ {streamsOfThisMatch.map((stream) => ( + + ))} +
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx new file mode 100644 index 000000000..60e86d846 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/RoundHeader.tsx @@ -0,0 +1,58 @@ +import clsx from "clsx"; +import { useIsMounted } from "~/hooks/useIsMounted"; +import { useDeadline } from "./useDeadline"; +import { useAutoRerender } from "~/hooks/useAutoRerender"; + +export function RoundHeader({ + roundId, + name, + bestOf, + showInfos, +}: { + roundId: number; + name: string; + bestOf?: 3 | 5 | 7; + showInfos?: boolean; +}) { + const hasDeadline = !["WB Finals", "Grand Finals", "Bracket Reset"].includes( + name, + ); + + return ( +
+
{name}
+ {showInfos && bestOf ? ( +
+
Bo{bestOf}
+ {hasDeadline ? : null} +
+ ) : ( +
+ Hidden +
+ )} +
+ ); +} + +function Deadline({ roundId, bestOf }: { roundId: number; bestOf: 3 | 5 | 7 }) { + useAutoRerender("ten seconds"); + const isMounted = useIsMounted(); + const deadline = useDeadline(roundId, bestOf); + + if (!deadline) return null; + + return ( +
+ DL{" "} + {deadline.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "numeric", + })} +
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx new file mode 100644 index 000000000..0b22dc63a --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/RoundRobin.tsx @@ -0,0 +1,270 @@ +import { useTournament } from "~/features/tournament/routes/to.$id"; +import type { Bracket as BracketType } from "../../core/Bracket"; +import { RoundHeader } from "./RoundHeader"; +import { Match } from "./Match"; +import type { Match as MatchType } from "~/modules/brackets-model"; +import { logger } from "~/utils/logger"; +import clsx from "clsx"; +import { Link } from "@remix-run/react"; +import { tournamentTeamPage } from "~/utils/urls"; + +export function RoundRobinBracket({ bracket }: { bracket: BracketType }) { + const groups = getGroups(bracket); + const tournament = useTournament(); + + return ( +
+ {groups.map(({ groupName, groupId }) => { + const rounds = bracket.data.round.filter((r) => r.group_id === groupId); + + const allMatchesFinished = rounds.every((round) => { + const matches = bracket.data.match.filter( + (match) => match.round_id === round.id, + ); + + return matches.every( + (match) => + match.opponent1?.result === "win" || + match.opponent2?.result === "win", + ); + }); + + return ( +
+

{groupName}

+
+ {rounds.flatMap((round) => { + const bestOf = tournament.ctx.bestOfs.find( + ({ roundId }) => roundId === round.id, + )?.bestOf; + + const matches = bracket.data.match.filter( + (match) => match.round_id === round.id, + ); + + const someMatchOngoing = matches.some( + (match) => + match.opponent1 && + match.opponent2 && + match.opponent1.result !== "win" && + match.opponent2.result !== "win", + ); + + return ( +
+ +
+ {matches.map((match) => { + if (!match.opponent1 || !match.opponent2) { + return null; + } + + return ( + + ); + })} +
+
+ ); + })} +
+ +
+ ); + })} +
+ ); +} + +function getGroups(bracket: BracketType) { + const result: Array<{ + groupName: string; + matches: MatchType[]; + groupId: number; + }> = []; + + for (const group of bracket.data.group) { + const matches = bracket.data.match.filter( + (match) => match.group_id === group.id, + ); + + const numberToLetter = (n: number) => + String.fromCharCode(65 + n - 1).toUpperCase(); + + result.push({ + groupName: `Group ${numberToLetter(group.number)}`, + matches, + groupId: group.id, + }); + } + + return result; +} + +function PlacementsTable({ + groupId, + bracket, + allMatchesFinished, +}: { + groupId: number; + bracket: BracketType; + allMatchesFinished: boolean; +}) { + const _standings = bracket + .currentStandings(true) + .filter((s) => s.groupId === groupId); + + const missingTeams = bracket.data.match.reduce((acc, cur) => { + if (cur.group_id !== groupId) return acc; + + if ( + cur.opponent1?.id && + !_standings.some((s) => s.team.id === cur.opponent1!.id) && + !acc.includes(cur.opponent1.id) + ) { + acc.push(cur.opponent1.id); + } + + if ( + cur.opponent2?.id && + !_standings.some((s) => s.team.id === cur.opponent2!.id) && + !acc.includes(cur.opponent2.id) + ) { + acc.push(cur.opponent2.id); + } + + return acc; + }, [] as number[]); + + const standings = _standings + .concat( + missingTeams.map((id) => ({ + team: bracket.tournament.teamById(id)!, + stats: { + mapLosses: 0, + mapWins: 0, + points: 0, + setLosses: 0, + setWins: 0, + winsAgainstTied: 0, + }, + placement: Math.max(..._standings.map((s) => s.placement)) + 1, + groupId, + })), + ) + .sort((a, b) => { + if (a.placement === b.placement && a.team.seed && b.team.seed) { + return a.team.seed - b.team.seed; + } + + return a.placement - b.placement; + }); + + const destinationBracket = (placement: number) => + bracket.tournament.brackets.find( + (b) => + b.id !== bracket.id && + b.sources?.some( + (s) => s.bracketIdx === 0 && s.placements.includes(placement), + ), + ); + + return ( + + + + + + + + + + + + + {standings.map((s, i) => { + const stats = s.stats!; + if (!stats) { + logger.error("No stats for team", s.team); + return null; + } + + const team = bracket.tournament.teamById(s.team.id); + + const dest = destinationBracket(i + 1); + + return ( + + + + + + + + {dest ? ( + + ) : null} + + ); + })} + +
Team + W/L + + TB + + W/L (M) + + Scr + Seed +
+ + {s.team.name} + + + + {stats.setWins}/{stats.setLosses} + + + {stats.winsAgainstTied} + + + {stats.mapWins}/{stats.mapLosses} + + + {stats.points} + {team?.seed} + → {dest.name} +
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/bracket.css b/app/features/tournament-bracket/components/Bracket/bracket.css new file mode 100644 index 000000000..9eea38c96 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/bracket.css @@ -0,0 +1,162 @@ +.bracket { + --match-width: 140px; + --match-height: 55px; + overflow-x: auto; + display: flex; + flex-direction: column; + gap: var(--s-8); + padding-block-end: var(--s-6); +} + +.bracket__match__header { + position: absolute; + display: flex; + justify-content: space-between; + width: var(--match-width); + margin-block-start: -16px; +} + +.bracket__match__header__box { + background-color: var(--bg-lightest-solid); + padding: var(--s-0-5) var(--s-1); + border-radius: var(--rounded-sm); + font-size: var(--fonts-xxxs) !important; + font-weight: var(--semi-bold); + color: var(--text); + border: 0; +} + +.bracket__match__header__box__button { + height: 18.86px; +} + +.bracket__match { + width: var(--match-width); + min-height: var(--match-height); + max-height: var(--match-height); + border-radius: var(--rounded-sm); + background-color: var(--bg-light-variation); + font-size: var(--fonts-xxs); + font-weight: var(--semi-bold); + padding: 0 var(--s-2); + display: flex; + flex-direction: column; + gap: var(--s-1); + color: var(--text-main); + justify-content: center; + transition: background-color 0.2s; +} + +a.bracket__match:hover { + background-color: var(--bg-lighter); + border-radius: var(--rounded-sm); +} + +.bracket__match__separator { + min-height: 2px; + max-height: 2px; + width: 100%; + background-color: var(--bg-lighter); +} + +.bracket__match__bye { + visibility: hidden; + min-height: var(--match-height); + max-height: var(--match-height); +} + +.bracket__match__seed { + color: var(--theme); + margin-inline-end: var(--s-0-5); + min-width: 15px; + max-width: 15px; +} + +.bracket__match__team-name { + max-width: 95px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.bracket__match__score { + margin-inline-start: auto; +} + +.elim-bracket__container { + --line-width: 30px; + display: grid; + grid-template-columns: repeat( + var(--round-count), + calc(var(--match-width) + var(--line-width)) + ); +} + +.elim-bracket__round-matches-container { + display: flex; + flex-direction: column; + justify-content: space-around; + gap: var(--s-7); + margin-top: var(--s-6); + flex: 1; +} + +.elim-bracket__round-matches-container__top-bye { + margin-top: -18px; +} + +.elim-bracket__round-header { + text-align: center; + background-color: var(--bg-lightest); + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + padding-block: var(--s-2); + width: var(--match-width); + border-radius: var(--rounded-sm); +} + +.elim-bracket__round-header__infos { + width: var(--match-width); + display: flex; + justify-content: space-between; + font-size: var(--fonts-xxs); + color: var(--text-lighter); + font-weight: var(--semi-bold); +} + +.elim-bracket__round-column { + display: flex; + flex-direction: column; +} + +.rr__placements-table { + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + min-width: max-content; + overflow-x: auto; + max-width: 600px; +} + +.rr__placements-table thead { + color: var(--text-lighter); +} + +.rr__placements-table th { + text-align: left; +} + +.rr__placements-table th abbr { + padding-inline: var(--s-2); +} + +.rr__placements-table td span { + padding-inline: var(--s-2); +} + +.rr__placements-table tbody tr:nth-child(odd) { + background-color: var(--bg-lighter); +} + +.rr__placements-table tbody tr:nth-child(even) { + background-color: var(--bg-lightest); +} diff --git a/app/features/tournament-bracket/components/Bracket/index.tsx b/app/features/tournament-bracket/components/Bracket/index.tsx new file mode 100644 index 000000000..a7d1f8710 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/index.tsx @@ -0,0 +1,51 @@ +import { useBracketExpanded } from "~/features/tournament/routes/to.$id"; +import type { Bracket as BracketType } from "../../core/Bracket"; +import { EliminationBracketSide } from "./Elimination"; +import { RoundRobinBracket } from "./RoundRobin"; + +export function Bracket({ bracket }: { bracket: BracketType }) { + const { bracketExpanded } = useBracketExpanded(); + + if (bracket.type === "round_robin") { + return ( + + + + ); + } + + if (bracket.type === "single_elimination") { + return ( + + + + ); + } + + return ( + + + + + ); +} + +function BracketContainer({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/features/tournament-bracket/components/Bracket/useDeadline.ts b/app/features/tournament-bracket/components/Bracket/useDeadline.ts new file mode 100644 index 000000000..08c596a19 --- /dev/null +++ b/app/features/tournament-bracket/components/Bracket/useDeadline.ts @@ -0,0 +1,160 @@ +import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + databaseTimestampToDate, + dateToDatabaseTimestamp, +} from "~/utils/dates"; +import type { Bracket } from "../../core/Bracket"; +import type { Round } from "~/modules/brackets-model"; +import { logger } from "~/utils/logger"; + +const MINUTES = { + BO3: 30, + BO5: 40, + BO7: 50, +}; + +export function useDeadline(roundId: number, bestOf: 3 | 5 | 7) { + const tournament = useTournament(); + + 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" && 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 === "round_robin") { + dl = dateByManyPreviousRounds(bracket, round); + } else { + dl = dateByPreviousRoundAndWinners(bracket, round); + } + } + + if (!dl) return null; + + dl.setMinutes(dl.getMinutes() + MINUTES[`BO${bestOf}`]); + + 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) { + 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 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/CastInfo.tsx b/app/features/tournament-bracket/components/CastInfo.tsx new file mode 100644 index 000000000..259f3ad8f --- /dev/null +++ b/app/features/tournament-bracket/components/CastInfo.tsx @@ -0,0 +1,134 @@ +import { useFetcher } from "@remix-run/react"; +import { InfoPopover } from "~/components/InfoPopover"; +import { SubmitButton } from "~/components/SubmitButton"; +import { LockIcon } from "~/components/icons/Lock"; +import { UnlockIcon } from "~/components/icons/Unlock"; +import { useUser } from "~/features/auth/core"; +import { useTournament } from "~/features/tournament/routes/to.$id"; + +const lockingInfo = + "You can lock the match to indicate that it should not be started before the cast is ready. Match being locked prevents score reporting and hides the map list till the organizer/streamer unlocks it."; +const setAsCastedInfo = + "Select the Twitch account that is currently casting this match. It is then indicated in the bracket view."; + +export function CastInfo({ + matchIsOngoing, + matchId, + hasBothParticipants, + matchIsOver, +}: { + matchIsOngoing: boolean; + matchId: number; + hasBothParticipants: boolean; + matchIsOver: boolean; +}) { + const user = useUser(); + const tournament = useTournament(); + + const castedMatchesInfo = tournament.ctx.castedMatchesInfo; + const castTwitchAccounts = tournament.ctx.castTwitchAccounts ?? []; + const currentlyCastedOn = castedMatchesInfo?.castedMatches.find( + (cm) => cm.matchId === matchId, + )?.twitchAccount; + const isLocked = castedMatchesInfo?.lockedMatches?.includes(matchId); + + const hasPerms = tournament.isOrganizerOrStreamer(user); + + if (castTwitchAccounts.length === 0 || !hasPerms || matchIsOver) return null; + + // match has to be locked beforehand, can't be done when both participants are there already + if (!hasBothParticipants && !isLocked) { + return ( + } + infoText={lockingInfo} + /> + ); + } + + // if for some reason match is locked in the DB but also has scores reported then the UI + // will act as if it's not locked at all + if (!matchIsOngoing && isLocked) { + return ( + } + infoText={lockingInfo} + /> + ); + } + + return ( + + + + ); +} + +function CastInfoWrapper({ + children, + icon, + submitButtonText, + _action, + infoText, +}: { + children?: React.ReactNode; + icon?: JSX.Element; + submitButtonText?: string; + _action?: string; + infoText?: string; +}) { + const fetcher = useFetcher(); + + return ( +
+ +
+ Cast +
+ +
+ {children ? ( +
+ {children} +
+ ) : null} + {submitButtonText && _action ? ( + + {submitButtonText} + + ) : null} +
+
+ {infoText ? {infoText} : null} +
+ ); +} diff --git a/app/features/tournament-bracket/components/ScoreReporter.tsx b/app/features/tournament-bracket/components/ScoreReporter.tsx index e506e57e4..1d0499957 100644 --- a/app/features/tournament-bracket/components/ScoreReporter.tsx +++ b/app/features/tournament-bracket/components/ScoreReporter.tsx @@ -18,6 +18,7 @@ import { modeImageUrl, stageImageUrl } from "~/utils/urls"; import { type TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid"; import { mapCountPlayedInSetWithCertainty, + matchIsLocked, resolveHostingTeam, resolveRoomPass, } from "../tournament-bracket-utils"; @@ -62,6 +63,10 @@ export function ScoreReporter({ const showFullInfos = !presentational && type === "EDIT"; + const isMemberOfTeamParticipating = data.match.players.some( + (p) => p.id === user?.id, + ); + const roundInfos = [ showFullInfos ? ( <> @@ -110,21 +115,33 @@ export function ScoreReporter({ stage={currentStageWithMode} infos={roundInfos} teams={teams} + matchIsLocked={matchIsLocked({ + matchId: data.match.id, + scores: [scoreOne, scoreTwo], + tournament, + })} > - {currentPosition > 0 && !presentational && type === "EDIT" && ( -
- -
- - {t("tournament:match.action.undoLastScore")} - -
-
- )} + {currentPosition > 0 && + !presentational && + type === "EDIT" && + (tournament.isOrganizer(user) || isMemberOfTeamParticipating) && ( +
+ +
+ + {t("tournament:match.action.undoLastScore")} + +
+
+ )} {tournament.isOrganizer(user) && tournament.matchCanBeReopened(data.match.id) && presentational && ( @@ -178,11 +195,13 @@ function FancyStageBanner({ infos, children, teams, + matchIsLocked, }: { stage: TournamentMapListMap; infos?: (JSX.Element | null)[]; children?: React.ReactNode; teams: [TournamentDataTeam, TournamentDataTeam]; + matchIsLocked: boolean; }) { const { t } = useTranslation(["game-misc", "tournament"]); @@ -212,33 +231,45 @@ function FancyStageBanner({ return ( <> -
-
-

- - - {t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "} - {t(`game-misc:STAGE_${stage.stageId}`)} - - - {t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "} - {t(`game-misc:STAGE_${stage.stageId}`)} - -

-

{pickInfoText()}

+ {matchIsLocked ? ( +
+
+
+ Match locked to be casted +
+
Please wait for staff to unlock
+
- {children} -
+ ) : ( +
+
+

+ + + {t(`game-misc:MODE_SHORT_${stage.mode}`)}{" "} + {t(`game-misc:STAGE_${stage.stageId}`)} + + + {t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "} + {t(`game-misc:STAGE_${stage.stageId}`)} + +

+

{pickInfoText()}

+
+ {children} +
+ )} {infos && (
{infos.filter(Boolean).map((info, i) => ( @@ -341,6 +372,7 @@ function MatchActionSectionTabs({ }, [data, tournament]); const showChat = + !tournament.ctx.isFinalized && data.match.chatCode && (data.match.players.some((p) => p.id === user?.id) || tournament.isOrganizerOrStreamer(user)); @@ -376,7 +408,7 @@ function MatchActionSectionTabs({ const currentPosition = scores[0] + scores[1]; return ( - + {children} diff --git a/app/features/tournament-bracket/components/ScoreReporterRosters.tsx b/app/features/tournament-bracket/components/ScoreReporterRosters.tsx index a11aef188..53eed1e4f 100644 --- a/app/features/tournament-bracket/components/ScoreReporterRosters.tsx +++ b/app/features/tournament-bracket/components/ScoreReporterRosters.tsx @@ -12,6 +12,7 @@ import { stageImageUrl } from "~/utils/urls"; import { Image } from "~/components/Image"; import type { TournamentDataTeam } from "../core/Tournament.server"; import { useTournament } from "~/features/tournament/routes/to.$id"; +import { matchIsLocked } from "../tournament-bracket-utils"; export function ScoreReporterRosters({ teams, @@ -92,6 +93,11 @@ export function ScoreReporterRosters({ winnerName={winningTeam()} currentStageWithMode={currentStageWithMode} wouldEndSet={wouldEndSet} + matchLocked={matchIsLocked({ + matchId: data.match.id, + scores: scores, + tournament, + })} />
) : null} @@ -160,6 +166,7 @@ function ReportScoreButtons({ winnerName, currentStageWithMode, wouldEndSet, + matchLocked, }: { points?: [number, number]; winnerIdx?: number; @@ -167,9 +174,18 @@ function ReportScoreButtons({ winnerName?: string; currentStageWithMode: TournamentMapListMap; wouldEndSet: boolean; + matchLocked: boolean; }) { const { t } = useTranslation(["game-misc"]); + if (matchLocked) { + return ( +

+ Match is pending to be casted. Please wait a bit +

+ ); + } + if (checkedPlayers.some((team) => team.length === 0)) { return (

@@ -185,7 +201,7 @@ function ReportScoreButtons({ ) { return (

- Winner should have more points than loser + Winner should have higher score than loser

); } @@ -197,7 +213,7 @@ function ReportScoreButtons({ ) { return (

- If there was a KO (100 points), other team should have 0 points + If there was a KO (100 score), other team should have 0 score

); } diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx index e95186750..d098499e8 100644 --- a/app/features/tournament-bracket/components/TeamRosterInputs.tsx +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -62,7 +62,7 @@ export function TeamRosterInputs({ const showWinnerRadio = !points || !presentational || winnerRadioChecked; - const seed = tournament.seedByTeamId(team.id); + const seed = tournament.teamById(team.id)?.seed; return (
@@ -84,7 +84,7 @@ export function TeamRosterInputs({ ) : null}{" "}
); diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index ceaa13aad..868da6432 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -8,6 +8,8 @@ import type { TournamentDataTeam } from "./Tournament.server"; import { removeDuplicates } from "~/utils/arrays"; import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; import { logger } from "~/utils/logger"; +import type { Round } from "~/modules/brackets-model"; +import { getTournamentManager } from ".."; interface CreateBracketArgs { id: number; @@ -18,6 +20,7 @@ interface CreateBracketArgs { name: string; teamsPendingCheckIn?: number[]; tournament: Tournament; + createdAt: number | null; sources?: { bracketIdx: number; placements: number[]; @@ -27,17 +30,28 @@ interface CreateBracketArgs { 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; + winsAgainstTied: number; + }; } export abstract class Bracket { id; preview; data; + simulatedData: ValueToArray | undefined; canBeStarted; name; teamsPendingCheckIn; tournament; sources; + createdAt; constructor({ id, @@ -48,6 +62,7 @@ export abstract class Bracket { teamsPendingCheckIn, tournament, sources, + createdAt, }: Omit) { this.id = id; this.preview = preview; @@ -57,6 +72,129 @@ export abstract class Bracket { this.teamsPendingCheckIn = teamsPendingCheckIn; this.tournament = tournament; this.sources = sources; + this.createdAt = createdAt; + + this.createdSimulation(); + } + + private createdSimulation() { + if ( + this.type === "round_robin" || + this.preview || + this.tournament.ctx.isFinalized + ) + return; + + try { + const manager = getTournamentManager("IN_MEMORY"); + + 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) + ) { + 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) { + console.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() { @@ -71,6 +209,14 @@ export abstract class Bracket { throw new Error("not implemented"); } + currentStandings(_includeUnfinishedGroups: boolean) { + return this.standings; + } + + winnersSourceRound(_roundNumber: number): Round | undefined { + return; + } + protected standingsWithoutNonParticipants(standings: Standing[]): Standing[] { return standings.map((standing) => { return { @@ -245,6 +391,19 @@ class DoubleEliminationBracket extends Bracket { return "double_elimination"; } + 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[] { const losersGroupId = this.data.group.find((g) => g.number === 2)?.id; @@ -515,6 +674,10 @@ class RoundRobinBracket extends Bracket { } get standings(): Standing[] { + return this.currentStandings(); + } + + currentStandings(includeUnfinishedGroups = false) { const groupIds = this.data.group.map((group) => group.id); const placements: (Standing & { groupId: number })[] = []; @@ -533,7 +696,7 @@ class RoundRobinBracket extends Bracket { match.opponent2?.result === "win", ); - if (!groupIsFinished) continue; + if (!groupIsFinished && !includeUnfinishedGroups) continue; const teams: { id: number; @@ -581,6 +744,13 @@ class RoundRobinBracket extends Bracket { }; 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; @@ -594,6 +764,7 @@ class RoundRobinBracket extends Bracket { typeof loser.id === "number" && typeof winner.score === "number" && typeof loser.score === "number", + "RoundRobinBracket.standings: winner or loser id not found", ); if ( @@ -661,8 +832,8 @@ class RoundRobinBracket extends Bracket { if (a.points > b.points) return -1; if (a.points < b.points) return 1; - const aSeed = Number(this.tournament.seedByTeamId(a.id)); - const bSeed = Number(this.tournament.seedByTeamId(b.id)); + 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; @@ -674,6 +845,14 @@ class RoundRobinBracket extends Bracket { 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, + }, }; }), ); @@ -702,6 +881,7 @@ class RoundRobinBracket extends Bracket { return { ...team, placement: currentPlacement, + stats: team.stats, }; }), ); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index fd2b19b49..7d7ccc1ce 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -18,6 +18,7 @@ import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils"; import type { Stage } from "~/modules/brackets-model"; import { Bracket } from "./Bracket"; import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; +import { currentSeason } from "~/features/mmr"; export type OptionalIdObject = { id: number } | undefined; @@ -42,7 +43,7 @@ export class Tournament { return 1; } - return a.createdAt - b.createdAt; + return this.compareUnseededTeams(a, b); }); this.ctx = { ...ctx, @@ -56,6 +57,31 @@ export class Tournament { this.initBrackets(data); } + private compareUnseededTeams( + a: TournamentData["ctx"]["teams"][number], + b: TournamentData["ctx"]["teams"][number], + ) { + const aPlus = a.members + .flatMap((a) => (a.plusTier ? [a.plusTier] : [])) + .sort((a, b) => a - b) + .slice(0, 4); + const bPlus = b.members + .flatMap((b) => (b.plusTier ? [b.plusTier] : [])) + .sort((a, b) => a - b) + .slice(0, 4); + + for (let i = 0; i < 4; i++) { + if (aPlus[i] && !bPlus[i]) return -1; + if (!aPlus[i] && bPlus[i]) return 1; + + if (aPlus[i] !== bPlus[i]) { + return aPlus[i] - bPlus[i]; + } + } + + return a.createdAt - b.createdAt; + } + private initBrackets(data: ValueToArray) { for (const [ bracketIdx, @@ -82,6 +108,7 @@ export class Tournament { preview: false, name, sources, + createdAt: inProgressStage.createdAt, data: { ...data, participant: data.participant.filter((participant) => @@ -117,7 +144,10 @@ export class Tournament { }); if (checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { - const seeding = checkedInTeams.map((team) => team.name); + const seeding = checkedInTeams.map((team) => ({ + name: team.name, + id: team.id, + })); manager.create({ tournamentId: this.ctx.id, name, @@ -139,6 +169,7 @@ export class Tournament { data: manager.get.tournamentData(this.ctx.id), type, sources, + createdAt: null, canBeStarted: checkedInTeams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START && (sources ? relevantMatchesFinished : this.regularCheckInHasEnded), @@ -233,6 +264,10 @@ export class Tournament { } } + get ranked() { + return Boolean(currentSeason(this.ctx.startTime)); + } + get logoSrc() { return HACKY_resolvePicture(this.ctx); } @@ -294,15 +329,11 @@ export class Tournament { } teamById(id: number) { - return this.ctx.teams.find((team) => team.id === id); - } + const teamIdx = this.ctx.teams.findIndex((team) => team.id === id); - seedByTeamId(id: number) { - const idx = this.ctx.teams.findIndex((team) => team.id === id); + if (teamIdx === -1) return; - if (idx === -1) return null; - - return idx + 1; + return { ...this.ctx.teams[teamIdx], seed: teamIdx + 1 }; } participatedPlayersByTeamId(id: number) { 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 8eda90fcb..977a8a52d 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 @@ -17,6 +17,7 @@ import type { TournamentTeam, } from "~/db/types"; import { nanoid } from "nanoid"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; const team_getByTournamentIdStm = sql.prepare(/*sql*/ ` select @@ -64,9 +65,9 @@ const stage_getByTournamentIdStm = sql.prepare(/*sql*/ ` const stage_insertStm = sql.prepare(/*sql*/ ` insert into "TournamentStage" - ("tournamentId", "number", "name", "type", "settings") + ("tournamentId", "number", "name", "type", "settings", "createdAt") values - (@tournamentId, @number, @name, @type, @settings) + (@tournamentId, @number, @name, @type, @settings, @createdAt) returning * `); @@ -110,6 +111,7 @@ export class Stage { name: this.name, type: this.type, settings: this.settings, + createdAt: dateToDatabaseTimestamp(new Date()), }) as any; this.id = stage.id; @@ -345,7 +347,8 @@ const match_getByStageIdStm = sql.prepare(/*sql*/ ` select "TournamentMatch".*, sum("TournamentMatchGameResult"."opponentOnePoints") as "opponentOnePointsTotal", - sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal" + sum("TournamentMatchGameResult"."opponentTwoPoints") as "opponentTwoPointsTotal", + max("TournamentMatchGameResult"."createdAt") as "lastGameFinishedAt" from "TournamentMatch" left join "TournamentMatchGameResult" on "TournamentMatch"."id" = "TournamentMatchGameResult"."matchId" where "TournamentMatch"."stageId" = @stageId @@ -362,16 +365,15 @@ const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ ` const match_insertStm = sql.prepare(/*sql*/ ` insert into "TournamentMatch" - ("childCount", "roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode") + ("roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status", "chatCode") values - (@childCount, @roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode) + (@roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status, @chatCode) returning * `); const match_updateStm = sql.prepare(/*sql*/ ` update "TournamentMatch" set - "childCount" = @childCount, "roundId" = @roundId, "stageId" = @stageId, "groupId" = @groupId, @@ -385,7 +387,6 @@ const match_updateStm = sql.prepare(/*sql*/ ` export class Match { id?: TournamentMatch["id"]; - childCount: TournamentMatch["childCount"]; roundId: TournamentMatch["roundId"]; stageId: TournamentMatch["stageId"]; groupId: TournamentMatch["groupId"]; @@ -401,7 +402,6 @@ export class Match { groupId: TournamentMatch["groupId"], roundId: TournamentMatch["roundId"], number: TournamentMatch["number"], - childCount: TournamentMatch["childCount"], _unknown1: null, _unknown2: null, _unknown3: null, @@ -409,7 +409,6 @@ export class Match { opponentTwo: TournamentMatch["opponentTwo"], ) { this.id = id; - this.childCount = childCount; this.roundId = roundId; this.stageId = stageId; this.groupId = groupId; @@ -423,11 +422,11 @@ export class Match { rawMatch: TournamentMatch & { opponentOnePointsTotal: number | null; opponentTwoPointsTotal: number | null; + lastGameFinishedAt: number | null; }, ): MatchType { return { id: rawMatch.id, - child_count: rawMatch.childCount, group_id: rawMatch.groupId, number: rawMatch.number, opponent1: @@ -447,6 +446,7 @@ export class Match { round_id: rawMatch.roundId, stage_id: rawMatch.stageId, status: rawMatch.status, + lastGameFinishedAt: rawMatch.lastGameFinishedAt, }; } @@ -473,7 +473,6 @@ export class Match { insert() { const match = match_insertStm.get({ - childCount: this.childCount, roundId: this.roundId, stageId: this.stageId, groupId: this.groupId, @@ -492,7 +491,6 @@ export class Match { update() { match_updateStm.run({ id: this.id, - childCount: this.childCount, roundId: this.roundId, stageId: this.stageId, groupId: this.groupId, diff --git a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts index e0be71072..d875fd24a 100644 --- a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts +++ b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts @@ -42,7 +42,6 @@ export class SqlDatabase { arg.group_id, arg.round_id, arg.number, - arg.child_count, null, null, null, @@ -50,8 +49,6 @@ export class SqlDatabase { JSON.stringify(arg.opponent2), ); return match.insert() && match.id; - - case "match_game": throw new Error("not implemented"); const matchGame = new MatchGame( undefined, @@ -185,8 +182,6 @@ export class SqlDatabase { } break; - - case "match_game": throw new Error("not implemented"); if (typeof arg === "number") { const game = MatchGame.getById(arg); @@ -230,7 +225,6 @@ export class SqlDatabase { update.group_id, update.round_id, update.number, - update.child_count, null, null, null, @@ -241,27 +235,7 @@ export class SqlDatabase { return match.update(); } - if (query.stage_id) - return Match.updateChildCountByStage( - query.stage_id, - update.child_count, - ); - - if (query.group_id) - return Match.updateChildCountByGroup( - query.group_id, - update.child_count, - ); - - if (query.round_id) - return Match.updateChildCountByRound( - query.round_id, - update.child_count, - ); - break; - - case "match_game": throw new Error("not implemented"); if (typeof query === "number") { const game = new MatchGame( @@ -326,8 +300,6 @@ export class SqlDatabase { Number.isInteger(filter.stage_id) && Match.deleteByStageId(filter.stage_id) ); - - case "match_game": if (Number.isInteger(filter.stage_id)) return MatchGame.deleteByStageId(filter.stage_id); if ( diff --git a/app/features/tournament-bracket/core/finalStandings.server.ts b/app/features/tournament-bracket/core/finalStandings.server.ts deleted file mode 100644 index 1d878796b..000000000 --- a/app/features/tournament-bracket/core/finalStandings.server.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { Tournament, TournamentStage, TournamentTeam } from "~/db/types"; -import type { BracketsManager } from "~/modules/brackets-manager"; -import type { FinalStandingsItem } from "~/modules/brackets-manager/types"; -import type { PlayerThatPlayedByTeamId } from "../queries/playersThatPlayedByTeamId.server"; -import { playersThatPlayedByTournamentId } from "../queries/playersThatPlayedByTeamId.server"; - -export interface FinalStanding { - tournamentTeam: Pick; - placement: number; // 1st, 2nd, 3rd, 4th, 5th, 5th... - players: PlayerThatPlayedByTeamId[]; -} - -const STANDINGS_TO_INCLUDE = 8; - -export function finalStandings({ - manager, - stageId, - tournamentId, - includeAll, -}: { - manager: BracketsManager; - stageId: TournamentStage["id"]; - tournamentId: Tournament["id"]; - includeAll?: boolean; -}): Array | null { - let standings: FinalStandingsItem[]; - try { - standings = manager.get.finalStandings(stageId); - } catch (e) { - if (!(e instanceof Error)) throw e; - - if (e.message.includes("The final match does not have a winner")) { - console.error(e); - return null; - } - - throw e; - } - if (!includeAll) { - standings = standings.slice(0, STANDINGS_TO_INCLUDE); - } - - const playersThatPlayed = playersThatPlayedByTournamentId(tournamentId); - - const result: Array = []; - - let lastRank = 1; - let currentPlacement = 1; - for (const [i, standing] of standings.entries()) { - if (lastRank !== standing.rank) { - lastRank = standing.rank; - currentPlacement = i + 1; - } - result.push({ - tournamentTeam: { - id: standing.id, - name: standing.name, - }, - placement: currentPlacement, - players: playersThatPlayed.filter( - (p) => p.tournamentTeamId === standing.id, - ), - }); - } - - return result; -} - -export function finalStandingOfTeam({ - manager, - tournamentId, - tournamentTeamId, - stageId, -}: { - manager: BracketsManager; - tournamentId: Tournament["id"]; - tournamentTeamId: TournamentTeam["id"]; - stageId: TournamentStage["id"]; -}) { - const standings = finalStandings({ - manager, - tournamentId, - includeAll: true, - stageId, - }); - if (!standings) return null; - - return ( - standings.find( - (standing) => standing.tournamentTeam.id === tournamentTeamId, - ) ?? null - ); -} diff --git a/app/features/tournament-bracket/core/mapList.server.ts b/app/features/tournament-bracket/core/mapList.server.ts index d1eba9e6f..38c33b83d 100644 --- a/app/features/tournament-bracket/core/mapList.server.ts +++ b/app/features/tournament-bracket/core/mapList.server.ts @@ -15,7 +15,10 @@ interface ResolveCurrentMapListArgs { } export function resolveMapList(args: ResolveCurrentMapListArgs) { - return syncCached(String(args.matchId), () => resolveFreshMapList(args)); + // include team ids in the key to handle a case where match was reopened causing one of the teams to change + return syncCached(`${args.matchId}-${args.teams[0]}-${args.teams[1]}`, () => + resolveFreshMapList(args), + ); } export function resolveFreshMapList(args: ResolveCurrentMapListArgs) { diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index b5dbf0694..4af3b21e0 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -22,6 +22,7 @@ const createTeam = (teamId: number, userIds: number[]): TournamentDataTeam => ({ inGameName: "test", isOwner: 0, plusTier: null, + createdAt: 0, userId, })), name: "Team " + teamId, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index f95fb0b44..800386653 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -11,7 +11,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ settings: { groupCount: 1, roundRobinMode: "simple", - matchesChildCount: 0, size: 4, seedOrdering: ["groups.seed_optimized"], }, @@ -51,7 +50,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 0, - child_count: 0, status: 2, opponent1: { id: 0, @@ -68,7 +66,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 0, - child_count: 0, status: 2, opponent1: { id: 2, @@ -85,7 +82,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 1, - child_count: 0, status: 2, opponent1: { id: 1, @@ -102,7 +98,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 1, - child_count: 0, status: 2, opponent1: { id: 0, @@ -119,7 +114,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 2, - child_count: 0, status: 2, opponent1: { id: 2, @@ -136,7 +130,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 2, - child_count: 0, status: 2, opponent1: { id: 1, @@ -148,7 +141,6 @@ export const FOUR_TEAMS_RR = (): ValueToArray => ({ }, }, ], - match_game: [], participant: [ { id: 0, @@ -185,7 +177,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ groupCount: 1, seedOrdering: ["groups.seed_optimized"], roundRobinMode: "simple", - matchesChildCount: 0, size: 5, }, }, @@ -236,7 +227,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 0, - child_count: 0, status: 2, opponent1: { id: 4, @@ -253,7 +243,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 1, - child_count: 0, status: 2, opponent1: { id: 0, @@ -270,7 +259,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 1, - child_count: 0, status: 2, opponent1: { id: 4, @@ -287,7 +275,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 2, - child_count: 0, status: 2, opponent1: { id: 1, @@ -304,7 +291,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 2, - child_count: 0, status: 2, opponent1: { id: 0, @@ -321,7 +307,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 3, - child_count: 0, status: 2, opponent1: { id: 2, @@ -338,7 +323,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 3, - child_count: 0, status: 2, opponent1: { id: 1, @@ -355,7 +339,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 4, - child_count: 0, status: 2, opponent1: { id: 3, @@ -372,7 +355,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 4, - child_count: 0, status: 2, opponent1: { id: 2, @@ -389,7 +371,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 0, - child_count: 0, status: 2, opponent1: { id: 3, @@ -401,7 +382,6 @@ export const FIVE_TEAMS_RR = (): ValueToArray => ({ }, }, ], - match_game: [], participant: [ { id: 0, @@ -443,7 +423,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ groupCount: 2, seedOrdering: ["groups.seed_optimized"], roundRobinMode: "simple", - matchesChildCount: 0, size: 6, }, }, @@ -505,7 +484,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 0, - child_count: 0, status: 2, opponent1: { id: 4, @@ -522,7 +500,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 1, - child_count: 0, status: 2, opponent1: { id: 0, @@ -539,7 +516,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 0, round_id: 2, - child_count: 0, status: 2, opponent1: { id: 3, @@ -556,7 +532,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 1, round_id: 3, - child_count: 0, status: 2, opponent1: { id: 5, @@ -573,7 +548,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 1, round_id: 4, - child_count: 0, status: 2, opponent1: { id: 1, @@ -590,7 +564,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ stage_id: 0, group_id: 1, round_id: 5, - child_count: 0, status: 2, opponent1: { id: 2, @@ -602,7 +575,6 @@ export const SIX_TEAMS_TWO_GROUPS_RR = (): ValueToArray => ({ }, }, ], - match_game: [], participant: [ { id: 0, diff --git a/app/features/tournament-bracket/core/tests/round-robin.test.ts b/app/features/tournament-bracket/core/tests/round-robin.test.ts index 8c022d238..aea1267ad 100644 --- a/app/features/tournament-bracket/core/tests/round-robin.test.ts +++ b/app/features/tournament-bracket/core/tests/round-robin.test.ts @@ -12,7 +12,7 @@ const roundRobinTournamentCtx: Partial = { bracketProgression: [{ name: BRACKET_NAMES.GROUPS, type: "round_robin" }], }, inProgressBrackets: [ - { id: 0, type: "round_robin", name: BRACKET_NAMES.GROUPS }, + { id: 0, type: "round_robin", name: BRACKET_NAMES.GROUPS, createdAt: 0 }, ], }; diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 20b5e5db0..d577bda9d 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -57,7 +57,9 @@ export const testTournament = ( id: stage.id, name: stage.name, type: stage.type, + createdAt: 0, })), + castedMatchesInfo: null, bestOfs: data.round.map((round) => ({ bestOf: 3, roundId: round.id })), teams: nTeams( data.participant.length, diff --git a/app/features/tournament-bracket/index.ts b/app/features/tournament-bracket/index.ts index a7fbddb83..92ae84570 100644 --- a/app/features/tournament-bracket/index.ts +++ b/app/features/tournament-bracket/index.ts @@ -1,4 +1,3 @@ export { everyMatchIsOver } from "./tournament-bracket-utils"; export { getTournamentManager } from "./core/brackets-manager"; -export { finalStandingOfTeam } from "./core/finalStandings.server"; export { findMapPoolByTeamId } from "./queries/findMapPoolByTeamId.server"; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index bf8d917da..562ef93da 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -1,11 +1,5 @@ import type { ActionFunction, LinksFunction } from "@remix-run/node"; -import { - Form, - Link, - useFetcher, - useNavigate, - useRevalidator, -} from "@remix-run/react"; +import { Form, Link, useFetcher, useRevalidator } from "@remix-run/react"; import clsx from "clsx"; import * as React from "react"; import { useTranslation } from "react-i18next"; @@ -19,7 +13,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Popover } from "~/components/Popover"; import { SubmitButton } from "~/components/SubmitButton"; import { sql } from "~/db/sql"; -import { Status } from "~/db/types"; import { requireUser, useUser } from "~/features/auth/core"; import { currentSeason, @@ -38,12 +31,13 @@ import { SENDOU_INK_BASE_URL, tournamentBracketsSubscribePage, tournamentJoinPage, - tournamentMatchPage, tournamentTeamPage, userPage, } from "~/utils/urls"; -import { useTournament } from "../../tournament/routes/to.$id"; -import bracketViewerStyles from "../brackets-viewer.css"; +import { + useBracketExpanded, + useTournament, +} from "../../tournament/routes/to.$id"; import { tournamentFromDB } from "../core/Tournament.server"; import { resolveBestOfs } from "../core/bestOf.server"; import { getTournamentManager } from "../core/brackets-manager"; @@ -58,27 +52,27 @@ import { fillWithNullTillPowerOfTwo, } from "../tournament-bracket-utils"; import bracketStyles from "../tournament-bracket.css"; +import bracketComponentStyles from "../components/Bracket/bracket.css"; import type { Standing } from "../core/Bracket"; import { removeDuplicates } from "~/utils/arrays"; import { Placement } from "~/components/Placement"; import { Avatar } from "~/components/Avatar"; import { Flag } from "~/components/Flag"; import { BRACKET_NAMES } from "~/features/tournament/tournament-constants"; +import { Bracket } from "../components/Bracket"; +import { EyeIcon } from "~/components/icons/Eye"; +import { EyeSlashIcon } from "~/components/icons/EyeSlash"; export const links: LinksFunction = () => { return [ - { - rel: "stylesheet", - href: "https://cdn.jsdelivr.net/npm/brackets-viewer@1.5.1/dist/brackets-viewer.min.css", - }, - { - rel: "stylesheet", - href: bracketViewerStyles, - }, { rel: "stylesheet", href: bracketStyles, }, + { + rel: "stylesheet", + href: bracketComponentStyles, + }, ]; }; @@ -207,8 +201,6 @@ export default function TournamentBracketsPage() { name: "idx", revive: Number, }); - const ref = React.useRef(null); - const navigate = useNavigate(); const tournament = useTournament(); const bracket = React.useMemo( @@ -216,91 +208,6 @@ export default function TournamentBracketsPage() { [tournament, bracketIdx], ); - // TODO: bracket i18n - React.useEffect(() => { - if (!bracket.enoughTeams) return; - - // matches aren't generated before tournament starts - if (!bracket.preview) { - // @ts-expect-error - brackets-viewer is not typed - window.bracketsViewer.onMatchClicked = (match) => { - // can't view match page of a bye - if (match.opponent1 === null || match.opponent2 === null) { - return; - } - navigate( - tournamentMatchPage({ - eventId: tournament.ctx.id, - matchId: match.id, - }), - ); - }; - } - - // @ts-expect-error - brackets-viewer is not typed - window.bracketsViewer.render( - { - stages: bracket.data.stage, - matches: bracket.data.match, - matchGames: bracket.data.match_game, - participants: bracket.data.participant, - }, - { - customRoundName: (info: any) => { - if (info.groupType === "final-group" && info.roundNumber === 1) { - return "Grand Finals"; - } - if (info.groupType === "final-group" && info.roundNumber === 2) { - return "Bracket Reset"; - } - - return undefined; - }, - separatedChildCountLabel: true, - }, - ); - - // my beautiful hack to show seeds - // clean up probably not needed as it's not harmful to append more than one - const cssRulesToAppend = tournament.ctx.teams.map((team, i) => { - const participantId = tournament.hasStarted ? team.id : i; - return /* css */ ` - [data-participant-id="${participantId}"] { - --seed: "${i + 1} "; - --space-after-seed: ${i < 9 ? "6px" : "0px"}; - } - `; - }); - - const ownTeam = tournament.teamMemberOfByUser(user); - if (ownTeam) { - cssRulesToAppend.push(/* css */ ` - [title="${ownTeam.name}"] { - --team-text-color: var(--theme-secondary); - } - `); - } - if (tournament.ctx.bestOfs) { - for (const { bestOf, roundId } of tournament.ctx.bestOfs) { - cssRulesToAppend.push(/* css */ ` - [data-round-id="${roundId}"] { - --best-of-text: "Bo${bestOf}"; - } - `); - } - } - appendStyleTagToHead(cssRulesToAppend.join("\n")); - - const element = ref.current; - return () => { - if (!element) return; - - element.innerHTML = ""; - // @ts-expect-error - brackets-viewer is not typed - window.bracketsViewer!.onMatchClicked = () => {}; - }; - }, [navigate, bracket, tournament, user]); - React.useEffect(() => { if (visibility !== "visible" || tournament.everyBracketOver) return; @@ -428,12 +335,15 @@ export default function TournamentBracketsPage() { {tournament.ctx.isFinalized || tournament.canFinalize(user) ? ( ) : null} - -
+
+
+ + {bracket.type !== "round_robin" && !bracket.preview ? ( + + ) : null} +
+ {bracket.enoughTeams ? : null} +
{!bracket.enoughTeams ? (
@@ -456,16 +366,6 @@ function AutoRefresher() { return null; } -function appendStyleTagToHead(content: string) { - const head = document.head || document.getElementsByTagName("head")[0]; - const style = document.createElement("style"); - - head.appendChild(style); - - style.type = "text/css"; - style.appendChild(document.createTextNode(content)); -} - function useAutoRefresh() { const { revalidate } = useRevalidator(); const tournament = useTournament(); @@ -479,31 +379,8 @@ function useAutoRefresh() { React.useEffect(() => { if (!lastEvent) return; - const [matchIdRaw, scoreOneRaw, scoreTwoRaw, isOverRaw] = - lastEvent.split("-"); - const matchId = Number(matchIdRaw); - const scoreOne = Number(scoreOneRaw); - const scoreTwo = Number(scoreTwoRaw); - const isOver = isOverRaw === "true"; - - if (isOver) { - // bracketsViewer.updateMatch can't advance bracket - // so we revalidate loader when the match is over - revalidate(); - } else { - // TODO: shows 1 - "-" when updating match where other score is 0 - // @ts-expect-error - brackets-viewer is not typed - window.bracketsViewer.updateMatch({ - id: matchId, - opponent1: { - score: scoreOne, - }, - opponent2: { - score: scoreTwo, - }, - status: Status.Running, - }); - } + // TODO: maybe later could look into not revalidating unless bracket advanced but do something fancy in the tournament class instead + revalidate(); }, [lastEvent, revalidate]); } @@ -538,7 +415,7 @@ function AddSubsPopOver() { tournament.maxTeamMemberCount - ownedTeam.members.length; const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({ - eventId: tournament.ctx.id, + tournamentId: tournament.ctx.id, inviteCode: ownedTeam.inviteCode, })}`; @@ -571,12 +448,16 @@ function AddSubsPopOver() { ); } +const MAX_PLACEMENT_TO_SHOW = 7; + function FinalStandings() { const tournament = useTournament(); const { t } = useTranslation(["tournament"]); const [viewAll, setViewAll] = React.useState(false); - const standings = tournament.standings; + const standings = tournament.standings.filter( + (s) => s.placement <= MAX_PLACEMENT_TO_SHOW, + ); if (standings.length < 2) { console.error("Unexpectedly few standings"); @@ -613,7 +494,7 @@ function FinalStandings() {
+
{tournament.ctx.settings.bracketProgression.map((bracket, i) => { // underground bracket was never played despite being in the format if ( @@ -762,17 +643,32 @@ function BracketNav({ return ( ); })}
); } + +function CompactifyButton() { + const { bracketExpanded, setBracketExpanded } = useBracketExpanded(); + + return ( + + ); +} 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 8634f0f4b..16df653c6 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -42,9 +42,12 @@ import { matchSchema } from "../tournament-bracket-schemas.server"; import { bracketSubscriptionKey, matchIdFromParams, + matchIsLocked, matchSubscriptionKey, } from "../tournament-bracket-utils"; import bracketStyles from "../tournament-bracket.css"; +import { CastInfo } from "../components/CastInfo"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; export const links: LinksFunction = () => [ { @@ -103,6 +106,10 @@ export const action: ActionFunction = async ({ params, request }) => { "Winner team id is invalid", ); validate(match.opponentOne && match.opponentTwo, "Teams are missing"); + validate( + !matchIsLocked({ matchId: match.id, tournament, scores }), + "Match is locked", + ); const mapList = match.opponentOne?.id && match.opponentTwo?.id @@ -259,6 +266,42 @@ export const action: ActionFunction = async ({ params, request }) => { break; } + case "SET_AS_CASTED": { + validate(tournament.isOrganizerOrStreamer(user)); + + await TournamentRepository.setMatchAsCasted({ + matchId: match.id, + tournamentId: tournament.ctx.id, + twitchAccount: data.twitchAccount, + }); + + break; + } + case "LOCK": { + validate(tournament.isOrganizerOrStreamer(user)); + + // can't lock, let's update their view to reflect that + if (match.opponentOne?.id && match.opponentTwo?.id) { + return null; + } + + await TournamentRepository.lockMatch({ + matchId: match.id, + tournamentId: tournament.ctx.id, + }); + + break; + } + case "UNLOCK": { + validate(tournament.isOrganizerOrStreamer(user)); + + await TournamentRepository.unlockMatch({ + matchId: match.id, + tournamentId: tournament.ctx.id, + }); + + break; + } default: { assertUnreachable(data); } @@ -364,20 +407,35 @@ export default function TournamentMatchPage() { Back to bracket
- {data.matchIsOver ? : null} - {!data.matchIsOver && - typeof data.match.opponentOne?.id === "number" && - typeof data.match.opponentTwo?.id === "number" ? ( - + 0) || + (data.match.opponentTwo?.score && + data.match.opponentTwo.score > 0), + )} + matchIsOver={data.matchIsOver} + matchId={data.match.id} + hasBothParticipants={Boolean( + data.match.opponentOne?.id && data.match.opponentTwo?.id, + )} /> - ) : null} - {showRosterPeek() ? ( - - ) : null} + {data.matchIsOver ? : null} + {!data.matchIsOver && + typeof data.match.opponentOne?.id === "number" && + typeof data.match.opponentTwo?.id === "number" ? ( + + ) : null} + {showRosterPeek() ? ( + + ) : null} +
); } @@ -394,7 +452,7 @@ function useAutoRefresh() { const data = useLoaderData(); const lastEventId = useEventSource( tournamentMatchSubscribePage({ - eventId: tournament.ctx.id, + tournamentId: tournament.ctx.id, matchId: data.match.id, }), { @@ -479,6 +537,8 @@ function ResultsSection() { ); } +const INACTIVE_PLAYER_CSS = + "tournament__team-with-roster__member__inactive text-lighter-important"; function Rosters({ teams, }: { @@ -496,6 +556,13 @@ function Rosters({ (p) => p.tournamentTeamId === teamTwo?.id, ); + const teamOneParticipatedPlayers = teamOnePlayers.filter((p) => + tournament.ctx.participatedUsers.includes(p.id), + ); + const teamTwoParticipatedPlayers = teamTwoPlayers.filter((p) => + tournament.ctx.participatedUsers.includes(p.id), + ); + return (
@@ -511,7 +578,7 @@ function Rosters({ {teamOne ? ( { return (
  • - + + p.id !== participatedPlayer.id, + ), + })} + > {p.discordName} @@ -546,7 +623,7 @@ function Rosters({ {teamTwo ? ( { return (
  • - + + p.id !== participatedPlayer.id, + ), + })} + > {p.discordName} diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 0114bc301..3379ee072 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { _action, id, safeJSONParse } from "~/utils/zod"; +import { _action, id, nullLiteraltoNull, safeJSONParse } from "~/utils/zod"; import { TOURNAMENT } from "../tournament/tournament-constants"; const reportedMatchPlayerIds = z.preprocess( @@ -53,6 +53,19 @@ export const matchSchema = z.union([ z.object({ _action: _action("REOPEN_MATCH"), }), + z.object({ + _action: _action("SET_AS_CASTED"), + twitchAccount: z.preprocess( + nullLiteraltoNull, + z.string().min(1).max(100).nullable(), + ), + }), + z.object({ + _action: _action("LOCK"), + }), + z.object({ + _action: _action("UNLOCK"), + }), ]); export const bracketIdx = z.coerce.number().int().min(0).max(2); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts index 0f3802062..3e96378de 100644 --- a/app/features/tournament-bracket/tournament-bracket-utils.ts +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -9,6 +9,7 @@ import { import { removeDuplicates } from "~/utils/arrays"; import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server"; import type { TournamentDataTeam } from "./core/Tournament.server"; +import type { Tournament } from "./core/Tournament"; export function matchIdFromParams(params: Params) { const result = Number(params["mid"]); @@ -159,3 +160,19 @@ export function everyBracketOver(tournament: ValueToArray) { export const bracketHasStarted = (bracket: ValueToArray) => bracket.stage[0] && bracket.stage[0].id !== 0; + +export function matchIsLocked({ + tournament, + matchId, + scores, +}: { + tournament: Tournament; + matchId: number; + scores: [number, number]; +}) { + if (scores[0] !== 0 || scores[1] !== 0) return false; + + const locked = tournament.ctx.castedMatchesInfo?.lockedMatches ?? []; + + return locked.includes(matchId); +} diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css index 3070063f7..4d56627b9 100644 --- a/app/features/tournament-bracket/tournament-bracket.css +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -34,6 +34,17 @@ font-size: var(--fonts-xxs); } +.tournament-bracket__locked-banner { + width: 100%; + height: 10rem; + background-color: var(--bg-lightest-solid); + border-start-end-radius: var(--rounded); + border-start-start-radius: var(--rounded); + grid-area: img; + display: grid; + place-items: center; +} + .tournament-bracket__stage-banner { display: flex; width: 100%; @@ -226,6 +237,7 @@ overflow-x: hidden; text-overflow: ellipsis; padding-inline: var(--s-2); + max-width: 150px; } .tournament-bracket__during-match-actions__seed { @@ -382,3 +394,84 @@ flex-direction: row; } } + +.tournament-bracket__bracket-nav { + display: flex; + flex-wrap: wrap; +} + +.tournament-bracket__bracket-nav__link { + font-size: var(--fonts-xxs); + color: var(--text-lighter); + border-color: var(--bg-lightest-solid); + background-color: var(--bg); + border-radius: 0; +} + +.tournament-bracket__bracket-nav__link:active { + transform: translateY(0px); +} + +.tournament-bracket__bracket-nav__link:first-of-type { + border-start-start-radius: var(--rounded); + border-end-start-radius: var(--rounded); +} + +.tournament-bracket__bracket-nav__link:not( + .tournament-bracket__bracket-nav__link:first-of-type + ) { + margin-left: -2px; +} + +.tournament-bracket__bracket-nav__link:last-of-type { + border-start-end-radius: var(--rounded); + border-end-end-radius: var(--rounded); +} + +.tournament-bracket__bracket-nav__link__selected { + color: var(--text); + background-color: var(--bg); + background-color: var(--bg-lighter); +} + +.tournament-bracket__compactify-button { + font-size: var(--fonts-xxs); + color: var(--text-lighter); + border-color: var(--bg-lightest-solid); + background-color: var(--bg); + border-radius: var(--rounded); +} + +.tournament-bracket__compactify-button .button-icon { + width: 0.85rem; +} + +.tournament-bracket__cast-info-container { + display: flex; + gap: var(--s-2); + border-radius: var(--rounded); + background-color: var(--bg-lighter); + width: max-content; +} + +.tournament-bracket__cast-info-container__label { + padding: var(--s-2) var(--s-3-5); + text-transform: uppercase; + background-color: var(--bg-lightest); + border-radius: var(--rounded) 0 0 var(--rounded); + font-weight: var(--bold); + color: var(--text-lighter); + display: grid; + place-items: center; + justify-content: center; +} + +.tournament-bracket__cast-info-container__content { + padding-block: var(--s-2); + display: flex; + align-items: center; +} + +.tournament-bracket__stream-popover { + width: 280px; +} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 564f71231..c9b595c12 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -1,7 +1,7 @@ -import type { NotNull } from "kysely"; +import type { NotNull, Transaction } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; -import type { Tables } from "~/db/tables"; +import type { CastedMatchesInfo, DB, Tables } from "~/db/tables"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server"; @@ -20,6 +20,7 @@ export async function findById(id: number) { "Tournament.settings", "Tournament.showMapListGenerator", "Tournament.castTwitchAccounts", + "Tournament.castedMatchesInfo", "Tournament.mapPickingStyle", "CalendarEvent.name", "CalendarEvent.description", @@ -53,6 +54,7 @@ export async function findById(id: number) { "TournamentStage.id", "TournamentStage.name", "TournamentStage.type", + "TournamentStage.createdAt", ]) .where("TournamentStage.tournamentId", "=", id) .orderBy("TournamentStage.number asc"), @@ -82,6 +84,7 @@ export async function findById(id: number) { "User.country", "PlusTier.tier as plusTier", "TournamentTeamMember.isOwner", + "TournamentTeamMember.createdAt", ]) .whereRef( "TournamentTeamMember.tournamentTeamId", @@ -315,3 +318,121 @@ export function updateCastTwitchAccounts({ .where("id", "=", tournamentId) .execute(); } + +const castedMatchesInfoByTournamentId = async ( + trx: Transaction, + tournamentId: number, +) => + ( + await trx + .selectFrom("Tournament") + .select("castedMatchesInfo") + .where("id", "=", tournamentId) + .executeTakeFirstOrThrow() + ).castedMatchesInfo ?? + ({ + castedMatches: [], + lockedMatches: [], + } as CastedMatchesInfo); + +export function lockMatch({ + matchId, + tournamentId, +}: { + matchId: number; + tournamentId: number; +}) { + return db.transaction().execute(async (trx) => { + const castedMatchesInfo = await castedMatchesInfoByTournamentId( + trx, + tournamentId, + ); + + if (!castedMatchesInfo.lockedMatches.includes(matchId)) { + castedMatchesInfo.lockedMatches.push(matchId); + } + + await trx + .updateTable("Tournament") + .set({ + castedMatchesInfo: JSON.stringify(castedMatchesInfo), + }) + .where("id", "=", tournamentId) + .execute(); + }); +} + +export function unlockMatch({ + matchId, + tournamentId, +}: { + matchId: number; + tournamentId: number; +}) { + return db.transaction().execute(async (trx) => { + const castedMatchesInfo = await castedMatchesInfoByTournamentId( + trx, + tournamentId, + ); + + castedMatchesInfo.lockedMatches = castedMatchesInfo.lockedMatches.filter( + (lockedMatchId) => lockedMatchId !== matchId, + ); + + await trx + .updateTable("Tournament") + .set({ + castedMatchesInfo: JSON.stringify(castedMatchesInfo), + }) + .where("id", "=", tournamentId) + .execute(); + }); +} + +export function setMatchAsCasted({ + matchId, + tournamentId, + twitchAccount, +}: { + matchId: number; + tournamentId: number; + twitchAccount: string | null; +}) { + return db.transaction().execute(async (trx) => { + const castedMatchesInfo = await castedMatchesInfoByTournamentId( + trx, + tournamentId, + ); + + let newCastedMatchesInfo: CastedMatchesInfo; + if (twitchAccount === null) { + newCastedMatchesInfo = { + ...castedMatchesInfo, + castedMatches: castedMatchesInfo.castedMatches.filter( + (cm) => cm.matchId !== matchId, + ), + }; + } else { + newCastedMatchesInfo = { + ...castedMatchesInfo, + castedMatches: castedMatchesInfo.castedMatches + .filter( + (cm) => + // currently a match can only be streamed by one account + // and a cast can only stream one match at a time + // these can change in the future + cm.matchId !== matchId && cm.twitchAccount !== twitchAccount, + ) + .concat([{ twitchAccount, matchId }]), + }; + } + + await trx + .updateTable("Tournament") + .set({ + castedMatchesInfo: JSON.stringify(newCastedMatchesInfo), + }) + .where("id", "=", tournamentId) + .execute(); + }); +} diff --git a/app/features/tournament/components/TeamWithRoster.tsx b/app/features/tournament/components/TeamWithRoster.tsx index a78f38648..87769b9c7 100644 --- a/app/features/tournament/components/TeamWithRoster.tsx +++ b/app/features/tournament/components/TeamWithRoster.tsx @@ -5,6 +5,8 @@ import { ModeImage, StageImage } from "~/components/Image"; import type { MapPoolMap, User } from "~/db/types"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; import { userPage } from "~/utils/urls"; +import { useTournament } from "../routes/to.$id"; +import { databaseTimestampToDate } from "~/utils/dates"; export function TeamWithRoster({ team, @@ -19,6 +21,8 @@ export function TeamWithRoster({ teamPageUrl?: string; activePlayers?: User["id"][]; }) { + const tournament = useTournament(); + return (
    @@ -29,36 +33,47 @@ export function TeamWithRoster({ {teamPageUrl ? {team.name} : team.name}
      - {team.members.map((member) => ( -
    • - - { + const isSub = + databaseTimestampToDate(member.createdAt) > + tournament.ctx.startTime; + + return ( +
    • - {member.discordName}{" "} - {member.isOwner ? ( - - (C) - - ) : null} - -
    • - ))} + + + {member.discordName}{" "} + {member.isOwner ? ( + + (C) + + ) : null} + {isSub ? ( + + Sub + + ) : null} + + + ); + })}
    {mapPool && mapPool.length > 0 ? : null} diff --git a/app/features/tournament/components/TournamentStream.tsx b/app/features/tournament/components/TournamentStream.tsx new file mode 100644 index 000000000..2ebcbf618 --- /dev/null +++ b/app/features/tournament/components/TournamentStream.tsx @@ -0,0 +1,67 @@ +import type { SerializeFrom } from "@remix-run/node"; +import { Avatar } from "~/components/Avatar"; +import { UserIcon } from "~/components/icons/User"; +import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils"; +import { twitchUrl } from "~/utils/urls"; +import type { TournamentStreamsLoader } from "../routes/to.$id.streams"; +import { useTournament } from "../routes/to.$id"; + +export function TournamentStream({ + stream, + withThumbnail = true, +}: { + stream: SerializeFrom["streams"][number]; + withThumbnail?: boolean; +}) { + const tournament = useTournament(); + const team = tournament.ctx.teams.find((team) => + team.members.some((m) => m.userId === stream.userId), + ); + const user = team?.members.find((m) => m.userId === stream.userId); + + return ( +
    + {withThumbnail ? ( + + + + ) : null} +
    + {user && team ? ( +
    + {user.discordName} + {team.name} +
    + ) : ( +
    + + Cast {stream.twitchUserName} +
    + )} +
    + + {stream.viewerCount} +
    +
    + {!withThumbnail ? ( + + Watch now + + ) : null} +
    + ); +} diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index cd15f6145..2be5a0a96 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -270,7 +270,23 @@ export default function TournamentRegisterPage() { />
    {tournament.ctx.name}
    -
    +
    + {tournament.ranked ? ( +
    + Ranked +
    + ) : ( +
    + Unranked +
    + )} +
    + {tournament.modesIncluded.map((mode) => ( + + ))} +
    +
    +
    {" "} {tournament.ctx.author.discordName} @@ -287,13 +303,6 @@ export default function TournamentRegisterPage() { }) : null}
    -
    - {tournament.modesIncluded.map((mode) => ( -
    - -
    - ))} -
    @@ -640,7 +649,7 @@ function FillRoster({ const { t } = useTranslation(["common", "tournament"]); const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({ - eventId: tournament.ctx.id, + tournamentId: tournament.ctx.id, inviteCode: ownTeam.inviteCode!, })}`; diff --git a/app/features/tournament/routes/to.$id.streams.tsx b/app/features/tournament/routes/to.$id.streams.tsx index 9b40a615f..74cdca7c7 100644 --- a/app/features/tournament/routes/to.$id.streams.tsx +++ b/app/features/tournament/routes/to.$id.streams.tsx @@ -1,15 +1,15 @@ import type { LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import { useTranslation } from "react-i18next"; -import { Avatar } from "~/components/Avatar"; import { Redirect } from "~/components/Redirect"; -import { UserIcon } from "~/components/icons/User"; -import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils"; -import { tournamentRegisterPage, twitchUrl } from "~/utils/urls"; +import { tournamentRegisterPage } from "~/utils/urls"; import * as TournamentRepository from "../TournamentRepository.server"; import { streamsByTournamentId } from "../core/streams.server"; import { tournamentIdFromParams } from "../tournament-utils"; import { useTournament } from "./to.$id"; +import { TournamentStream } from "../components/TournamentStream"; + +export type TournamentStreamsLoader = typeof loader; export const loader = async ({ params }: LoaderFunctionArgs) => { const tournamentId = tournamentIdFromParams(params); @@ -45,46 +45,9 @@ export default function TournamentStreamsPage() { // TODO: link to user page, later tournament team page? return (
    - {data.streams.map((stream) => { - const team = tournament.ctx.teams.find((team) => - team.members.some((m) => m.userId === stream.userId), - ); - const user = team?.members.find((m) => m.userId === stream.userId); - - return ( -
    - - - -
    - {user && team ? ( -
    - {user.discordName} - {team.name} -
    - ) : ( -
    - Cast - {stream.twitchUserName} -
    - )} -
    - - {stream.viewerCount} -
    -
    -
    - ); - })} + {data.streams.map((stream) => ( + + ))}
    ); } diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index a384ee4b7..cc8e18f8a 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -190,7 +190,7 @@ function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) { @@ -235,7 +235,7 @@ function SetInfo({ set, team }: { set: PlayedSet; team: TournamentDataTeam }) { diff --git a/app/features/tournament/routes/to.$id.teams.tsx b/app/features/tournament/routes/to.$id.teams.tsx index 6b1e85daf..c798e6bdb 100644 --- a/app/features/tournament/routes/to.$id.teams.tsx +++ b/app/features/tournament/routes/to.$id.teams.tsx @@ -14,7 +14,7 @@ export default function TournamentTeamsPage() { team={team} seed={i + 1} teamPageUrl={tournamentTeamPage({ - eventId: tournament.ctx.id, + tournamentId: tournament.ctx.id, tournamentTeamId: team.id, })} /> diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index f13386c46..f868c872f 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -75,24 +75,24 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { const tournament = await tournamentData({ tournamentId, user }); + const streams = + tournament.ctx.inProgressBrackets.length > 0 + ? await streamsByTournamentId({ + tournamentId, + castTwitchAccounts: tournament.ctx.castTwitchAccounts, + }) + : []; + return { tournament, subsCount, - streamsCount: - tournament.ctx.inProgressBrackets.length > 0 - ? ( - await streamsByTournamentId({ - tournamentId, - castTwitchAccounts: tournament.ctx.castTwitchAccounts, - }) - ).length - : 0, + streamingParticipants: streams.flatMap((s) => (s.userId ? [s.userId] : [])), + streamsCount: streams.length, }; }; const TournamentContext = React.createContext(null!); -// TODO: icons to nav could be nice export default function TournamentLayout() { const { t } = useTranslation(["tournament"]); const user = useUser(); @@ -102,6 +102,7 @@ export default function TournamentLayout() { () => new Tournament(data.tournament), [data], ); + const [bracketExpanded, setBracketExpanded] = React.useState(true); // this is nice to debug with tournament in browser console if (process.env.NODE_ENV === "development") { @@ -118,17 +119,21 @@ export default function TournamentLayout() {
    {!tournament.hasStarted ? ( - + {t("tournament:tabs.register")} ) : null} - + {t("tournament:tabs.brackets")} {tournament.ctx.showMapListGenerator ? ( {t("tournament:tabs.maps")} ) : null} - + {t("tournament:tabs.teams", { count: tournament.ctx.teams.length })} {!tournament.everyBracketOver && tournament.subsFeatureEnabled && ( @@ -138,7 +143,9 @@ export default function TournamentLayout() { )} {tournament.hasStarted && !tournament.everyBracketOver ? ( - {t("tournament:tabs.streams", { count: data.streamsCount })} + {t("tournament:tabs.streams", { + count: data.streamsCount, + })} ) : null} {tournament.isOrganizer(user) && !tournament.hasStarted && ( @@ -151,12 +158,39 @@ export default function TournamentLayout() { )} - +
    ); } +type TournamentContext = { + tournament: Tournament; + bracketExpanded: boolean; + streamingParticipants: number[]; + setBracketExpanded: (expanded: boolean) => void; +}; + export function useTournament() { - return useOutletContext(); + return useOutletContext().tournament; +} + +export function useBracketExpanded() { + const { bracketExpanded, setBracketExpanded } = + useOutletContext(); + + return { bracketExpanded, setBracketExpanded }; +} + +export function useStreamingParticipants() { + return useOutletContext().streamingParticipants; } diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index e44de9bbd..273f220bb 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -4,6 +4,10 @@ background-color: var(--bg-lighter); } +.tournament__action-section__top-padded { + padding: var(--s-3) var(--s-6) var(--s-6) var(--s-6); +} + .tournament__action-section-title { font-size: var(--fonts-lg); font-weight: var(--bold); @@ -175,10 +179,9 @@ white-space: nowrap; } -.tournament__team-member-name__captain { +.tournament__team-member-name__role { font-size: var(--fonts-xxxs); - color: var(--theme); - font-weight: var(--semi-bold); + font-weight: var(--bold); } .tournament__logo-container { @@ -196,17 +199,38 @@ font-weight: var(--bold); } +.tournament__badge { + text-transform: uppercase; + font-size: var(--fonts-xxs); + font-weight: var(--bold); + padding: var(--s-0-5) var(--s-2); + border-radius: var(--rounded); + display: grid; + place-items: center; + width: max-content; + display: flex; + gap: var(--s-2); +} + +.tournament__badge__ranked { + background-color: var(--theme-info-transparent); + color: var(--theme-info); +} + +.tournament__badge__unranked { + background-color: var(--theme-success-transparent); + color: var(--theme-success); +} + +.tournament__badge__modes { + background-color: var(--bg-lighter-transparent); +} + .tournament__info__icon { width: 18px; padding: var(--s-1) 0; } -.tournament___info__mode-container { - background-color: var(--bg-lighter); - border-radius: 100%; - padding: var(--s-2); -} - .tournament__by { color: var(--text-lighter); font-size: var(--fonts-sm); diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index 3187ac708..5fbc3529a 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -77,7 +77,7 @@ export function UserResultsTable({ {result.tournamentId ? ( diff --git a/app/hooks/useAutoRerender.ts b/app/hooks/useAutoRerender.ts index acdae30cf..8008cf65d 100644 --- a/app/hooks/useAutoRerender.ts +++ b/app/hooks/useAutoRerender.ts @@ -1,16 +1,18 @@ import * as React from "react"; -/** Forces the component to rerender every second */ -export function useAutoRerender() { +/** Forces the component to rerender periodically*/ +export function useAutoRerender(every?: "second" | "ten seconds") { const [, setNow] = React.useState(new Date().getTime()); React.useEffect(() => { + const intervalTime = !every || every === "second" ? 1000 : 10000; + const interval = setInterval(() => { setNow(new Date().getTime()); - }, 1000); + }, intervalTime); return () => { clearInterval(interval); }; - }, []); + }, [every]); } diff --git a/app/modules/brackets-manager/base/getter.ts b/app/modules/brackets-manager/base/getter.ts index 39a146f18..6d7e2ecf1 100644 --- a/app/modules/brackets-manager/base/getter.ts +++ b/app/modules/brackets-manager/base/getter.ts @@ -1,8 +1,7 @@ -import type { DeepPartial, Storage, RoundPositionalInfo } from "../types"; +import type { Storage, RoundPositionalInfo } from "../types"; import type { Group, Match, - MatchGame, Round, SeedOrdering, Stage, @@ -704,29 +703,4 @@ export class BaseGetter { return match; } - - /** - * Finds a match game based on its `id` or based on the combination of its `parent_id` and `number`. - * - * @param game Values to change in a match game. - */ - protected findMatchGame(game: DeepPartial): MatchGame { - if (game.id !== undefined) { - const stored = this.storage.select("match_game", game.id); - if (!stored) throw Error("Match game not found."); - return stored; - } - - if (game.parent_id !== undefined && game.number) { - const stored = this.storage.selectFirst("match_game", { - parent_id: game.parent_id, - number: game.number, - }); - - if (!stored) throw Error("Match game not found."); - return stored; - } - - throw Error("No match game id nor parent id and number given."); - } } diff --git a/app/modules/brackets-manager/base/updater.ts b/app/modules/brackets-manager/base/updater.ts index c8943eb75..517bbff05 100644 --- a/app/modules/brackets-manager/base/updater.ts +++ b/app/modules/brackets-manager/base/updater.ts @@ -1,6 +1,5 @@ import type { Match, - MatchGame, Seeding, Stage, GroupType, @@ -76,33 +75,6 @@ export class BaseUpdater extends BaseGetter { create.run(); } - /** - * Updates a parent match based on its child games. - * - * @param parentId ID of the parent match. - * @param inRoundRobin Indicates whether the parent match is in a round-robin stage. - */ - protected updateParentMatch(parentId: number, inRoundRobin: boolean): void { - const storedParent = this.storage.select("match", parentId); - if (!storedParent) throw Error("Parent not found."); - - const games = this.storage.select("match_game", { - parent_id: parentId, - }); - if (!games) throw Error("No match games."); - - const parentScores = helpers.getChildGamesResults(games); - const parent = helpers.getParentMatchResults(storedParent, parentScores); - - helpers.setParentMatchCompleted( - parent, - storedParent.child_count, - inRoundRobin, - ); - - this.updateMatch(storedParent, parent, true); - } - /** * Throws an error if a match is locked and the new seeding will change this match's participants. * @@ -194,32 +166,6 @@ export class BaseUpdater extends BaseGetter { this.updateRelatedMatches(stored, statusChanged, resultChanged); } - /** - * Updates a match game based on a partial match game. - * - * @param stored A reference to what will be updated in the storage. - * @param game Input of the update. - */ - protected updateMatchGame( - stored: MatchGame, - game: DeepPartial, - ): void { - if (helpers.isMatchUpdateLocked(stored)) - throw Error("The match game is locked."); - - const stage = this.storage.select("stage", stored.stage_id); - if (!stage) throw Error("Stage not found."); - - const inRoundRobin = helpers.isRoundRobin(stage); - - helpers.setMatchResults(stored, game, inRoundRobin); - - if (!this.storage.update("match_game", stored.id, stored)) - throw Error("Could not update the match game."); - - this.updateParentMatch(stored.parent_id, inRoundRobin); - } - /** * Updates the opponents and status of a match and its child games. * @@ -228,26 +174,6 @@ export class BaseUpdater extends BaseGetter { protected applyMatchUpdate(match: Match): void { if (!this.storage.update("match", match.id, match)) throw Error("Could not update the match."); - - if (match.child_count === 0) return; - - const updatedMatchGame: Partial = { - opponent1: helpers.toResult(match.opponent1), - opponent2: helpers.toResult(match.opponent2), - }; - - // Only sync the child games' status with their parent's status when changing the parent match participants - // (Locked, Waiting, Ready). - if (match.status <= Status.Ready) updatedMatchGame.status = match.status; - - if ( - !this.storage.update( - "match_game", - { parent_id: match.id }, - updatedMatchGame, - ) - ) - throw Error("Could not update the match game."); } /** diff --git a/app/modules/brackets-manager/create.ts b/app/modules/brackets-manager/create.ts index 7291a9250..cdfc0217e 100644 --- a/app/modules/brackets-manager/create.ts +++ b/app/modules/brackets-manager/create.ts @@ -2,7 +2,6 @@ import type { Group, InputStage, Match, - MatchGame, Participant, Round, Seeding, @@ -68,9 +67,6 @@ export class Create { if (stage.type === "double_elimination") this.stage.settings.grandFinal = this.stage.settings.grandFinal || "none"; - - this.stage.settings.matchesChildCount = - this.stage.settings.matchesChildCount || 0; } /** @@ -393,8 +389,6 @@ export class Create { matchCount: number, duels: Duel[], ): void { - const matchesChildCount = this.getMatchesChildCount(); - const roundId = this.insertRound({ number: roundNumber, stage_id: stageId, @@ -404,28 +398,17 @@ 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], - matchesChildCount, - ); + this.createMatch(stageId, groupId, roundId, i + 1, duels[i]); } /** * Creates a match, possibly with match games. * - * - If `childCount` is 0, then there is no children. The score of the match is directly its intrinsic score. - * - If `childCount` is greater than 0, then the score of the match will automatically be calculated based on its child games. - * * @param stageId ID of the parent stage. * @param groupId ID of the parent group. * @param roundId ID of the parent round. * @param matchNumber Number in the round. * @param opponents The two opponents matching against each other. - * @param childCount Child count for this match (number of games). */ private createMatch( stageId: number, @@ -433,7 +416,6 @@ export class Create { roundId: number, matchNumber: number, opponents: Duel, - childCount: number, ): void { const opponent1 = helpers.toResultWithPosition(opponents[0]); const opponent2 = helpers.toResultWithPosition(opponents[1]); @@ -455,10 +437,6 @@ export class Create { number: matchNumber, }); - const currentChildCount = existing?.child_count; - childCount = - currentChildCount === undefined ? childCount : currentChildCount; - if (existing) { // Keep the most advanced status when updating a match. const existingStatus = helpers.getMatchStatus(existing); @@ -472,7 +450,6 @@ export class Create { stage_id: stageId, group_id: groupId, round_id: roundId, - child_count: childCount, status: status, opponent1, opponent2, @@ -481,19 +458,6 @@ export class Create { ); if (parentId === -1) throw Error("Could not insert the match."); - - for (let i = 0; i < childCount; i++) { - const id = this.insertMatchGame({ - number: i + 1, - stage_id: stageId, - parent_id: parentId, - status: status, - opponent1: helpers.toResult(opponents[0]), - opponent2: helpers.toResult(opponents[1]), - }); - - if (id === -1) throw Error("Could not insert the match game."); - } } /** @@ -682,15 +646,6 @@ export class Create { return maxNumber + 1; } - /** - * Safely gets `matchesChildCount` in the stage input settings. - */ - private getMatchesChildCount(): number { - if (!this.stage.settings?.matchesChildCount) return 0; - - return this.stage.settings.matchesChildCount; - } - /** * Safely gets an ordering by its index in the stage input settings. * @@ -896,34 +851,6 @@ export class Create { return existing.id; } - /** - * Inserts a match game or finds an existing one (and updates it). - * - * @param matchGame The match game to insert. - */ - private insertMatchGame(matchGame: OmitId): number { - let existing: MatchGame | null = null; - - if (this.updateMode) { - existing = this.storage.selectFirst("match_game", { - parent_id: matchGame.parent_id, - number: matchGame.number, - }); - } - - if (!existing) return this.storage.insert("match_game", matchGame); - - const updated = helpers.getUpdatedMatchResults( - matchGame, - existing, - this.enableByesInUpdate, - ) as MatchGame; - if (!this.storage.update("match_game", existing.id, updated)) - throw Error("Could not update the match game."); - - return existing.id; - } - /** * Inserts missing participants. * diff --git a/app/modules/brackets-manager/delete.ts b/app/modules/brackets-manager/delete.ts index 210317d47..83ea8db3b 100644 --- a/app/modules/brackets-manager/delete.ts +++ b/app/modules/brackets-manager/delete.ts @@ -25,9 +25,6 @@ export class Delete { public stage(stageId: number): void { // The order is important here, because the abstract storage can possibly have foreign key checks (e.g. SQL). - if (!this.storage.delete("match_game", { stage_id: stageId })) - throw Error("Could not delete match games."); - if (!this.storage.delete("match", { stage_id: stageId })) throw Error("Could not delete matches."); diff --git a/app/modules/brackets-manager/find.ts b/app/modules/brackets-manager/find.ts index 9fcc9bb1c..1bb0d8103 100644 --- a/app/modules/brackets-manager/find.ts +++ b/app/modules/brackets-manager/find.ts @@ -1,4 +1,4 @@ -import type { Group, Match, MatchGame } from "~/modules/brackets-model"; +import type { Group, Match } from "~/modules/brackets-model"; import { BaseGetter } from "./base/getter"; import * as helpers from "./helpers"; @@ -152,13 +152,4 @@ export class Find extends BaseGetter { ): Match { return this.findMatch(groupId, roundNumber, matchNumber); } - - /** - * Finds a match game based on its `id` or based on the combination of its `parent_id` and `number`. - * - * @param game Values to change in a match game. - */ - public matchGame(game: Partial): MatchGame { - return this.findMatchGame(game); - } } diff --git a/app/modules/brackets-manager/get.ts b/app/modules/brackets-manager/get.ts index b58c04e30..af7fe9313 100644 --- a/app/modules/brackets-manager/get.ts +++ b/app/modules/brackets-manager/get.ts @@ -3,7 +3,6 @@ import type { Group, Round, Match, - MatchGame, Participant, } from "~/modules/brackets-model"; import { Status } from "~/modules/brackets-model"; @@ -30,7 +29,6 @@ export class Get extends BaseGetter { group: stageData.groups, round: stageData.rounds, match: stageData.matches, - match_game: stageData.matchGames, participant: participants, }; } @@ -69,31 +67,10 @@ export class Get extends BaseGetter { (acc, data) => [...acc, ...data.matches], [] as Match[], ), - match_game: stagesData.reduce( - (acc, data) => [...acc, ...data.matchGames], - [] as MatchGame[], - ), participant: participants, }; } - /** - * Returns the match games associated to a list of matches. - * - * @param matches A list of matches. - */ - public matchGames(matches: Match[]): MatchGame[] { - const parentMatches = matches.filter((match) => match.child_count > 0); - - const matchGamesQueries = parentMatches.map((match) => - this.storage.select("match_game", { parent_id: match.id }), - ); - if (matchGamesQueries.some((game) => game === null)) - throw Error("Error getting match games."); - - return helpers.getNonNull(matchGamesQueries).flat(); - } - /** * Returns the stage that is not completed yet, because of uncompleted matches. * If all matches are completed in this tournament, there is no "current stage", so `null` is returned. @@ -443,7 +420,6 @@ export class Get extends BaseGetter { groups: Group[]; rounds: Round[]; matches: Match[]; - matchGames: MatchGame[]; } { const stage = this.storage.select("stage", stageId); if (!stage) throw Error("Stage not found."); @@ -457,14 +433,11 @@ export class Get extends BaseGetter { const matches = this.storage.select("match", { stage_id: stageId }); if (!matches) throw Error("Error getting matches."); - const matchGames = this.matchGames(matches); - return { stage, groups, rounds, matches, - matchGames, }; } } diff --git a/app/modules/brackets-manager/helpers.ts b/app/modules/brackets-manager/helpers.ts index 683dcba67..cc4ce171f 100644 --- a/app/modules/brackets-manager/helpers.ts +++ b/app/modules/brackets-manager/helpers.ts @@ -1,7 +1,6 @@ import type { GrandFinalType, Match, - MatchGame, MatchResults, Participant, ParticipantResult, @@ -240,7 +239,6 @@ export function normalizeIds(data: Database): Database { group: makeNormalizedIdMapping(data.group), round: makeNormalizedIdMapping(data.round), match: makeNormalizedIdMapping(data.match), - match_game: makeNormalizedIdMapping(data.match_game), }; return { @@ -272,14 +270,6 @@ export function normalizeIds(data: Database): Database { opponent1: normalizeParticipant(value.opponent1, mappings.participant), opponent2: normalizeParticipant(value.opponent2, mappings.participant), })), - match_game: data.match_game.map((value) => ({ - ...value, - id: mappings.match_game[value.id], - stage_id: mappings.stage[value.stage_id], - parent_id: mappings.match[value.parent_id], - opponent1: normalizeParticipant(value.opponent1, mappings.participant), - opponent2: normalizeParticipant(value.opponent2, mappings.participant), - })), }; } @@ -566,18 +556,10 @@ export function getMatchResult(match: MatchResults): Side | null { let winner: Side | null = null; - if ( - match.opponent1?.result === "win" || - match.opponent2 === null || - match.opponent2.forfeit - ) + if (match.opponent1?.result === "win" || match.opponent2 === null) winner = "opponent1"; - if ( - match.opponent2?.result === "win" || - match.opponent1 === null || - match.opponent1.forfeit - ) { + if (match.opponent2?.result === "win" || match.opponent1 === null) { if (winner !== null) throw Error("There are two winners."); winner = "opponent2"; } @@ -654,25 +636,7 @@ export function isMatchStarted(match: DeepPartial): boolean { * @param match Partial match results. */ export function isMatchCompleted(match: DeepPartial): boolean { - return ( - isMatchByeCompleted(match) || - isMatchForfeitCompleted(match) || - isMatchResultCompleted(match) - ); -} - -/** - * Checks if a match is completed because of a forfeit. - * - * @param match Partial match results. - */ -export function isMatchForfeitCompleted( - match: DeepPartial, -): boolean { - return ( - match.opponent1?.forfeit !== undefined || - match.opponent2?.forfeit !== undefined - ); + return isMatchByeCompleted(match) || isMatchResultCompleted(match); } /** @@ -832,12 +796,12 @@ export function setMatchResults( if (completed && currentlyCompleted) { // Ensure everything is good. - setCompleted(stored, match, inRoundRobin); + setCompleted(stored, match); return { statusChanged: false, resultChanged: true }; } if (completed && !currentlyCompleted) { - setCompleted(stored, match, inRoundRobin); + setCompleted(stored, match); return { statusChanged: true, resultChanged: true }; } @@ -856,12 +820,10 @@ export function setMatchResults( */ export function resetMatchResults(stored: MatchResults): void { if (stored.opponent1) { - stored.opponent1.forfeit = undefined; stored.opponent1.result = undefined; } if (stored.opponent2) { - stored.opponent2.forfeit = undefined; stored.opponent2.result = undefined; } @@ -896,7 +858,7 @@ export function setExtraFields( }); }; - const ignoredKeys: Array = [ + const ignoredKeys: Array = [ "id", "number", "stage_id", @@ -905,8 +867,6 @@ export function setExtraFields( "status", "opponent1", "opponent2", - "child_count", - "parent_id", ]; const ignoredOpponentKeys: Array = [ @@ -1209,24 +1169,20 @@ export function setScores( * * @param stored A reference to what will be updated in the storage. * @param match Input of the update. - * @param inRoundRobin Indicates whether the match is in a round-robin stage. */ export function setCompleted( stored: MatchResults, match: DeepPartial, - inRoundRobin: boolean, ): void { stored.status = Status.Completed; - setResults(stored, match, "win", "loss", inRoundRobin); - setResults(stored, match, "loss", "win", inRoundRobin); - setResults(stored, match, "draw", "draw", inRoundRobin); + setResults(stored, match, "win", "loss"); + setResults(stored, match, "loss", "win"); + setResults(stored, match, "draw", "draw"); if (stored.opponent1 && !stored.opponent2) stored.opponent1.result = "win"; // Win against opponent 2 BYE. if (!stored.opponent1 && stored.opponent2) stored.opponent2.result = "win"; // Win against opponent 1 BYE. - - setForfeits(stored, match); } /** @@ -1238,14 +1194,12 @@ export function setCompleted( * @param match Input of the update. * @param check A result to check in each opponent. * @param change A result to set in each other opponent if `check` is correct. - * @param inRoundRobin Indicates whether the match is in a round-robin stage. */ export function setResults( stored: MatchResults, match: DeepPartial, check: Result, change: Result, - inRoundRobin: boolean, ): void { if (match.opponent1 && match.opponent2) { if (match.opponent1.result === "win" && match.opponent2.result === "win") @@ -1253,13 +1207,6 @@ export function setResults( if (match.opponent1.result === "loss" && match.opponent2.result === "loss") throw Error("There are two losers."); - - if ( - !inRoundRobin && - match.opponent1.forfeit === true && - match.opponent2.forfeit === true - ) - throw Error("There are two forfeits."); } if (match.opponent1?.result === check) { @@ -1279,40 +1226,6 @@ export function setResults( } } -/** - * Sets forfeits for each opponent (if needed). - * - * @param stored A reference to what will be updated in the storage. - * @param match Input of the update. - */ -export function setForfeits( - stored: MatchResults, - match: DeepPartial, -): void { - if (match.opponent1?.forfeit === true && match.opponent2?.forfeit === true) { - if (stored.opponent1) stored.opponent1.forfeit = true; - if (stored.opponent2) stored.opponent2.forfeit = true; - - // Don't set any result (win/draw/loss) with a double forfeit - // so that it doesn't count any point in the ranking. - return; - } - - if (match.opponent1?.forfeit === true) { - if (stored.opponent1) stored.opponent1.forfeit = true; - - if (stored.opponent2) stored.opponent2.result = "win"; - else stored.opponent2 = { id: null, result: "win" }; - } - - if (match.opponent2?.forfeit === true) { - if (stored.opponent2) stored.opponent2.forfeit = true; - - if (stored.opponent1) stored.opponent1.result = "win"; - else stored.opponent1 = { id: null, result: "win" }; - } -} - /** * Indicates if a seeding is filled with participants' IDs. * @@ -1547,50 +1460,6 @@ export function transitionToMinor( return currentDuels; } -/** - * Sets the parent match to a completed status if all its child games are completed. - * - * @param parent The partial parent match to update. - * @param childCount Child count of this parent match. - * @param inRoundRobin Indicates whether the parent match is in a round-robin stage. - */ -export function setParentMatchCompleted( - parent: Pick, - childCount: number, - inRoundRobin: boolean, -): void { - if ( - parent.opponent1?.score === undefined || - parent.opponent2?.score === undefined - ) - throw Error("Either opponent1, opponent2 or their scores are falsy."); - - const minToWin = minScoreToWinBestOfX(childCount); - - if (parent.opponent1.score >= minToWin) { - parent.opponent1.result = "win"; - return; - } - - if (parent.opponent2.score >= minToWin) { - parent.opponent2.result = "win"; - return; - } - - if ( - parent.opponent1.score === parent.opponent2.score && - parent.opponent1.score + parent.opponent2.score > childCount - 1 - ) { - if (inRoundRobin) { - parent.opponent1.result = "draw"; - parent.opponent2.result = "draw"; - return; - } - - throw Error("Match games result in a tie for the parent match."); - } -} - /** * Returns a parent match results based on its child games scores. * @@ -1652,26 +1521,6 @@ export function getUpdatedMatchResults( }; } -/** - * Calculates the score of a parent match based on its child games. - * - * @param games The child games to process. - */ -export function getChildGamesResults(games: MatchGame[]): Scores { - const scores = { - opponent1: 0, - opponent2: 0, - }; - - for (const game of games) { - const result = getMatchResult(game); - if (result === "opponent1") scores.opponent1++; - else if (result === "opponent2") scores.opponent2++; - } - - return scores; -} - /** * Gets the default list of seeds for a round's matches. * diff --git a/app/modules/brackets-manager/manager.ts b/app/modules/brackets-manager/manager.ts index 681421d81..f382ebaf8 100644 --- a/app/modules/brackets-manager/manager.ts +++ b/app/modules/brackets-manager/manager.ts @@ -15,7 +15,7 @@ import { Reset } from "./reset"; import * as helpers from "./helpers"; /** - * A class to handle tournament management at those levels: `stage`, `group`, `round`, `match` and `match_game`. + * A class to handle tournament management at those levels: `stage`, `group`, `round` and `match`. */ export class BracketsManager { public storage: Storage; @@ -102,11 +102,6 @@ export class BracketsManager { throw Error("Could not empty the match table."); if (!this.storage.insert("match", data.match)) throw Error("Could not import matches."); - - if (!this.storage.delete("match_game")) - throw Error("Could not empty the match_game table."); - if (!this.storage.insert("match_game", data.match_game)) - throw Error("Could not import match games."); } /** @@ -128,15 +123,12 @@ export class BracketsManager { const matches = this.storage.select("match"); if (!matches) throw Error("Error getting matches."); - const matchGames = this.get.matchGames(matches); - return { participant: participants, stage: stages, group: groups, round: rounds, match: matches, - match_game: matchGames, }; } } diff --git a/app/modules/brackets-manager/reset.ts b/app/modules/brackets-manager/reset.ts index 22ff4d2b4..112aed100 100644 --- a/app/modules/brackets-manager/reset.ts +++ b/app/modules/brackets-manager/reset.ts @@ -14,22 +14,6 @@ export class Reset extends BaseUpdater { const stored = this.storage.select("match", matchId); if (!stored) throw Error("Match not found."); - // The user can handle forfeits with matches which have child games in two possible ways: - // - // 1. Set forfeits for the parent match directly. - // --> The child games will never be updated: not locked, not finished, without forfeit. They will just be ignored and never be played. - // --> To reset the forfeits, the user has to reset the parent match, with `reset.matchResults()`. - // --> `reset.matchResults()` will be usable **only** to reset the forfeit of the parent match. Otherwise it will throw the error below. - // - // 2. Set forfeits for each child game. - // --> The parent match won't automatically have a forfeit, but will be updated with a computed score according to the forfeited match games. - // --> To reset the forfeits, the user has to reset each child game on its own, with `reset.matchGameResults()`. - // --> `reset.matchResults()` will throw the error below in all cases. - if (!helpers.isMatchForfeitCompleted(stored) && stored.child_count > 0) - throw Error( - "The parent match is controlled by its child games and its result cannot be reset.", - ); - const stage = this.storage.select("stage", stored.stage_id); if (!stage) throw Error("Stage not found."); @@ -65,28 +49,6 @@ export class Reset extends BaseUpdater { this.updateRelatedMatches(stored, true, true); } - /** - * Resets the results of a match game. - * - * @param gameId ID of the match game. - */ - public matchGameResults(gameId: number): void { - const stored = this.storage.select("match_game", gameId); - if (!stored) throw Error("Match game not found."); - - const stage = this.storage.select("stage", stored.stage_id); - if (!stage) throw Error("Stage not found."); - - const inRoundRobin = helpers.isRoundRobin(stage); - - helpers.resetMatchResults(stored); - - if (!this.storage.update("match_game", stored.id, stored)) - throw Error("Could not update the match game."); - - this.updateParentMatch(stored.parent_id, inRoundRobin); - } - /** * Resets the seeding of a stage. * diff --git a/app/modules/brackets-manager/test/custom.test.ts b/app/modules/brackets-manager/test/custom.test.ts index 590890afd..e87010127 100644 --- a/app/modules/brackets-manager/test/custom.test.ts +++ b/app/modules/brackets-manager/test/custom.test.ts @@ -127,53 +127,5 @@ ExtraFields("Extra fields when updating a match", () => { }); }); -ExtraFields("Extra fields when updating a match game", () => { - manager.create({ - name: "Amateur", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2"], - settings: { - matchesChildCount: 3, - }, - }); - - manager.update.matchGame({ - id: 0, - // @ts-expect-error incomplete types - weather: "rainy", // Extra field. - opponent1: { - score: 3, - result: "win", - }, - opponent2: { - score: 1, - result: "loss", - }, - }); - - manager.update.matchGame({ - id: 1, - opponent1: { - score: 3, - result: "win", - // @ts-expect-error incomplete types - foo: 42, // Extra field. - }, - opponent2: { - score: 1, - result: "loss", - // @ts-expect-error incomplete types - info: { replacements: [1, 2] }, // Extra field. - }, - }); - - assert.equal(storage.select("match_game", 0).weather, "rainy"); - assert.equal(storage.select("match_game", 1).opponent1.foo, 42); - assert.equal(storage.select("match_game", 1).opponent2.info, { - replacements: [1, 2], - }); -}); - CustomSeeding.run(); ExtraFields.run(); diff --git a/app/modules/brackets-manager/test/delete.test.ts b/app/modules/brackets-manager/test/delete.test.ts index 164dd870c..395a6c115 100644 --- a/app/modules/brackets-manager/test/delete.test.ts +++ b/app/modules/brackets-manager/test/delete.test.ts @@ -18,7 +18,6 @@ DeleteStage("should delete a stage and all its linked data", () => { tournamentId: 0, type: "single_elimination", seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, }); manager.delete.stage(0); @@ -27,13 +26,11 @@ DeleteStage("should delete a stage and all its linked data", () => { const groups = storage.select("group")!; const rounds = storage.select("round")!; const matches = storage.select("match")!; - const games = manager.get.matchGames(matches); assert.equal(stages.length, 0); assert.equal(groups.length, 0); assert.equal(rounds.length, 0); assert.equal(matches.length, 0); - assert.equal(games.length, 0); }); DeleteStage("should delete one stage and only its linked data", () => { @@ -42,7 +39,6 @@ DeleteStage("should delete one stage and only its linked data", () => { tournamentId: 0, type: "single_elimination", seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, }); manager.create({ @@ -50,7 +46,6 @@ DeleteStage("should delete one stage and only its linked data", () => { tournamentId: 0, type: "single_elimination", seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, }); manager.delete.stage(0); @@ -59,20 +54,17 @@ DeleteStage("should delete one stage and only its linked data", () => { const groups = storage.select("group")!; const rounds = storage.select("round")!; const matches = storage.select("match")!; - const games = manager.get.matchGames(matches); assert.equal(stages.length, 1); assert.equal(groups.length, 1); assert.equal(rounds.length, 2); assert.equal(matches.length, 3); - assert.equal(games.length, 6); // Remaining data assert.equal(stages[0].id, 1); assert.equal(groups[0].id, 1); assert.equal(rounds[0].id, 2); assert.equal(matches[0].id, 3); - assert.equal(games[0].id, 6); }); DeleteStage("should delete all stages of the tournament", () => { @@ -81,7 +73,6 @@ DeleteStage("should delete all stages of the tournament", () => { tournamentId: 0, type: "single_elimination", seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, }); manager.create({ @@ -89,7 +80,6 @@ DeleteStage("should delete all stages of the tournament", () => { tournamentId: 0, type: "single_elimination", seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, }); manager.delete.tournament(0); @@ -98,13 +88,11 @@ DeleteStage("should delete all stages of the tournament", () => { const groups = storage.select("group")!; const rounds = storage.select("round")!; const matches = storage.select("match")!; - const games = manager.get.matchGames(matches); assert.equal(stages.length, 0); assert.equal(groups.length, 0); assert.equal(rounds.length, 0); assert.equal(matches.length, 0); - assert.equal(games.length, 0); }); DeleteStage.run(); diff --git a/app/modules/brackets-manager/test/general.test.ts b/app/modules/brackets-manager/test/general.test.ts index 54f44db10..0b7a77c0f 100644 --- a/app/modules/brackets-manager/test/general.test.ts +++ b/app/modules/brackets-manager/test/general.test.ts @@ -256,69 +256,6 @@ SpecialCases( }, ); -const UpdateMatchChildCount = suite("Update match child count"); - -UpdateMatchChildCount.before.each(() => { - storage.reset(); - - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: [ - "Team 1", - "Team 2", - "Team 3", - "Team 4", - "Team 5", - "Team 6", - "Team 7", - "Team 8", - ], - settings: { seedOrdering: ["natural"], matchesChildCount: 1 }, - }); -}); - -UpdateMatchChildCount("should change match child count at match level", () => { - manager.update.matchChildCount("match", 0, 3); - assert.equal(storage.select("match", 0).child_count, 3); - assert.equal(storage.select("match_game")!.length, 6 + 3); -}); - -UpdateMatchChildCount("should remove all child games of the match", () => { - manager.update.matchChildCount("match", 0, 3); // Bo3 - manager.update.matchChildCount("match", 0, 0); // No child games. - assert.equal(storage.select("match", 0).child_count, 0); - assert.equal(storage.select("match_game")!.length, 6); -}); - -UpdateMatchChildCount("should change match child count at round level", () => { - manager.update.matchChildCount("round", 2, 3); // Round of id 2 in Bo3 - assert.equal(storage.select("match_game")!.length, 6 + 3); - - manager.update.matchChildCount("round", 1, 2); // Round of id 1 in Bo2 - assert.equal(storage.select("match_game")!.length, 4 + 4 + 3); - - manager.update.matchChildCount("round", 0, 0); // Round of id 0 in Bo0 (normal matches without games) - assert.equal(storage.select("match_game")!.length, 0 + 4 + 3); -}); - -UpdateMatchChildCount("should change match child count at group level", () => { - manager.update.matchChildCount("group", 0, 4); - assert.equal(storage.select("match_game")!.length, 7 * 4); - - manager.update.matchChildCount("group", 0, 2); - assert.equal(storage.select("match_game")!.length, 7 * 2); -}); - -UpdateMatchChildCount("should change match child count at stage level", () => { - manager.update.matchChildCount("stage", 0, 4); - assert.equal(storage.select("match_game")!.length, 7 * 4); - - manager.update.matchChildCount("stage", 0, 2); - assert.equal(storage.select("match_game")!.length, 7 * 2); -}); - const SeedingAndOrderingInElimination = suite( "Seeding and ordering in elimination", ); @@ -481,289 +418,6 @@ SeedingAndOrderingInElimination( }, ); -const BestOfSeriesMatchesCompletion = suite( - "Best-Of series matches completion", -); - -BestOfSeriesMatchesCompletion.before.each(() => { - storage.reset(); -}); - -BestOfSeriesMatchesCompletion("should end Bo1 matches", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 1, - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - - const match = storage.select("match", 0); - assert.equal(match.opponent1.score, 1); - assert.equal(match.opponent2.score, 0); - assert.equal(match.opponent1.result, "win"); -}); - -BestOfSeriesMatchesCompletion( - "should end Bo2 matches in round-robin stage", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "round_robin", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 2, // Bo2 - groupCount: 1, - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 1, opponent2: { result: "win" } }); - - const match = storage.select("match", 0); - assert.equal(match.opponent1.score, 1); - assert.equal(match.opponent2.score, 1); - assert.equal(match.opponent1.result, "draw"); - assert.equal(match.opponent2.result, "draw"); - }, -); - -BestOfSeriesMatchesCompletion( - "should throw if a BoX match has a tie in an elimination stage", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 2, // Bo2 - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - - assert.throws( - () => - manager.update.matchGame({ - id: 1, - opponent2: { result: "win" }, - }), - "Match games result in a tie for the parent match.", - ); - }, -); - -BestOfSeriesMatchesCompletion("should end Bo3 matches", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 3, - }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - - const firstMatch = storage.select("match", 0); - assert.equal(firstMatch.opponent1.score, 2); - assert.equal(firstMatch.opponent2.score, 0); - assert.equal(firstMatch.opponent1.result, "win"); - - manager.update.matchGame({ - parent_id: 1, - number: 1, - opponent2: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 2, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 3, - opponent1: { result: "win" }, - }); - - const secondMatch = storage.select("match", 1); - assert.equal(secondMatch.opponent1.score, 2); - assert.equal(secondMatch.opponent2.score, 1); - assert.equal(secondMatch.opponent1.result, "win"); -}); - -BestOfSeriesMatchesCompletion( - "should let the last match be played even if not necessary", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 3, - }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - - let match = storage.select("match", 0); - assert.equal(match.opponent1.score, 2); - assert.equal(match.opponent2.score, 0); - assert.equal(match.opponent1.result, "win"); - - manager.update.matchGame({ - parent_id: 0, - number: 3, - opponent2: { result: "win" }, - }); - - match = storage.select("match", 0); - assert.equal(match.opponent1.score, 2); - assert.equal(match.opponent2.score, 1); - assert.equal(match.opponent1.result, "win"); - }, -); - -BestOfSeriesMatchesCompletion("should end Bo5 matches", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 5, - }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 3, - opponent1: { result: "win" }, - }); - - const firstMatch = storage.select("match", 0); - assert.equal(firstMatch.opponent1.score, 3); - assert.equal(firstMatch.opponent2.score, 0); - assert.equal(firstMatch.opponent1.result, "win"); - - manager.update.matchGame({ - parent_id: 1, - number: 1, - opponent2: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 2, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 3, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 4, - opponent1: { result: "win" }, - }); - - const secondMatch = storage.select("match", 1); - assert.equal(secondMatch.opponent1.score, 3); - assert.equal(secondMatch.opponent2.score, 1); - assert.equal(secondMatch.opponent1.result, "win"); - - manager.update.matchGame({ - parent_id: 2, - number: 1, - opponent2: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 2, - number: 2, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 2, - number: 3, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 2, - number: 4, - opponent2: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 2, - number: 5, - opponent1: { result: "win" }, - }); - - const thirdMatch = storage.select("match", 2); - assert.equal(thirdMatch.opponent1.score, 3); - assert.equal(thirdMatch.opponent2.score, 2); - assert.equal(thirdMatch.opponent1.result, "win"); -}); - -BestOfSeriesMatchesCompletion( - "should handle match auto-win against a BYE after a BoX series", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2"], - settings: { - seedOrdering: ["natural"], - matchesChildCount: 3, - size: 8, - consolationFinal: true, - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - - assert.equal(storage.select("match", 4).opponent1.result, "win"); - assert.equal(storage.select("match", 6).opponent1.result, "win"); - }, -); - const ResetMatchAndMatchGames = suite("Reset match and match games"); ResetMatchAndMatchGames.before.each(() => { @@ -852,34 +506,6 @@ ResetMatchAndMatchGames( }, ); -ResetMatchAndMatchGames("should reset results of a match game", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2"], - settings: { - seedOrdering: ["natural"], - matchesChildCount: 3, - consolationFinal: true, - size: 8, - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - - assert.equal(storage.select("match", 4).opponent1.result, "win"); - assert.equal(storage.select("match", 6).opponent1.result, "win"); - assert.equal(storage.select("match", 7).opponent1, null); // BYE in consolation final. - - manager.reset.matchGameResults(1); - - assert.equal(storage.select("match", 4).opponent1.result, undefined); - assert.equal(storage.select("match", 6).opponent1.result, undefined); - assert.equal(storage.select("match", 7).opponent1, null); // Still BYE in consolation final. -}); - const ImportExport = suite("Import / export"); ImportExport.before.each(() => { @@ -894,7 +520,6 @@ ImportExport("should import data in the storage", () => { seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], settings: { seedOrdering: ["natural"], - matchesChildCount: 1, }, }); @@ -937,7 +562,6 @@ ImportExport("should import data in the storage with normalized IDs", () => { seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], settings: { groupCount: 1, - matchesChildCount: 1, }, }); @@ -948,7 +572,6 @@ ImportExport("should import data in the storage with normalized IDs", () => { seeding: ["Team 5", "Team 6", "Team 7", "Team 8"], settings: { seedOrdering: ["natural"], - matchesChildCount: 1, }, }); @@ -976,16 +599,6 @@ ImportExport("should import data in the storage with normalized IDs", () => { opponent2: { id: 6, position: 2 }, number: 1, status: 2, - child_count: 1, - }); - assert.equal(initialData.match_game[0], { - id: 6, - number: 1, - stage_id: 1, - parent_id: 6, - status: 2, - opponent1: { id: 5 }, - opponent2: { id: 6 }, }); manager.import(initialData, true); @@ -1014,16 +627,6 @@ ImportExport("should import data in the storage with normalized IDs", () => { opponent2: { id: 5, position: 2 }, number: 1, status: 2, - child_count: 1, - }); - assert.equal(data.match_game[0], { - id: 0, - number: 1, - stage_id: 0, - parent_id: 0, - status: 2, - opponent1: { id: 4 }, - opponent2: { id: 5 }, }); }); @@ -1035,20 +638,12 @@ ImportExport("should export data from the storage", () => { seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], settings: { seedOrdering: ["natural"], - matchesChildCount: 2, }, }); const data = manager.export(); - for (const key of [ - "participant", - "stage", - "group", - "round", - "match", - "match_game", - ]) { + for (const key of ["participant", "stage", "group", "round", "match"]) { assert.ok(Object.keys(data).includes(key)); } @@ -1057,14 +652,11 @@ ImportExport("should export data from the storage", () => { assert.equal(storage.select("group"), data.group); assert.equal(storage.select("round"), data.round); assert.equal(storage.select("match"), data.match); - assert.equal(storage.select("match_game"), data.match_game); }); BYEHandling.run(); PositionChecks.run(); SpecialCases.run(); -UpdateMatchChildCount.run(); SeedingAndOrderingInElimination.run(); -BestOfSeriesMatchesCompletion.run(); ResetMatchAndMatchGames.run(); ImportExport.run(); diff --git a/app/modules/brackets-manager/test/get.test.ts b/app/modules/brackets-manager/test/get.test.ts index fb09e6615..2b2c35829 100644 --- a/app/modules/brackets-manager/test/get.test.ts +++ b/app/modules/brackets-manager/test/get.test.ts @@ -6,50 +6,6 @@ import * as assert from "uvu/assert"; const storage = new InMemoryDatabase(); const manager = new BracketsManager(storage); -const GetChildGames = suite("Get child games"); - -GetChildGames.before.each(() => { - storage.reset(); -}); - -GetChildGames("should get child games of a list of matches", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, - }); - - const matches = storage.select("match", { round_id: 0 })!; - const games = manager.get.matchGames(matches); - - assert.equal(matches.length, 2); - assert.equal(games.length, 4); - assert.equal(games[2].parent_id, 1); -}); - -GetChildGames( - "should get child games of a list of matches with some which do not have child games", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 2 }, - }); - - manager.update.matchChildCount("match", 1, 0); // Remove child games from match id 1. - - const matches = storage.select("match", { round_id: 0 })!; - const games = manager.get.matchGames(matches); - - assert.equal(matches.length, 2); - assert.equal(games.length, 2); // Only two child games. - }, -); - const GetFinalStandings = suite("Get final standings"); GetFinalStandings.before.each(() => { @@ -362,6 +318,5 @@ GetSeeding("should get the seeding with BYEs", () => { ]); }); -GetChildGames.run(); GetFinalStandings.run(); GetSeeding.run(); diff --git a/app/modules/brackets-manager/test/round-robin.test.ts b/app/modules/brackets-manager/test/round-robin.test.ts index fdcf9c9c8..fafec4455 100644 --- a/app/modules/brackets-manager/test/round-robin.test.ts +++ b/app/modules/brackets-manager/test/round-robin.test.ts @@ -2,7 +2,6 @@ import { InMemoryDatabase } from "~/modules/brackets-memory-db"; import { BracketsManager } from "../manager"; import { suite } from "uvu"; import * as assert from "uvu/assert"; -import { Status } from "~/db/types"; const storage = new InMemoryDatabase(); const manager = new BracketsManager(storage); @@ -259,21 +258,6 @@ UpdateRoundRobinScores.before.each(() => { }); }); -UpdateRoundRobinScores("should set two forfeits for the match", () => { - manager.update.match({ - id: 0, - opponent1: { forfeit: true }, - opponent2: { forfeit: true }, - }); - - const after = storage.select("match", 0); - assert.equal(after.status, Status.Completed); - assert.equal(after.opponent1.forfeit, true); - assert.equal(after.opponent2.forfeit, true); - assert.equal(after.opponent1.result, undefined); - assert.equal(after.opponent2.result, undefined); -}); - const ExampleUseCase = suite("Example use-case"); // Example taken from here: diff --git a/app/modules/brackets-manager/test/single-elimination.test.ts b/app/modules/brackets-manager/test/single-elimination.test.ts index 1dc038200..20c39ae08 100644 --- a/app/modules/brackets-manager/test/single-elimination.test.ts +++ b/app/modules/brackets-manager/test/single-elimination.test.ts @@ -149,13 +149,12 @@ CreateSingleEliminationStage( "Team 7", "Team 8", ], - settings: { seedOrdering: ["natural"], matchesChildCount: 3 }, + settings: { seedOrdering: ["natural"] }, }); assert.equal(storage.select("group")!.length, 1); assert.equal(storage.select("round")!.length, 3); assert.equal(storage.select("match")!.length, 7); - assert.equal(storage.select("match_game")!.length, 7 * 3); }, ); diff --git a/app/modules/brackets-manager/test/update.test.ts b/app/modules/brackets-manager/test/update.test.ts index 54ca05d4f..20b04d3e0 100644 --- a/app/modules/brackets-manager/test/update.test.ts +++ b/app/modules/brackets-manager/test/update.test.ts @@ -128,37 +128,6 @@ UpdateMatches("should update the status of the next match", () => { assert.equal(storage.select("match", 8).status, Status.Ready); }); -UpdateMatches("should end the match by only setting a forfeit", () => { - const before = storage.select("match", 2); - assert.not.ok(before.opponent1.result); - - manager.update.match({ - id: 2, - opponent1: { forfeit: true }, - }); - - const after = storage.select("match", 2); - assert.equal(after.status, Status.Completed); - assert.equal(after.opponent1.forfeit, true); - assert.equal(after.opponent1.result, undefined); - assert.equal(after.opponent2.result, "win"); -}); - -UpdateMatches("should remove forfeit from a match", () => { - manager.update.match({ - id: 2, - opponent1: { forfeit: true }, - }); - - manager.reset.matchResults(2); - - const after = storage.select("match", 2); - assert.equal(after.status, Status.Ready); - assert.not.ok(after.opponent1.forfeit); - assert.not.ok(after.opponent1.result); - assert.not.ok(after.opponent2.result); -}); - UpdateMatches("should end the match by setting winner and loser", () => { manager.update.match({ id: 0, @@ -263,41 +232,6 @@ UpdateMatches("should throw if two winners", () => { ); }); -UpdateMatches("should throw if two forfeits", () => { - assert.throws( - () => - manager.update.match({ - id: 3, - opponent1: { forfeit: true }, - opponent2: { forfeit: true }, - }), - "There are two forfeits.", - ); -}); - -UpdateMatches( - "should throw if one forfeit then the other without resetting the match between", - () => { - manager.update.match({ - id: 2, - opponent1: { forfeit: true }, - }); - - const after = storage.select("match", 2); - assert.equal(after.opponent1.forfeit, true); - assert.not.ok(after.opponent2.forfeit); - - assert.throws( - () => - manager.update.match({ - id: 2, - opponent2: { forfeit: true }, - }), - "Didn't throw when updating a match with second forfeit.", - ); - }, -); - const GiveOpponentIds = suite("Give opponent IDs when updating"); GiveOpponentIds.before.each(() => { @@ -396,273 +330,6 @@ LockedMatches( }, ); -const UpdateMatchGames = suite("Update match games"); - -UpdateMatchGames.before.each(() => { - storage.reset(); -}); - -UpdateMatchGames( - "should update child games status based on the parent match status", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - settings: { - seedOrdering: ["natural"], - size: 4, - }, - }); - - manager.update.matchChildCount("stage", 0, 2); // Set Bo2 for all the stage. - assert.equal( - storage.select("match", 0).status, - storage.select("match_game", 0).status, - ); - - manager.update.seeding(0, ["Team 1", "Team 2", "Team 3", "Team 4"]); - assert.equal( - storage.select("match", 0).status, - storage.select("match_game", 0).status, - ); - - // Semi 1 - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - assert.equal(storage.select("match", 0).status, Status.Completed); - assert.equal(storage.select("match", 0).opponent1.score, 2); - assert.equal(storage.select("match", 0).opponent2.score, 0); - - let finalMatchStatus = storage.select("match", 2).status; - assert.equal(finalMatchStatus, Status.Waiting); - assert.equal(finalMatchStatus, storage.select("match_game", 4).status); - - // Semi 2 - manager.update.matchGame({ - parent_id: 1, - number: 1, - opponent2: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 2, - opponent2: { result: "win" }, - }); - - finalMatchStatus = storage.select("match", 2).status; - assert.equal(finalMatchStatus, Status.Ready); - assert.equal(finalMatchStatus, storage.select("match_game", 4).status); - - // Final - manager.update.matchGame({ - parent_id: 2, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 2, - number: 2, - opponent1: { result: "win" }, - }); - - finalMatchStatus = storage.select("match", 2).status; - assert.equal(finalMatchStatus, storage.select("match_game", 4).status); - - const semi1Status = storage.select("match", 0).status; - assert.equal(semi1Status, storage.select("match_game", 0).status); - - const semi2Status = storage.select("match", 1).status; - assert.equal(semi2Status, storage.select("match_game", 2).status); - }, -); - -UpdateMatchGames( - "should update parent score when match game is updated", - () => { - manager.create({ - name: "With match games", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 3, // Bo3. - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - const firstChildCompleted = storage.select("match", 0); - assert.equal(firstChildCompleted.status, Status.Running); - assert.equal(firstChildCompleted.opponent1.score, 1); - assert.equal(firstChildCompleted.opponent2.score, 0); - - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - const secondChildCompleted = storage.select("match", 0); - assert.equal(secondChildCompleted.status, Status.Completed); - assert.equal(secondChildCompleted.opponent1.score, 2); - assert.equal(secondChildCompleted.opponent2.score, 0); - - manager.reset.matchGameResults(1); - const secondChildReset = storage.select("match", 0); - assert.equal(secondChildReset.status, Status.Running); - assert.equal(secondChildReset.opponent1.score, 1); - assert.equal(secondChildReset.opponent2.score, 0); - }, -); - -UpdateMatchGames("should throw if trying to update a locked match game", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - settings: { - seedOrdering: ["natural"], - size: 4, - matchesChildCount: 3, // Example with all Bo3 at creation time. - }, - }); - - assert.throws( - () => manager.update.matchGame({ id: 0 }), - "The match game is locked.", - ); - - storage.reset(); - - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - settings: { - seedOrdering: ["natural"], - size: 4, - }, - }); - - manager.update.matchChildCount("round", 0, 3); // Example with all Bo3 after creation time. - assert.throws( - () => manager.update.matchGame({ id: 0 }), - "The match game is locked.", - ); -}); - -UpdateMatchGames( - "should propagate the winner of the parent match in the next match", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { seedOrdering: ["natural"] }, - }); - - manager.update.matchChildCount("round", 0, 3); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 2, opponent2: { result: "win" } }); - - assert.equal( - storage.select("match", 2).opponent1.id, // Should be determined automatically. - storage.select("match", 0).opponent1.id, // Winner of the first BO3 match. - ); - }, -); - -UpdateMatchGames( - "should select a match game with its parent match id and number", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { - matchesChildCount: 3, - }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - - assert.equal(storage.select("match", 0).opponent1.score, 2); - }, -); - -UpdateMatchGames( - "should throw if trying to reset the results of a parent match", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2"], - settings: { - matchesChildCount: 3, - }, - }); - - assert.throws( - () => manager.reset.matchResults(0), - "The parent match is controlled by its child games and its result cannot be reset.", - ); - }, -); - -UpdateMatchGames( - "should reset the results of a parent match when a child game's results are reset", - () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2"], - settings: { - matchesChildCount: 3, - }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - - manager.reset.matchGameResults(0); - assert.equal(storage.select("match", 0).status, Status.Running); - }, -); - -UpdateMatchGames("should reset the forfeit of a parent match", () => { - manager.create({ - name: "Example", - tournamentId: 0, - type: "single_elimination", - seeding: ["Team 1", "Team 2"], - settings: { - matchesChildCount: 3, - }, - }); - - manager.update.match({ id: 0, opponent1: { forfeit: true } }); - manager.reset.matchResults(0); -}); - const Seeding = suite("Seeding"); Seeding.before.each(() => { @@ -1043,237 +710,7 @@ Seeding("should confirm the current seeding", () => { assert.equal(storage.select("match", 10).opponent2, null); }); -const MatchGamesStatus = suite("Match games status"); - -MatchGamesStatus.before.each(() => { - storage.reset(); -}); - -MatchGamesStatus( - "should have all the child games to Locked when the parent match is Locked", - () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 3 }, - }); - - const games = storage.select("match_game", { parent_id: 2 }); - assert.equal(games![0].status, Status.Locked); - assert.equal(games![1].status, Status.Locked); - assert.equal(games![2].status, Status.Locked); - }, -); - -MatchGamesStatus("should set all the child games to Waiting", () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 3 }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - - const games = storage.select("match_game", { parent_id: 2 }); - assert.equal(games![0].status, Status.Waiting); - assert.equal(games![1].status, Status.Waiting); - assert.equal(games![2].status, Status.Waiting); -}); - -MatchGamesStatus("should set all the child games to Ready", () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 3 }, - }); - - manager.update.matchGame({ - parent_id: 0, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 0, - number: 2, - opponent1: { result: "win" }, - }); - - manager.update.matchGame({ - parent_id: 1, - number: 1, - opponent1: { result: "win" }, - }); - manager.update.matchGame({ - parent_id: 1, - number: 2, - opponent1: { result: "win" }, - }); - - const games = storage.select("match_game", { parent_id: 2 }); - assert.equal(games![0].status, Status.Ready); - assert.equal(games![1].status, Status.Ready); - assert.equal(games![2].status, Status.Ready); -}); - -MatchGamesStatus( - "should set the parent match to Running when one match game starts", - () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 3 }, - }); - - manager.update.matchGame({ - id: 1, - opponent1: { score: 0 }, - opponent2: { score: 0 }, - }); - - const games = storage.select("match_game", { parent_id: 0 }); - - // Siblings are left untouched. - assert.equal(games![0].status, Status.Ready); - assert.equal(games![2].status, Status.Ready); - - assert.equal(games![1].status, Status.Running); - assert.equal(storage.select("match", 0).status, Status.Running); - }, -); - -MatchGamesStatus( - "should set the child game to Completed without changing the siblings or the parent match status", - () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 3 }, - }); - - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - - const games = storage.select("match_game", { parent_id: 0 }); - - // Siblings and parent match are left untouched. - assert.equal(games![0].status, Status.Ready); - assert.equal(games![2].status, Status.Ready); - assert.equal(storage.select("match", 0).status, Status.Running); - - assert.equal(games![1].status, Status.Completed); - }, -); - -MatchGamesStatus("should set the parent match to Completed", () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "single_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 3 }, - }); - - manager.update.matchGame({ id: 0, opponent1: { result: "win" } }); - manager.update.matchGame({ id: 1, opponent1: { result: "win" } }); - assert.equal(storage.select("match", 0).status, Status.Completed); - - // Left untouched, can be played if we want. - assert.equal(storage.select("match_game", 2).status, Status.Ready); - - manager.update.matchGame({ id: 2, opponent1: { result: "win" } }); - assert.equal(storage.select("match", 0).status, Status.Completed); - assert.equal(storage.select("match_game", 2).status, Status.Completed); -}); - -MatchGamesStatus( - "should work with unique match games when controlled via the parent", - () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "double_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 1 }, - }); - - assert.equal(storage.select("match_game", 2).status, Status.Locked); - assert.equal(storage.select("match_game", 3).status, Status.Locked); - - manager.update.match({ - id: 0, - opponent1: { score: 2, result: "win" }, - opponent2: { score: 1 }, - }); - - assert.equal(storage.select("match_game", 2).status, Status.Waiting); - assert.equal(storage.select("match_game", 3).status, Status.Waiting); - - manager.update.match({ - id: 1, - opponent1: { score: 1 }, - opponent2: { score: 2, result: "win" }, - }); - - assert.equal(storage.select("match_game", 2).status, Status.Ready); - assert.equal(storage.select("match_game", 3).status, Status.Ready); - }, -); - -MatchGamesStatus( - "should work with unique match games when controlled via the child games", - () => { - manager.create({ - tournamentId: 0, - name: "Example", - type: "double_elimination", - seeding: ["Team 1", "Team 2", "Team 3", "Team 4"], - settings: { matchesChildCount: 1 }, - }); - - assert.equal(storage.select("match_game", 2).status, Status.Locked); - assert.equal(storage.select("match_game", 3).status, Status.Locked); - - manager.update.matchGame({ - id: 0, - opponent1: { score: 2, result: "win" }, - opponent2: { score: 1 }, - }); - - assert.equal(storage.select("match_game", 2).status, Status.Waiting); - assert.equal(storage.select("match_game", 3).status, Status.Waiting); - - manager.update.matchGame({ - id: 1, - opponent1: { score: 1 }, - opponent2: { score: 2, result: "win" }, - }); - - assert.equal(storage.select("match_game", 2).status, Status.Ready); - assert.equal(storage.select("match_game", 3).status, Status.Ready); - }, -); - UpdateMatches.run(); GiveOpponentIds.run(); LockedMatches.run(); -UpdateMatchGames.run(); Seeding.run(); -MatchGamesStatus.run(); diff --git a/app/modules/brackets-manager/types.ts b/app/modules/brackets-manager/types.ts index 757a65d8e..f0faf5742 100644 --- a/app/modules/brackets-manager/types.ts +++ b/app/modules/brackets-manager/types.ts @@ -1,7 +1,6 @@ import type { Group, Match, - MatchGame, Participant, Round, SeedOrdering, @@ -51,11 +50,6 @@ export type Side = "opponent1" | "opponent2"; */ export type Scores = { opponent1: number; opponent2: number }; -/** - * The possible levels of data to which we can update the child games count. - */ -export type ChildCountLevel = "stage" | "group" | "round" | "match"; - /** * Positional information about a round. */ @@ -96,7 +90,6 @@ export interface DataTypes { group: Group; round: Round; match: Match; - match_game: MatchGame; participant: Participant; } diff --git a/app/modules/brackets-manager/update.ts b/app/modules/brackets-manager/update.ts index 4102b275a..9e7a326b7 100644 --- a/app/modules/brackets-manager/update.ts +++ b/app/modules/brackets-manager/update.ts @@ -1,6 +1,5 @@ import type { Match, - MatchGame, Round, Seeding, SeedOrdering, @@ -8,7 +7,7 @@ import type { import { Status } from "~/modules/brackets-model"; import { ordering } from "./ordering"; import { BaseUpdater } from "./base/updater"; -import type { ChildCountLevel, DeepPartial } from "./types"; +import type { DeepPartial } from "./types"; import * as helpers from "./helpers"; export class Update extends BaseUpdater { @@ -28,21 +27,6 @@ export class Update extends BaseUpdater { this.updateMatch(stored, match); } - /** - * Updates partial information of a match game. Its id must be given. - * - * This will update the parent match accordingly. - * - * @param game Values to change in a match game. - */ - public matchGame( - game: DeepPartial, - ): void { - const stored = this.findMatchGame(game); - - this.updateMatchGame(stored, game); - } - /** * Updates the seed ordering of every ordered round in a stage. * @@ -81,39 +65,6 @@ export class Update extends BaseUpdater { this.updateRoundOrdering(round, method); } - /** - * Updates child count of all matches of a given level. - * - * @param level The level at which to act. - * @param id ID of the chosen level. - * @param childCount The target child count. - */ - public matchChildCount( - level: ChildCountLevel, - id: number, - childCount: number, - ): void { - switch (level) { - case "stage": - this.updateStageMatchChildCount(id, childCount); - break; - case "group": - this.updateGroupMatchChildCount(id, childCount); - break; - case "round": - this.updateRoundMatchChildCount(id, childCount); - break; - case "match": - // eslint-disable-next-line no-case-declarations - const match = this.storage.select("match", id); - if (!match) throw Error("Match not found."); - this.adjustMatchChildGames(match, childCount); - break; - default: - throw Error("Unknown child count level."); - } - } - /** * Updates the seeding of a stage. * @@ -168,81 +119,6 @@ export class Update extends BaseUpdater { this.applyRoundOrdering(round.number, matches, positions); } - /** - * Updates child count of all matches of a stage. - * - * @param stageId ID of the stage. - * @param childCount The target child count. - */ - private updateStageMatchChildCount( - stageId: number, - childCount: number, - ): void { - if ( - !this.storage.update( - "match", - { stage_id: stageId }, - { child_count: childCount }, - ) - ) - throw Error("Could not update the match."); - - const matches = this.storage.select("match", { stage_id: stageId }); - if (!matches) throw Error("This stage has no match."); - - for (const match of matches) this.adjustMatchChildGames(match, childCount); - } - - /** - * Updates child count of all matches of a group. - * - * @param groupId ID of the group. - * @param childCount The target child count. - */ - private updateGroupMatchChildCount( - groupId: number, - childCount: number, - ): void { - if ( - !this.storage.update( - "match", - { group_id: groupId }, - { child_count: childCount }, - ) - ) - throw Error("Could not update the match."); - - const matches = this.storage.select("match", { group_id: groupId }); - if (!matches) throw Error("This group has no match."); - - for (const match of matches) this.adjustMatchChildGames(match, childCount); - } - - /** - * Updates child count of all matches of a round. - * - * @param roundId ID of the round. - * @param childCount The target child count. - */ - private updateRoundMatchChildCount( - roundId: number, - childCount: number, - ): void { - if ( - !this.storage.update( - "match", - { round_id: roundId }, - { child_count: childCount }, - ) - ) - throw Error("Could not update the match."); - - const matches = this.storage.select("match", { round_id: roundId }); - if (!matches) throw Error("This round has no match."); - - for (const match of matches) this.adjustMatchChildGames(match, childCount); - } - /** * Updates the ordering of participants in a round's matches. * @@ -267,53 +143,4 @@ export class Update extends BaseUpdater { throw Error("Could not update the match."); } } - - /** - * Adds or deletes match games of a match based on a target child count. - * - * @param match The match of which child games need to be adjusted. - * @param targetChildCount The target child count. - */ - private adjustMatchChildGames(match: Match, targetChildCount: number): void { - const games = this.storage.select("match_game", { - parent_id: match.id, - }); - let childCount = games ? games.length : 0; - - while (childCount < targetChildCount) { - const id = this.storage.insert("match_game", { - number: childCount + 1, - stage_id: match.stage_id, - parent_id: match.id, - status: match.status, - opponent1: { id: null }, - opponent2: { id: null }, - }); - - if (id === -1) - throw Error("Could not adjust the match games when inserting."); - - childCount++; - } - - while (childCount > targetChildCount) { - const deleted = this.storage.delete("match_game", { - parent_id: match.id, - number: childCount, - }); - - if (!deleted) - throw Error("Could not adjust the match games when deleting."); - - childCount--; - } - - if ( - !this.storage.update("match", match.id, { - ...match, - child_count: targetChildCount, - }) - ) - throw Error("Could not update the match."); - } } diff --git a/app/modules/brackets-memory-db/index.ts b/app/modules/brackets-memory-db/index.ts index 042826239..eac7e661e 100644 --- a/app/modules/brackets-memory-db/index.ts +++ b/app/modules/brackets-memory-db/index.ts @@ -13,7 +13,6 @@ export class InMemoryDatabase implements CrudInterface { group: [], round: [], match: [], - match_game: [], }; /** @@ -46,7 +45,6 @@ export class InMemoryDatabase implements CrudInterface { group: [], round: [], match: [], - match_game: [], }; } @@ -73,8 +71,14 @@ export class InMemoryDatabase implements CrudInterface { if (!Array.isArray(values)) { try { - // @ts-expect-error imported - this.data[table].push({ id, ...values }); + // @ts-expect-error idc + if (values.id) { + // @ts-expect-error idc + this.data[table][values.id] = values; + } else { + // @ts-expect-error imported + this.data[table].push({ id, ...values }); + } } catch (error) { return -1; } @@ -83,8 +87,14 @@ export class InMemoryDatabase implements CrudInterface { try { values.forEach((object) => { - // @ts-expect-error imported - this.data[table].push({ id: id++, ...object }); + // @ts-expect-error idc + if (object.id) { + // @ts-expect-error idc + this.data[table][object.id] = object; + } else { + // @ts-expect-error imported + this.data[table].push({ id: id++, ...object }); + } }); } catch (error) { return false; @@ -127,7 +137,7 @@ export class InMemoryDatabase implements CrudInterface { if (typeof arg === "number") { // @ts-expect-error imported - return clone(this.data[table].find((d) => d.id === arg)); + return clone(this.data[table].find((d) => d?.id === arg)); } // @ts-expect-error imported @@ -169,6 +179,14 @@ export class InMemoryDatabase implements CrudInterface { value?: Partial, ): boolean { if (typeof arg === "number") { + if ( + this.data[table][arg] && + value && + // @ts-expect-error idc + this.data[table][arg].id !== value.id + ) { + throw new Error("ID mismatch."); + } try { // @ts-expect-error imported this.data[table][arg] = value; diff --git a/app/modules/brackets-model/input.ts b/app/modules/brackets-model/input.ts index dbb73b7f5..b4fb3f23c 100644 --- a/app/modules/brackets-model/input.ts +++ b/app/modules/brackets-model/input.ts @@ -84,11 +84,6 @@ export interface StageSettings { */ balanceByes?: boolean; - /** - * All matches of the stage will have this child count. This can later be overridden for certain groups, rounds or matches. - */ - matchesChildCount?: number; - /** * Number of groups in a round-robin stage. */ diff --git a/app/modules/brackets-model/storage.ts b/app/modules/brackets-model/storage.ts index a6add2f1a..bbda3c37b 100644 --- a/app/modules/brackets-model/storage.ts +++ b/app/modules/brackets-model/storage.ts @@ -95,23 +95,5 @@ export interface Match extends MatchResults { /** The number of the match in its round. */ number: number; - /** The count of match games this match has. Can be `0` if it's a simple match, or a positive number for "Best Of" matches. */ - child_count: number; -} - -/** - * A game of a match. - */ -export interface MatchGame extends MatchResults { - /** ID of the match game. */ - id: number; - - /** ID of the parent stage. */ - stage_id: number; - - /** ID of the parent match. */ - parent_id: number; - - /** The number of the match game in its parent match. */ - number: number; + lastGameFinishedAt?: number | null; } diff --git a/app/modules/twitch/streams.ts b/app/modules/twitch/streams.ts index 474fa0381..62d7f0c25 100644 --- a/app/modules/twitch/streams.ts +++ b/app/modules/twitch/streams.ts @@ -73,8 +73,8 @@ export async function getStreams() { const result = await cachified({ key: `twitch-streams`, cache, - // 5 minutes - ttl: 1000 * 60 * 5, + // 2 minutes + ttl: 1000 * 60 * 2, // 10 minutes staleWhileRevalidate: 1000 * 60 * 5 * 2, async getFreshValue() { diff --git a/app/root.tsx b/app/root.tsx index 45ce36800..e2db21a06 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -161,10 +161,6 @@ function Document({ -