diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index 44eaa67d5..734948b18 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -1,6 +1,7 @@ import * as R from "remeda"; import { TOURNAMENT } from "../../../tournament/tournament-constants"; import type { Bracket as BracketType } from "../../core/Bracket"; +import type { MatchExitGroupsById } from "../../core/Bracket/Bracket"; import { getRounds } from "../../core/rounds"; import { BracketColumn, @@ -17,6 +18,7 @@ interface EliminationBracketSideProps { /** Group shown of a bracket split into groups, defaults to the first. */ groupId?: number; isExpanded?: boolean; + exitGroups?: MatchExitGroupsById; } // must match --match-height in index.module.css and the match gap in BracketColumns.module.css @@ -195,6 +197,7 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) { lineType={lineType} lineVerticalExtend={verticalExtend} exitDestinations={exitDestinations.get(match.id)} + exitGroups={props.exitGroups?.[match.id]} /> ); })} diff --git a/app/features/tournament-bracket/components/Bracket/Match.tsx b/app/features/tournament-bracket/components/Bracket/Match.tsx index 869eae1f8..fc82347b0 100644 --- a/app/features/tournament-bracket/components/Bracket/Match.tsx +++ b/app/features/tournament-bracket/components/Bracket/Match.tsx @@ -22,7 +22,10 @@ import { vodUrl, } from "~/utils/urls"; import type { Bracket } from "../../core/Bracket"; -import type { MatchExitDestinations } from "../../core/Bracket/Bracket"; +import type { + MatchExitDestinations, + MatchExitGroups, +} from "../../core/Bracket/Bracket"; import * as Deadline from "../../core/Deadline"; import type { TournamentData } from "../../core/Tournament.server"; import type { VodsByTournamentId } from "../../TournamentMatchVodRepository.server"; @@ -45,6 +48,8 @@ interface MatchProps { spoilerCensor?: "full" | "score-only"; /** Brackets the teams leaving the bracket from this match continue to. */ exitDestinations?: MatchExitDestinations; + /** Groups of the destination brackets the teams leaving from this match landed in. */ + exitGroups?: MatchExitGroups; } export function Match(props: MatchProps) { @@ -79,6 +84,7 @@ export function Match(props: MatchProps) { ) : null} @@ -96,10 +102,12 @@ export function Match(props: MatchProps) { function MatchExitLabels({ match, destinations, + groups, hasLine, }: { match: MatchProps["match"]; destinations: MatchExitDestinations; + groups?: MatchExitGroups; hasLine: boolean; }) { const { t } = useTranslation(["tournament"]); @@ -108,6 +116,7 @@ function MatchExitLabels({ const exitRow = (type: "winner" | "loser", bracketIdx: number | null) => ({ type, bracketIdx, + groupId: groups?.[type] ?? undefined, bracket: bracketIdx === null ? null : tournament.bracketsMeta[bracketIdx]?.name, isLinkable: tournament.visibleBracketsMeta.some( @@ -128,7 +137,7 @@ function MatchExitLabels({ })} data-testid="match-exits" > - {rows.map(({ type, bracket, bracketIdx, isLinkable }) => { + {rows.map(({ type, bracket, bracketIdx, groupId, isLinkable }) => { if (!bracket) { return
; } @@ -149,6 +158,7 @@ function MatchExitLabels({ to={tournamentBracketsPage({ tournamentId: tournament.ctx.id, bracketIdx, + groupId, })} className={styles.matchExit} title={title} diff --git a/app/features/tournament-bracket/components/Bracket/index.tsx b/app/features/tournament-bracket/components/Bracket/index.tsx index 0d6d3ff65..d65a0f748 100644 --- a/app/features/tournament-bracket/components/Bracket/index.tsx +++ b/app/features/tournament-bracket/components/Bracket/index.tsx @@ -4,6 +4,7 @@ import { useBracketExpanded } from "~/features/tournament/routes/to.$id"; import { useDragToScroll } from "~/hooks/useDragToScroll"; import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect"; import type { Bracket as BracketType } from "../../core/Bracket"; +import type { MatchExitGroupsById } from "../../core/Bracket/Bracket"; import { EliminationBracketSide } from "./Elimination"; import { GroupTabs } from "./GroupTabs"; import styles from "./index.module.css"; @@ -14,11 +15,13 @@ export function Bracket({ bracket, bracketIdx, groupId, + exitGroups, }: { bracket: BracketType; bracketIdx: number; /** Group whose matches were loaded, for the bracket types shown one group at a time. */ groupId?: number | null; + exitGroups?: MatchExitGroupsById; }) { const { bracketExpanded } = useBracketExpanded(); @@ -57,6 +60,7 @@ export function Bracket({ bracket={bracket} groupId={selectedGroupId} isExpanded={bracketExpanded} + exitGroups={exitGroups} /> ) : ( <> @@ -65,12 +69,14 @@ export function Bracket({ bracket={bracket} groupId={selectedGroupId} isExpanded={bracketExpanded} + exitGroups={exitGroups} /> )} diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 255efb567..e520c7f5c 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -2212,6 +2212,106 @@ describe("follow-up seeding through a redemption bracket", () => { }); }); +describe("matchExitGroups", () => { + const TEAM_IDS = Array.from({ length: 16 }, (_, index) => index + 1); + const PROGRESSION: TournamentSettings["bracketProgression"] = [ + { + type: "double_elimination", + name: "Main", + requiresCheckIn: false, + settings: { + groupCount: 2, + skippedRounds: [ + "LB_SEMIS", + "LB_FINALS", + "GRAND_FINALS", + "BRACKET_RESET", + ], + }, + }, + { + type: "double_elimination", + name: "Redemption", + requiresCheckIn: false, + settings: { + groupCount: 2, + skippedRounds: ["GRAND_FINALS", "BRACKET_RESET"], + }, + sources: [{ bracketIdx: 0, placements: [2, 3] }], + }, + { + type: "single_elimination", + name: "Top Cut", + requiresCheckIn: false, + settings: {}, + sources: [{ bracketIdx: 0, placements: [1] }], + }, + ]; + + const tournamentWith = (data: BracketData) => + testTournament({ + ctx: { + settings: { bracketProgression: PROGRESSION }, + teams: TEAM_IDS.map((id) => tournamentCtxTeam(id, { seed: id })), + }, + data, + }); + + const main = playedOut( + createResolved({ + type: "double_elimination", + seeding: TEAM_IDS, + settings: PROGRESSION[0].settings, + }), + ); + + test("gives the group of the not yet started grouped destination each leaving team is projected in", () => { + expectExitGroupsToMatchDestination(tournamentWith(main)); + }); + + test("gives the group of the started grouped destination each leaving team landed in", () => { + const redemption = createResolved({ + type: "double_elimination", + seeding: tournamentWith(main).bracketByIdx(1)!.seeding!, + settings: PROGRESSION[1].settings, + }); + + expectExitGroupsToMatchDestination( + tournamentWith(mergeStages(main, redemption)), + ); + }); + + function expectExitGroupsToMatchDestination(tournament: Tournament) { + const redemptionData = tournament.bracketByIdx(1)!.data; + const redemptionGroupOf = (teamId: number | null | undefined) => + redemptionData.match.find( + (match) => + match.opponent1?.id === teamId || match.opponent2?.id === teamId, + )?.groupId ?? null; + + const exitGroups = [...tournament.bracketByIdx(0)!.matchExitGroups()]; + + for (const [matchId, groups] of exitGroups) { + const match = matchById(main, matchId); + const winner = + match.winnerSide === "opponent1" ? "opponent1" : "opponent2"; + const loser = winner === "opponent1" ? "opponent2" : "opponent1"; + + expect(groups).toEqual({ + winner: redemptionGroupOf(match[winner]?.id), + loser: redemptionGroupOf(match[loser]?.id), + }); + } + expect( + new Set( + exitGroups.flatMap(([, groups]) => [groups.winner, groups.loser]), + ), + ).toEqual( + new Set([null, ...redemptionData.group.map((group) => group.id)]), + ); + } +}); + /** Plays every match of the bracket, the lower team id winning unless the other team is favored. */ function playedOut(data: BracketData, favoredIds: number[] = []): BracketData { let result = data; diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 2c5ca945b..b6694433a 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -9,6 +9,7 @@ import type { } from "~/features/tournament-bracket/core/engine/types"; import { invariant } from "~/utils/invariant"; import { logger } from "~/utils/logger"; +import { showsOneGroupAtATime } from "../../tournament-bracket-utils"; import * as AbDivisions from "../AbDivisions"; import * as Engine from "../engine"; import * as Progression from "../Progression"; @@ -68,6 +69,15 @@ export interface MatchExitDestinations { loser: number | null; } +/** Groups of the destination brackets the winner and loser of a match landed in. See {@link Bracket.matchExitGroups}. */ +export interface MatchExitGroups { + winner: number | null; + loser: number | null; +} + +/** {@link MatchExitGroups} keyed by match id, as shipped to the client. */ +export type MatchExitGroupsById = Partial>; + export interface TeamTrackRecord { wins: number; losses: number; @@ -369,6 +379,52 @@ export abstract class Bracket { return result; } + /** + * Groups the winner and loser of each decided match landed in (or are projected in, if not started), keyed by match id. + * Only destinations shown one group at a time count. Needs their match data, so not available in views that only got `bracketsMeta`. + */ + matchExitGroups(): Map { + const groupIdOfTeam = ( + bracketIdx: number | null, + tournamentTeamId: number | null | undefined, + ) => { + if (bracketIdx === null || typeof tournamentTeamId !== "number") { + return null; + } + + const destination = this.tournament.bracketByIdx(bracketIdx); + if (!destination || !showsOneGroupAtATime(destination)) return null; + + const teamMatch = destination.data.match.find( + (match) => + match.opponent1?.id === tournamentTeamId || + match.opponent2?.id === tournamentTeamId, + ); + + return teamMatch?.groupId ?? null; + }; + + const result = new Map(); + for (const [matchId, destinations] of this.matchExitDestinations()) { + const match = this.data.match.find( + (candidate) => candidate.id === matchId, + ); + if (!match?.winnerSide) continue; + + const winnerId = match[match.winnerSide]?.id; + const loserId = + match[match.winnerSide === "opponent1" ? "opponent2" : "opponent1"]?.id; + + const winner = groupIdOfTeam(destinations.winner, winnerId); + const loser = groupIdOfTeam(destinations.loser, loserId); + if (winner === null && loser === null) continue; + + result.set(matchId, { winner, loser }); + } + + return result; + } + /** Teams start their tournament from this bracket. There can be more than one. */ get isStartingBracket() { return !this.sources || this.sources.length === 0; diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts index 7ee23dab4..530df3843 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts @@ -59,6 +59,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { groupId: bracket.preview ? null : groupId, }) : null, + // destination brackets' match data is not shipped, so their groups are resolved here + exitGroups: Object.fromEntries(bracket?.matchExitGroups() ?? []), // the layout does not ship these, standings derived in the view need them participatedUserIds: tournament.participatedUserIds, // only its bracket check-in is acted on here, the header status indicator covers the rest diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 4b5467c57..1b74b6958 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -50,6 +50,7 @@ import { BracketCheckIn } from "../components/BracketCheckIn"; import { BracketMapListDialog } from "../components/BracketMapListDialog"; import * as AbDivisions from "../core/AbDivisions"; import type { Bracket as BracketType } from "../core/Bracket"; +import type { MatchExitGroupsById } from "../core/Bracket/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; import * as Progression from "../core/Progression"; import type { Tournament } from "../core/Tournament"; @@ -253,6 +254,7 @@ function TournamentBracketsView() { bracket={bracket} bracketIdx={data.bracketIdx} groupId={data.groupId} + exitGroups={data.exitGroups} waitingForTeamsText={waitingForTeamsText} teamsSourceText={teamsSourceText} /> @@ -495,12 +497,14 @@ function BracketTabContent({ bracket, bracketIdx, groupId, + exitGroups, waitingForTeamsText, teamsSourceText, }: { bracket: BracketType; bracketIdx: number; groupId: number | null; + exitGroups: MatchExitGroupsById; waitingForTeamsText: (bracket: BracketType, bracketIdx: number) => string; teamsSourceText: (bracket: BracketType) => string | null; }) { @@ -517,6 +521,7 @@ function BracketTabContent({ bracket={bracket} bracketIdx={bracketIdx} groupId={groupId} + exitGroups={exitGroups} /> ) : ( diff --git a/e2e/tournament-bracket-grouped-elimination.spec.ts b/e2e/tournament-bracket-grouped-elimination.spec.ts index d9d03758f..137d330a0 100644 --- a/e2e/tournament-bracket-grouped-elimination.spec.ts +++ b/e2e/tournament-bracket-grouped-elimination.spec.ts @@ -40,9 +40,27 @@ test.describe("Tournament bracket grouped elimination", () => { await expect(brackets.bracketTeamName("Team 2").first()).toBeVisible(); await isNotVisible(brackets.bracketTeamName("Team 1")); + // a pool's winners final loser is linked to the redemption group it lands in, before and after it starts + const expectExitLinksToRedemptionGroups = async () => { + for (const [pool, team, otherGroupTeam] of [ + ["A", "Team 4", "Team 3"], + ["B", "Team 3", "Team 4"], + ]) { + await brackets.goto(tournament.id, 0); + await brackets.openGroup(pool); + await brackets.exitLabel("Redemption [L]").click(); + await expect(page).toHaveURL(/idx=1.*group=\d+/); + await expect(brackets.bracketTeamName(team).first()).toBeVisible(); + await isNotVisible(brackets.bracketTeamName(otherGroupTeam)); + } + }; + await expectExitLinksToRedemptionGroups(); + // Team 3 (pool B) and Team 4 (pool A) win their redemption groups, having lost their pool's winners final await factories.TournamentFactory.playOut(tournament.id, 1); + await expectExitLinksToRedemptionGroups(); + await brackets.goto(tournament.id, 2); // pool winners take the top seeds and play a redemption winner from the other pool, not a rematch expect(await brackets.firstRoundTeamNames(4)).toEqual([