mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 13:06:13 -05:00
SE/DE exit link: link to the correct group too
This commit is contained in:
@@ -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]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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) {
|
||||
<MatchExitLabels
|
||||
match={props.match}
|
||||
destinations={props.exitDestinations}
|
||||
groups={props.exitGroups}
|
||||
hasLine={Boolean(props.lineType) && props.lineType !== "none"}
|
||||
/>
|
||||
) : 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 <div key={type} className={styles.matchExitRow} />;
|
||||
}
|
||||
@@ -149,6 +158,7 @@ function MatchExitLabels({
|
||||
to={tournamentBracketsPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
bracketIdx,
|
||||
groupId,
|
||||
})}
|
||||
className={styles.matchExit}
|
||||
title={title}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<EliminationBracketSide
|
||||
type="losers"
|
||||
bracket={bracket}
|
||||
groupId={selectedGroupId}
|
||||
isExpanded={bracketExpanded}
|
||||
exitGroups={exitGroups}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<number, MatchExitGroups>>;
|
||||
|
||||
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<number, MatchExitGroups> {
|
||||
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<number, MatchExitGroups>();
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user