import clsx from "clsx"; import { ChevronsUpDown, Link as LinkIcon, Minus, MousePointerClick, Plus, RefreshCcw, Unlink, } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { type FetcherWithComponents, Link, useFetcher } from "react-router"; import { SendouDialog } from "~/components/elements/Dialog"; import { ModeImage, StageImage } from "~/components/Image"; import { InfoPopover } from "~/components/InfoPopover"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; import { SubmitButton } from "~/components/SubmitButton"; import type { TournamentRoundMaps } from "~/db/tables"; import { useTournament, useTournamentPreparedMaps, } from "~/features/tournament/routes/to.$id"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { nullFilledArray } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import invariant from "~/utils/invariant"; import { assertUnreachable } from "~/utils/types"; import { calendarEditPage } from "~/utils/urls"; import { SendouButton } from "../../../components/elements/Button"; import { logger } from "../../../utils/logger"; import type { Bracket } from "../core/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; import { getRounds } from "../core/rounds"; import type { Tournament } from "../core/Tournament"; import { type BracketMapCounts, generateTournamentRoundMaplist, type TournamentRoundMapList, } from "../core/toMapList"; import styles from "./BracketMapListDialog.module.css"; export function BracketMapListDialog({ close, bracket, bracketIdx, isPreparing, }: { close: () => void; bracket: Bracket; bracketIdx: number; isPreparing?: boolean; }) { const { t } = useTranslation(["common"]); const fetcher = useFetcher(); const tournament = useTournament(); const untrimmedPreparedMaps = useBracketPreparedMaps(bracketIdx); useCloseModalOnSubmit(fetcher, close); const bracketTeamsCount = bracket.participantTournamentTeamIds.length; const preparedMaps = !isPreparing && (bracket.type === "single_elimination" || bracket.type === "double_elimination") ? // we are about to start bracket, "trim" prepared map for actual PreparedMaps.trimPreparedEliminationMaps({ preparedMaps: untrimmedPreparedMaps, teamCount: bracketTeamsCount, bracket, }) : untrimmedPreparedMaps; const [eliminationTeamCount, setEliminationTeamCount] = React.useState< number | null >(() => { if (preparedMaps?.eliminationTeamCount) { return preparedMaps.eliminationTeamCount; } if (isPreparing) { return null; } return PreparedMaps.eliminationTeamCountOptions(bracketTeamsCount)[0].max; }); const [thirdPlaceMatchLinked, setThirdPlaceMatchLinked] = React.useState( () => { if ( !tournament.bracketManagerSettings( bracket.settings, bracket.type, eliminationTeamCount ?? 2, ).consolationFinal ) { return true; // default to true if not applicable or elimination team count not yet set (initial state) } if (!preparedMaps?.maps) { return true; } // if maps were set before infer default from whether finals and third place match have different maps or not const finalsMaps = preparedMaps.maps .filter((map) => map.groupId === 0) .sort((a, b) => b.roundId - a.roundId)[0]; const thirdPlaceMaps = preparedMaps.maps.find((map) => map.groupId === 1); if (!finalsMaps?.list || !thirdPlaceMaps?.list) { logger.error( "Expected both finals and third place match maps to be defined", ); return true; } return ( finalsMaps.count === thirdPlaceMaps.count && finalsMaps.pickBan === thirdPlaceMaps.pickBan && finalsMaps.list.every( (map, i) => map.mode === thirdPlaceMaps.list![i].mode && map.stageId === thirdPlaceMaps.list![i].stageId, ) ); }, ); const [patterns, setPatterns] = React.useState(new Map()); const bracketData = isPreparing ? teamCountAdjustedBracketData({ bracket, teamCount: eliminationTeamCount ?? 2, }) : bracket.data; const rounds = bracketData.round; const defaultRoundBestOfs = bracket.defaultRoundBestOfs(bracketData); const [countType, setCountType] = React.useState( preparedMaps?.maps[0].type ?? "BEST_OF", ); const [maps, setMaps] = React.useState(() => { if (preparedMaps) { return new Map(preparedMaps.maps.map((map) => [map.roundId, map])); } return generateTournamentRoundMaplist({ mapCounts: defaultRoundBestOfs, roundsWithPickBan: new Set(), pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, pickBanStyle: null, patterns, countType, }); }); const [pickBanStyle, setPickBanStyle] = React.useState( Array.from(maps.values()).find((round) => round.pickBan)?.pickBan ?? "COUNTERPICK", ); const [hoveredMap, setHoveredMap] = React.useState(null); const roundsWithPickBan = new Set( Array.from(maps.entries()) .filter(([, round]) => round.pickBan) .map(([roundId]) => roundId), ); const mapCounts = inferMapCounts({ bracket, data: bracketData, tournamentRoundMapList: maps, }); const roundsWithNames = React.useMemo(() => { if (bracket.type === "round_robin" || bracket.type === "swiss") { return Array.from(maps.keys()).map((roundId, i) => { return { id: roundId, name: `Round ${i + 1}`, }; }); } if (bracket.type === "double_elimination") { const winners = getRounds({ type: "winners", bracketData }); const losers = getRounds({ type: "losers", bracketData }); return [...winners, ...losers]; } if (bracket.type === "single_elimination") { const rounds = getRounds({ type: "single", bracketData }); const hasThirdPlaceMatch = rounds.some((round) => round.group_id === 1); if (!thirdPlaceMatchLinked || !hasThirdPlaceMatch) return rounds; return rounds .filter((round) => round.group_id !== 1) .map((round) => round.name === "Finals" ? { ...round, name: TOURNAMENT.ROUND_NAMES.FINALS_THIRD_PLACE_MATCH_UNIFIED, } : round, ); } assertUnreachable(bracket.type); }, [bracketData, maps, bracket.type, thirdPlaceMatchLinked]); const mapCountsWithGlobalCount = (newCount: number) => { const newMap = new Map(defaultRoundBestOfs); for (const [groupId, value] of newMap.entries()) { const newGroupMap: typeof value = new Map(value); for (const [roundNumber, roundValue] of value.entries()) { newGroupMap.set(roundNumber, { ...roundValue, count: newCount }); } newMap.set(groupId, newGroupMap); } return newMap; }; const mapCountsWithGlobalPickBanStyle = ( newPickBanStyle: TournamentRoundMaps["pickBan"], ): Set => { if (!newPickBanStyle) { return new Set(); } const newRoundsWithPickBan = new Set(roundsWithPickBan); for (const round of roundsWithNames) { newRoundsWithPickBan.add(round.id); } return newRoundsWithPickBan; }; const validateNoDecreasingCount = () => { for (const groupCounts of mapCounts.values()) { let roundPreviousValue = 0; for (const [, roundValue] of Array.from(groupCounts.entries()).sort( // sort by round number (a, b) => a[0] - b[0], )) { if (roundPreviousValue > roundValue.count) { return false; } roundPreviousValue = roundValue.count; } } // check grands have at least as many maps as winners final (different groups) if (bracket.type === "double_elimination") { const grandsCounts = Array.from(mapCounts.get(2)?.values() ?? []); const winnersCounts = Array.from(mapCounts.get(0)?.values() ?? []); const maxWinnersCount = Math.max(...winnersCounts.map((c) => c.count)); if (grandsCounts.some(({ count }) => count < maxWinnersCount)) { return false; } } return true; }; const lacksToSetMapPool = tournament.ctx.toSetMapPool.length === 0 && tournament.ctx.mapPickingStyle === "TO"; const globalSelections = bracket.type === "round_robin" || bracket.type === "swiss"; const needsToPickEliminationTeamCount = (bracket.type === "single_elimination" || bracket.type === "double_elimination") && !eliminationTeamCount; return ( ({ ...value, roundId: key, groupId: rounds.find((r) => r.id === key)?.group_id, type: countType, })), )} /> {isPreparing && (bracket.type === "single_elimination" || bracket.type === "double_elimination") ? ( ) : null}
{preparedMaps ? (
Prepared by{" "} {authorIdToUsername(tournament, preparedMaps.authorId)} @{" "} {databaseTimestampToDate(preparedMaps.createdAt).toLocaleString()}
) : null}
{lacksToSetMapPool ? (
You need to select map pool in the{" "} tournament settings {" "} before bracket can be started
) : ( <>
{isPreparing && (bracket.type === "single_elimination" || bracket.type === "double_elimination") ? ( { if (!newCount) { setEliminationTeamCount(null); return; } const newBracketData = teamCountAdjustedBracketData({ bracket, teamCount: newCount, }); setMaps( generateTournamentRoundMaplist({ mapCounts: bracket.defaultRoundBestOfs(newBracketData), pool: tournament.ctx.toSetMapPool, rounds: newBracketData.round, type: bracket.type, roundsWithPickBan, pickBanStyle, patterns, countType, }), ); setEliminationTeamCount(newCount); }} /> ) : null} {!needsToPickEliminationTeamCount ? ( { let newRoundsWithPickBan = roundsWithPickBan; if (globalSelections) { newRoundsWithPickBan = mapCountsWithGlobalPickBanStyle(newPickBanStyle); } setPickBanStyle(newPickBanStyle); const noPickBanSetBeforeOrAfter = !roundsWithPickBan.size && !newRoundsWithPickBan.size; const switchedFromCounterpickToAnother = (pickBanStyle === "COUNTERPICK" && newPickBanStyle === "COUNTERPICK_MODE_REPEAT_OK") || (pickBanStyle === "COUNTERPICK_MODE_REPEAT_OK" && newPickBanStyle === "COUNTERPICK"); const shouldSkipRegenerateMaps = noPickBanSetBeforeOrAfter || switchedFromCounterpickToAnother; if (!shouldSkipRegenerateMaps) { setMaps( generateTournamentRoundMaplist({ mapCounts, pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, roundsWithPickBan: newRoundsWithPickBan, pickBanStyle: newPickBanStyle, patterns, countType, }), ); } }} /> ) : null} {globalSelections ? ( ) : null} {tournament.ctx.mapPickingStyle === "TO" && tournament.modesIncluded.length > 1 && !needsToPickEliminationTeamCount ? ( ) : null}
{tournament.ctx.toSetMapPool.length > 0 && !needsToPickEliminationTeamCount ? ( } variant="outlined" onPress={() => setMaps( generateTournamentRoundMaplist({ mapCounts, pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, roundsWithPickBan, pickBanStyle, patterns, countType, }), ) } > Reroll all maps ) : null}
{needsToPickEliminationTeamCount ? (
Pick the expected teams count above to prepare maps
Tip: if uncertain, overestimate the team count.
The system can remove unnecessary rounds, but if you choose too few, you'll need to repick all the maps.
) : ( <>
{roundsWithNames.map((round) => { const roundMaps = maps.get(round.id); invariant(roundMaps, "Expected maps to be defined"); const showUnlinkButton = bracket.type === "single_elimination" && thirdPlaceMatchLinked === true && round.name === TOURNAMENT.ROUND_NAMES.FINALS_THIRD_PLACE_MATCH_UNIFIED; const showLinkButton = bracket.type === "single_elimination" && thirdPlaceMatchLinked === false && round.name === TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH; return ( setThirdPlaceMatchLinked(false) : undefined } link={ showLinkButton ? () => setThirdPlaceMatchLinked(true) : undefined } hoveredMap={hoveredMap} onCountChange={(newCount) => { if (globalSelections) { const newMapCounts = mapCountsWithGlobalCount(newCount); setMaps( generateTournamentRoundMaplist({ mapCounts: newMapCounts, pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, roundsWithPickBan, pickBanStyle, patterns, countType, }), ); return; } const newMapCounts = new Map(mapCounts); const bracketRound = rounds.find( (r) => r.id === round.id, ); invariant( bracketRound, "Expected round to be defined", ); const groupInfo = newMapCounts.get( bracketRound.group_id, ); invariant( groupInfo, "Expected group info to be defined", ); const oldMapInfo = newMapCounts .get(bracketRound.group_id) ?.get(bracketRound.number); invariant( oldMapInfo, "Expected map info to be defined", ); groupInfo.set(bracketRound.number, { ...oldMapInfo, count: newCount, }); const newMap = generateTournamentRoundMaplist({ mapCounts: newMapCounts, pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, roundsWithPickBan, pickBanStyle, patterns, countType, }).get(round.id); setMaps(new Map(maps).set(round.id, newMap!)); }} onPickBanChange={(hasPickBan) => { if (globalSelections) { const newRoundsWithPickBan = hasPickBan ? mapCountsWithGlobalPickBanStyle(pickBanStyle) : new Set(); setMaps( generateTournamentRoundMaplist({ mapCounts, pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, roundsWithPickBan: newRoundsWithPickBan, pickBanStyle, patterns, countType, }), ); return; } const newRoundsWithPickBan = new Set( roundsWithPickBan, ); if (hasPickBan) { newRoundsWithPickBan.add(round.id); } else { newRoundsWithPickBan.delete(round.id); } const newMap = generateTournamentRoundMaplist({ mapCounts, pool: tournament.ctx.toSetMapPool, rounds, type: bracket.type, roundsWithPickBan: newRoundsWithPickBan, pickBanStyle, patterns, countType, }).get(round.id); setMaps(new Map(maps).set(round.id, newMap!)); }} onRoundMapListChange={(newRoundMaps) => { const newMaps = new Map(maps); newMaps.set(round.id, newRoundMaps); setMaps(newMaps); }} /> ); })}
{!validateNoDecreasingCount() ? (
Invalid selection: tournament progression decreases in map count
) : ( {isPreparing ? t("common:actions.save") : "Start the bracket"} )} )} )}
); } function useCloseModalOnSubmit( fetcher: FetcherWithComponents, close: () => void, ) { React.useEffect(() => { if (fetcher.state !== "loading") return; close(); }, [fetcher.state, close]); } function inferMapCounts({ bracket, data, tournamentRoundMapList, }: { bracket: Bracket; data: TournamentManagerDataSet; tournamentRoundMapList: TournamentRoundMapList; }) { const result: BracketMapCounts = new Map(); for (const [groupId, value] of bracket.defaultRoundBestOfs(data).entries()) { for (const roundNumber of value.keys()) { const roundId = data.round.find( (round) => round.group_id === groupId && round.number === roundNumber, )?.id; invariant(typeof roundId === "number", "Expected roundId to be defined"); const count = tournamentRoundMapList.get(roundId)?.count; // skip rounds in RR and Swiss that don't have maps (only one group has maps) if (typeof count !== "number") { continue; } result.set( groupId, new Map(result.get(groupId)).set(roundNumber, { count, // currently "best of" / "play all" is defined per bracket but in future it might be per round // that's why there is this hardcoded default value for now type: "BEST_OF", }), ); } } invariant(result.size > 0, "Expected result to be defined"); return result; } function useBracketPreparedMaps(bracketIdx: number) { const prepared = useTournamentPreparedMaps(); const tournament = useTournament(); return PreparedMaps.resolvePreparedForTheBracket({ bracketIdx, preparedByBracket: prepared, tournament, }); } function authorIdToUsername(tournament: Tournament, authorId: number) { if (tournament.ctx.author.id === authorId) { return tournament.ctx.author.username; } return ( tournament.ctx.staff.find((staff) => staff.id === authorId)?.username ?? tournament.ctx.organization?.members.find( (member) => member.userId === authorId, )?.username ?? "???" ); } function teamCountAdjustedBracketData({ bracket, teamCount, }: { bracket: Bracket; teamCount: number; }) { switch (bracket.type) { case "swiss": // always has the same amount of rounds even if 0 participants return bracket.data; case "round_robin": // ensure a full bracket (no bye round) gets generated even if registration is underway return bracket.generateMatchesData( nullFilledArray( bracket.settings?.teamsPerGroup ?? TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP, ).map((_, i) => i + 1), ); case "single_elimination": case "double_elimination": return bracket.generateMatchesData( nullFilledArray(teamCount).map((_, i) => i + 1), ); } } function EliminationTeamCountSelect({ count, realCount, setCount, }: { count: number | null; realCount: number; setCount: (count: number | null) => void; }) { return (
); } function GlobalCountTypeSelect({ defaultValue, onSetCountType, }: { defaultValue: TournamentRoundMaps["type"]; onSetCountType: (type: TournamentRoundMaps["type"]) => void; }) { return (
); } function PickBanSelect({ pickBanStyle, isOneModeOnly, onPickBanStyleChange, }: { pickBanStyle: NonNullable; isOneModeOnly: boolean; onPickBanStyleChange: ( pickBanStyle: NonNullable, ) => void; }) { const pickBanSelectText: Record = { COUNTERPICK: "Counterpick", COUNTERPICK_MODE_REPEAT_OK: "Counterpick (mode repeat allowed)", BAN_2: "Ban 2", }; // selection doesn't make sense for one mode only tournaments as you have to repeat the mode const availableTypes = PickBan.types.filter( (type) => !isOneModeOnly || type !== "COUNTERPICK_MODE_REPEAT_OK", ); return (
); } const serializedMapMode = ( map: NonNullable[number], ) => `${map.mode}-${map.stageId}`; function RoundMapList({ name, maps, onHoverMap, onCountChange, onPickBanChange, onRoundMapListChange, unlink, link, hoveredMap, }: { name: string; maps: Omit; onHoverMap: (map: string | null) => void; onCountChange: (count: number) => void; onPickBanChange: (hasPickBan: boolean) => void; onRoundMapListChange: (maps: Omit) => void; unlink?: () => void; link?: () => void; hoveredMap: string | null; }) { const tournament = useTournament(); const minCount = TOURNAMENT.AVAILABLE_BEST_OF[0]; const maxCount = TOURNAMENT.AVAILABLE_BEST_OF.at(-1)!; return (

{name}

{maps.count}
{unlink ? ( ) : null} {link ? ( ) : null}
    {nullFilledArray( maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count, ).map((_, i) => { const map = maps.list?.[i]; if (map) { return ( { onRoundMapListChange({ ...maps, list: maps.list?.map((m, j) => (i === j ? map : m)), }); }} /> ); } const isTeamsPick = !maps.list && i === 0; const isLast = i === (maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count) - 1; return ( ); })}
); } function MapListRow({ map, number, onHoverMap, hoveredMap, onMapChange, }: { map: NonNullable[number]; number: number; onHoverMap: (map: string | null) => void; hoveredMap: string | null; onMapChange: (map: NonNullable[number]) => void; }) { const { t } = useTranslation(["game-misc"]); const tournament = useTournament(); return (
  • onHoverMap(serializedMapMode(map))} >
    {number}. {t(`game-misc:STAGE_${map.stageId}`)}
  • ); } function MysteryRow({ number, isCounterpicks, isTiebreaker, }: { number: number; isCounterpicks: boolean; isTiebreaker: boolean; }) { return (
  • {number}. {isCounterpicks ? "Counterpick" : isTiebreaker ? "Tiebreaker" : "Team's pick"}
  • ); } function PatternInputs({ patterns, mapCounts, onPatternsChange, }: { patterns: Map; mapCounts: BracketMapCounts; onPatternsChange: (patterns: Map) => void; }) { const uniqueCounts = new Set(); for (const groupCounts of mapCounts.values()) { for (const { count } of groupCounts.values()) { uniqueCounts.add(count); } } const sortedCounts = Array.from(uniqueCounts).sort((a, b) => a - b); return (
    Control the mode selection with a pattern. Examples:
    *SZ* Any, SZ, any mode
    SZ*RM SZ, any, RM
    [TC] Must include TC
    [RM!] RM in guaranteed spots
    [TC]*SZ* TC once + every 2nd is SZ
    {sortedCounts.map((count) => ( { const newPatterns = new Map(patterns); if (e.target.value) { newPatterns.set(count, e.target.value); } else { newPatterns.delete(count); } onPatternsChange(newPatterns); }} /> ))}
    ); }