diff --git a/app/db/tables.ts b/app/db/tables.ts index 2ebd31bd0..ecbaf21cd 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -456,8 +456,12 @@ export interface Tournament { settings: ColumnType; id: GeneratedAlways; mapPickingStyle: TournamentMapPickingStyle; - // TODO: remove in migration - // showMapListGenerator: Generated; + /** Maps prepared ahead of time for rounds. Follows settings.bracketProgression order. Null in the spot if not defined yet for that bracket. */ + preparedMaps: ColumnType< + (PreparedMaps | null)[] | null, + string | null, + string | null + >; castTwitchAccounts: ColumnType; castedMatchesInfo: ColumnType< CastedMatchesInfo | null, @@ -467,6 +471,13 @@ export interface Tournament { rules: string | null; } +export interface PreparedMaps { + authorId: number; + createdAt: number; + maps: Array; + eliminationTeamCount?: number; +} + export interface TournamentBadgeOwner { badgeId: number; userId: number; diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 8be105a8a..5aeed8a47 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -662,6 +662,9 @@ export async function update(args: UpdateArgs) { .set({ settings: JSON.stringify(settings), rules: args.rules, + // when tournament is updated clear the preparedMaps just in case the format changed + // in the future though we might want to be smarter with this i.e. only clear if the format really did change + preparedMaps: null, }) .where("id", "=", tournamentId) .returning("mapPickingStyle") diff --git a/app/features/tournament-bracket/components/Bracket/Elimination.tsx b/app/features/tournament-bracket/components/Bracket/Elimination.tsx index 28b68059e..9662ce4f7 100644 --- a/app/features/tournament-bracket/components/Bracket/Elimination.tsx +++ b/app/features/tournament-bracket/components/Bracket/Elimination.tsx @@ -11,7 +11,7 @@ interface EliminationBracketSideProps { } export function EliminationBracketSide(props: EliminationBracketSideProps) { - const rounds = getRounds(props); + const rounds = getRounds({ ...props, bracketData: props.bracket.data }); let atLeastOneColumnHidden = false; return ( diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index cdca6a8b1..af7233616 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -1,7 +1,6 @@ -import * as React from "react"; - -import { Link, useFetcher } from "@remix-run/react"; +import { type FetcherWithComponents, Link, useFetcher } from "@remix-run/react"; import clsx from "clsx"; +import * as React from "react"; import { useTranslation } from "react-i18next"; import { Button } from "~/components/Button"; import { Dialog } from "~/components/Dialog"; @@ -11,53 +10,119 @@ import { SubmitButton } from "~/components/SubmitButton"; import { Toggle } from "~/components/Toggle"; import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows"; import type { TournamentRoundMaps } from "~/db/tables"; -import { useTournament } from "~/features/tournament/routes/to.$id"; +import { + useTournament, + useTournamentPreparedMaps, +} from "~/features/tournament/routes/to.$id"; +import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import type { ModeShort, StageId } from "~/modules/in-game-lists"; 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 type { Bracket } from "../core/Bracket"; +import * as PreparedMaps from "../core/PreparedMaps"; +import type { Tournament } from "../core/Tournament"; import { getRounds } from "../core/rounds"; -import { generateTournamentRoundMaplist } from "../core/toMapList"; +import { + type BracketMapCounts, + type TournamentRoundMapList, + generateTournamentRoundMaplist, +} from "../core/toMapList"; export function BracketMapListDialog({ isOpen, close, bracket, bracketIdx, + isPreparing, }: { isOpen: boolean; close: () => void; bracket: Bracket; bracketIdx: number; + isPreparing?: boolean; }) { const fetcher = useFetcher(); const tournament = useTournament(); + const untrimmedPreparedMaps = useBracketPreparedMaps(bracketIdx); - const [roundsWithPickBan, setRoundsWithPickBan] = React.useState>( - new Set(), + 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, + tournament, + type: bracket.type, + }) + : untrimmedPreparedMaps; + + const [eliminationTeamCount, setEliminationTeamCount] = React.useState(() => { + if (preparedMaps?.eliminationTeamCount) { + return preparedMaps.eliminationTeamCount; + } + + // at least 8 for somewhat reasonable default + return Math.max( + PreparedMaps.eliminationTeamCountOptions(bracketTeamsCount)[0].max, + PreparedMaps.eliminationTeamCountOptions(0)[2].max, + ); + }); + + const bracketData = isPreparing + ? teamCountAdjustedBracketData({ + bracket, + tournament, + teamCount: eliminationTeamCount, + }) + : bracket.data; + const rounds = bracketData.round; + const defaultRoundBestOfs = bracket.defaultRoundBestOfs(bracketData); + + const [countType, setCountType] = React.useState( + preparedMaps?.maps[0].type ?? "BEST_OF", ); - const [pickBanStyle, setPickBanStyle] = - React.useState(); - const [countType, setCountType] = - React.useState("BEST_OF"); - const [maps, setMaps] = React.useState(() => - generateTournamentRoundMaplist({ - mapCounts: bracket.defaultRoundBestOfs, - roundsWithPickBan, + + 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: bracket.data.round, + rounds, type: bracket.type, - pickBanStyle, - }), - ); - const [mapCounts, setMapCounts] = React.useState( - () => bracket.defaultRoundBestOfs, + pickBanStyle: null, + }); + }); + const [pickBanStyle, setPickBanStyle] = React.useState( + Array.from(maps.values()).find((round) => round.pickBan)?.pickBan, ); const [hoveredMap, setHoveredMap] = React.useState(null); - const rounds = React.useMemo(() => { + 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 { @@ -68,21 +133,21 @@ export function BracketMapListDialog({ } if (bracket.type === "double_elimination") { - const winners = getRounds({ type: "winners", bracket }); - const losers = getRounds({ type: "losers", bracket }); + const winners = getRounds({ type: "winners", bracketData }); + const losers = getRounds({ type: "losers", bracketData }); return [...winners, ...losers]; } if (bracket.type === "single_elimination") { - return getRounds({ type: "single", bracket }); + return getRounds({ type: "single", bracketData }); } assertUnreachable(bracket.type); - }, [bracket, maps]); + }, [bracketData, maps, bracket.type]); const mapCountsWithGlobalCount = (newCount: number) => { - const newMap = new Map(bracket.defaultRoundBestOfs); + const newMap = new Map(defaultRoundBestOfs); for (const [groupId, value] of newMap.entries()) { const newGroupMap: typeof value = new Map(value); @@ -100,17 +165,15 @@ export function BracketMapListDialog({ newPickBanStyle: TournamentRoundMaps["pickBan"], ): Set => { if (!newPickBanStyle) { - setRoundsWithPickBan(new Set()); return new Set(); } const newRoundsWithPickBan = new Set(roundsWithPickBan); - for (const round of rounds) { + for (const round of roundsWithNames) { newRoundsWithPickBan.add(round.id); } - setRoundsWithPickBan(newRoundsWithPickBan); return newRoundsWithPickBan; }; @@ -141,7 +204,7 @@ export function BracketMapListDialog({ bracket.type === "round_robin" || bracket.type === "swiss"; return ( - + ({ - roundId: key, - type: countType, ...value, + roundId: key, + groupId: rounds.find((r) => r.id === key)?.group_id, + type: countType, })), )} /> -

{bracket.name}

+ {isPreparing && + (bracket.type === "single_elimination" || + bracket.type === "double_elimination") ? ( + + ) : null} +
+

{bracket.name}

+ {preparedMaps ? ( +
+ Prepared by{" "} + {authorIdToUsername(tournament, preparedMaps.authorId)} @{" "} + {databaseTimestampToDate(preparedMaps.createdAt).toLocaleString()} +
+ ) : null} +
{lacksToSetMapPool ? (
You need to select map pool in the{" "} @@ -166,7 +251,7 @@ export function BracketMapListDialog({
) : ( <> -
+
+ {isPreparing && + (bracket.type === "single_elimination" || + bracket.type === "double_elimination") ? ( + { + const newBracketData = teamCountAdjustedBracketData({ + bracket, + tournament, + teamCount: newCount, + }); + + setMaps( + generateTournamentRoundMaplist({ + mapCounts: + bracket.defaultRoundBestOfs(newBracketData), + pool: tournament.ctx.toSetMapPool, + rounds: newBracketData.round, + type: bracket.type, + roundsWithPickBan, + pickBanStyle, + }), + ); + setEliminationTeamCount(newCount); + }} + /> + ) : null} {globalSelections ? ( { const newMapCounts = mapCountsWithGlobalCount(newCount); const newMaps = generateTournamentRoundMaplist({ mapCounts: newMapCounts, pool: tournament.ctx.toSetMapPool, - rounds: bracket.data.round, + rounds, type: bracket.type, roundsWithPickBan, pickBanStyle, }); setMaps(newMaps); - setMapCounts(newMapCounts); }} /> ) : null} {globalSelections ? ( - + ) : null}
{tournament.ctx.toSetMapPool.length > 0 ? ( @@ -221,7 +341,7 @@ export function BracketMapListDialog({ generateTournamentRoundMaplist({ mapCounts, pool: tournament.ctx.toSetMapPool, - rounds: bracket.data.round, + rounds, type: bracket.type, roundsWithPickBan, pickBanStyle, @@ -234,7 +354,7 @@ export function BracketMapListDialog({ ) : null}
- {rounds.map((round) => { + {roundsWithNames.map((round) => { const roundMaps = maps.get(round.id); invariant(roundMaps, "Expected maps to be defined"); @@ -250,7 +370,7 @@ export function BracketMapListDialog({ } onCountChange={(newCount) => { const newMapCounts = new Map(mapCounts); - const bracketRound = bracket.data.round.find( + const bracketRound = rounds.find( (r) => r.id === round.id, ); invariant(bracketRound, "Expected round to be defined"); @@ -270,13 +390,12 @@ export function BracketMapListDialog({ const newMaps = generateTournamentRoundMaplist({ mapCounts: newMapCounts, pool: tournament.ctx.toSetMapPool, - rounds: bracket.data.round, + rounds, type: bracket.type, roundsWithPickBan, pickBanStyle, }); setMaps(newMaps); - setMapCounts(newMapCounts); }} onPickBanChange={ pickBanStyle @@ -290,12 +409,11 @@ export function BracketMapListDialog({ newRoundsWithPickBan.delete(round.id); } - setRoundsWithPickBan(newRoundsWithPickBan); setMaps( generateTournamentRoundMaplist({ mapCounts, pool: tournament.ctx.toSetMapPool, - rounds: bracket.data.round, + rounds, type: bracket.type, roundsWithPickBan: newRoundsWithPickBan, pickBanStyle, @@ -328,10 +446,10 @@ export function BracketMapListDialog({ variant="outlined" size="tiny" testId="confirm-finalize-bracket-button" - _action="START_BRACKET" + _action={isPreparing ? "PREPARE_MAPS" : "START_BRACKET"} className="mx-auto" > - Start the bracket + {isPreparing ? "Save the maps" : "Start the bracket"} )} @@ -341,15 +459,158 @@ export function BracketMapListDialog({ ); } +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, + tournament, + teamCount, +}: { bracket: Bracket; tournament: Tournament; teamCount: number }) { + switch (bracket.type) { + // RR & swiss are different because for those the amount of participants won't affect the amount of rounds + case "round_robin": + case "swiss": + return bracket.data; + case "single_elimination": + return tournament.generateMatchesData( + nullFilledArray(teamCount).map((_, i) => i + 1), + "single_elimination", + ); + case "double_elimination": + return tournament.generateMatchesData( + nullFilledArray(teamCount).map((_, i) => i + 1), + "double_elimination", + ); + } +} + +function EliminationTeamCountSelect({ + count, + realCount, + setCount, +}: { + count: number; + realCount: number; + setCount: (count: number) => void; +}) { + return ( +
+ + +
+ ); +} + function GlobalMapCountInput({ + defaultValue = 3, onSetCount, }: { + defaultValue?: number; onSetCount: (bestOf: number) => void; }) { return (
- onSetCount(Number(e.target.value))} + defaultValue={defaultValue} + > @@ -359,8 +620,10 @@ function GlobalMapCountInput({ } function GlobalCountTypeSelect({ + defaultValue, onSetCountType, }: { + defaultValue: TournamentRoundMaps["type"]; onSetCountType: (type: TournamentRoundMaps["type"]) => void; }) { return ( @@ -371,6 +634,7 @@ function GlobalCountTypeSelect({ onChange={(e) => onSetCountType(e.target.value as TournamentRoundMaps["type"]) } + defaultValue={defaultValue} > @@ -564,7 +828,7 @@ function MapListRow({ return (
  • onHoverMap(serializedMapMode(map))} > diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index 79e1057ee..18926c3d6 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -217,10 +217,6 @@ export abstract class Bracket { } get participantTournamentTeamIds() { - // if (this.seeding) { - // return this.seeding.map((seed) => seed.id); - // } - return removeDuplicates( this.data.match .flatMap((match) => [match.opponent1?.id, match.opponent2?.id]) @@ -339,7 +335,7 @@ export abstract class Bracket { } } - get defaultRoundBestOfs(): BracketMapCounts { + defaultRoundBestOfs(_data: TournamentManagerDataSet): BracketMapCounts { throw new Error("not implemented"); } } @@ -349,14 +345,12 @@ class SingleEliminationBracket extends Bracket { return "single_elimination"; } - get defaultRoundBestOfs(): BracketMapCounts { + defaultRoundBestOfs(data: TournamentManagerDataSet) { const result: BracketMapCounts = new Map(); - const maxRoundNumber = Math.max( - ...this.data.round.map((round) => round.number), - ); - for (const group of this.data.group) { - const roundsOfGroup = this.data.round.filter( + const maxRoundNumber = Math.max(...data.round.map((round) => round.number)); + for (const group of data.group) { + const roundsOfGroup = data.round.filter( (round) => round.group_id === group.id, ); @@ -498,11 +492,11 @@ class DoubleEliminationBracket extends Bracket { return "double_elimination"; } - get defaultRoundBestOfs(): BracketMapCounts { + defaultRoundBestOfs(data: TournamentManagerDataSet) { const result: BracketMapCounts = new Map(); - for (const group of this.data.group) { - const roundsOfGroup = this.data.round.filter( + for (const group of data.group) { + const roundsOfGroup = data.round.filter( (round) => round.group_id === group.id, ); @@ -1031,10 +1025,10 @@ class RoundRobinBracket extends Bracket { return "round_robin"; } - get defaultRoundBestOfs() { + defaultRoundBestOfs(data: TournamentManagerDataSet) { const result: BracketMapCounts = new Map(); - for (const round of this.data.round) { + for (const round of data.round) { if (!result.get(round.group_id)) { result.set(round.group_id, new Map()); } @@ -1404,10 +1398,10 @@ class SwissBracket extends Bracket { return "swiss"; } - get defaultRoundBestOfs() { + defaultRoundBestOfs(data: TournamentManagerDataSet) { const result: BracketMapCounts = new Map(); - for (const round of this.data.round) { + for (const round of data.round) { if (!result.get(round.group_id)) { result.set(round.group_id, new Map()); } diff --git a/app/features/tournament-bracket/core/PreparedMaps.test.ts b/app/features/tournament-bracket/core/PreparedMaps.test.ts new file mode 100644 index 000000000..ba6146167 --- /dev/null +++ b/app/features/tournament-bracket/core/PreparedMaps.test.ts @@ -0,0 +1,665 @@ +import { describe, expect, test } from "bun:test"; +import type { PreparedMaps as PreparedMapsType } from "~/db/tables"; +import * as PreparedMaps from "./PreparedMaps"; +import { testTournament } from "./tests/test-utils"; + +describe("PreparedMaps - resolvePreparedForTheBracket", () => { + const tournament = testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "round_robin", + name: "Round Robin", + sources: [], + }, + { + type: "single_elimination", + name: "Top Cut", + sources: [ + { + bracketIdx: 0, + placements: [1, 2], + }, + ], + }, + { + type: "single_elimination", + name: "Underground Bracket", + sources: [ + { + bracketIdx: 0, + placements: [3, 4], + }, + ], + }, + ], + }, + }, + }); + + test("returns null if no prepared maps at all", () => { + const prepared = PreparedMaps.resolvePreparedForTheBracket({ + tournament, + bracketIdx: 1, + }); + + expect(prepared).toBeNull(); + }); + + test("returns null if no prepared maps for that bracket", () => { + const prepared = PreparedMaps.resolvePreparedForTheBracket({ + tournament, + bracketIdx: 1, + preparedByBracket: [ + { + authorId: 1, + createdAt: 1, + maps: [], + }, + null, + null, + ], + }); + + expect(prepared).toBeNull(); + }); + + test("returns prepared maps for that bracket if exists", () => { + const prepared = PreparedMaps.resolvePreparedForTheBracket({ + tournament, + bracketIdx: 1, + preparedByBracket: [ + null, + { + authorId: 1, + createdAt: 1, + maps: [], + }, + null, + ], + }); + + expect(prepared).not.toBeNull(); + }); + + test("returns 'sibling bracket' prepared maps if exists", () => { + const prepared = PreparedMaps.resolvePreparedForTheBracket({ + tournament, + bracketIdx: 1, + preparedByBracket: [ + null, + null, + { + authorId: 1, + createdAt: 1, + maps: [], + }, + ], + }); + + expect(prepared).not.toBeNull(); + }); +}); + +describe("PreparedMaps - eliminationTeamCountOptions", () => { + test("returns options greater than the count given", () => { + expect( + PreparedMaps.eliminationTeamCountOptions(3).every( + (option) => option.max > 3, + ), + ).toBeTrue(); + }); + + test("returns the option equivalent to the current count", () => { + expect(PreparedMaps.eliminationTeamCountOptions(32)[0].max).toBe(32); + }); +}); + +describe("PreparedMaps - trimPreparedEliminationMaps", () => { + const tournament = testTournament({ + ctx: { + settings: { + thirdPlaceMatch: true, + bracketProgression: [], + }, + }, + }); + + test("returns null if no prepared maps", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: null, + teamCount: 4, + tournament, + type: "single_elimination", + }); + + expect(trimmed).toBeNull(); + }); + + test("returns null if didn't prepare for enough teams", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: FOUR_TEAM_SE_PREPARED, + teamCount: 8, + tournament, + type: "single_elimination", + }); + + expect(trimmed).toBeNull(); + }); + + test("returns null if no elimination team count recorded", () => { + const copy = structuredClone(FOUR_TEAM_SE_PREPARED); + // biome-ignore lint/performance/noDelete: for testing purposes + delete copy.eliminationTeamCount; + + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: copy, + teamCount: 4, + tournament, + type: "single_elimination", + }); + + expect(trimmed).toBeNull(); + }); + + test("returns the maps untouched if no need to trim", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: FOUR_TEAM_SE_PREPARED, + teamCount: 4, + tournament, + type: "single_elimination", + }); + + expect(trimmed).toBe(FOUR_TEAM_SE_PREPARED); + }); + + test("trims the maps (SE - 1 extra round)", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: EIGHT_TEAM_SE_PREPARED, + teamCount: 4, + tournament, + type: "single_elimination", + }); + + expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 1); + }); + + test("trimming happens from the earlier rounds, not the latest", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: EIGHT_TEAM_SE_PREPARED, + teamCount: 4, + tournament, + type: "single_elimination", + }); + + expect(trimmed?.maps[0].list?.[0].stageId).toBe( + EIGHT_TEAM_SE_PREPARED.maps[1].list?.[0].stageId!, + ); + }); + + test("trims the maps (SE - disappearing 3rd place match)", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: EIGHT_TEAM_SE_PREPARED, + teamCount: 3, + tournament, + type: "single_elimination", + }); + + expect(trimmed?.maps.length).toBe(EIGHT_TEAM_SE_PREPARED.maps.length - 2); + + const uniqueGroupIds = new Set(trimmed?.maps.map((map) => map.groupId)); + + expect(uniqueGroupIds.size).toBe(1); + }); + + test("trims the maps (DE - both winners and losers)", () => { + const trimmed = PreparedMaps.trimPreparedEliminationMaps({ + preparedMaps: EIGHT_TEAM_DE_PREPARED, + teamCount: 4, + tournament, + type: "double_elimination", + }); + + const expectedWinnersCount = 2; + const expectedLosersCount = 2; + const expectedFinalsCount = 2; + + expect( + trimmed?.maps.filter((m) => m.groupId === 0).length, + "Winners count is wrong", + ).toBe(expectedWinnersCount); + expect( + trimmed?.maps.filter((m) => m.groupId === 1).length, + "Losers count is wrong", + ).toBe(expectedLosersCount); + expect( + trimmed?.maps.filter((m) => m.groupId === 2).length, + "Finals count is wrong", + ).toBe(expectedFinalsCount); + }); + + const FOUR_TEAM_SE_PREPARED: PreparedMapsType = { + maps: [ + { + roundId: 0, + groupId: 0, + list: [ + { + mode: "TC", + stageId: 10, + }, + { + mode: "RM", + stageId: 4, + }, + { + mode: "SZ", + stageId: 18, + }, + { + mode: "CB", + stageId: 13, + }, + { + mode: "TC", + stageId: 1, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 1, + groupId: 0, + list: [ + { + mode: "CB", + stageId: 16, + }, + { + mode: "TC", + stageId: 21, + }, + { + mode: "SZ", + stageId: 2, + }, + { + mode: "RM", + stageId: 12, + }, + { + mode: "CB", + stageId: 14, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 2, + groupId: 1, + list: [ + { + mode: "TC", + stageId: 3, + }, + { + mode: "RM", + stageId: 0, + }, + { + mode: "SZ", + stageId: 7, + }, + { + mode: "CB", + stageId: 15, + }, + { + mode: "TC", + stageId: 6, + }, + ], + count: 5, + type: "BEST_OF", + }, + ], + authorId: 274, + eliminationTeamCount: 4, + createdAt: 1724481143, + }; + + const EIGHT_TEAM_SE_PREPARED: PreparedMapsType = { + maps: [ + { + roundId: 0, + groupId: 0, + list: [ + { + mode: "CB", + stageId: 0, + }, + { + mode: "TC", + stageId: 21, + }, + { + mode: "SZ", + stageId: 2, + }, + ], + count: 3, + type: "BEST_OF", + }, + { + roundId: 1, + groupId: 0, + list: [ + { + mode: "RM", + stageId: 3, + }, + { + mode: "SZ", + stageId: 18, + }, + { + mode: "CB", + stageId: 13, + }, + { + mode: "TC", + stageId: 1, + }, + { + mode: "RM", + stageId: 4, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 2, + groupId: 0, + list: [ + { + mode: "CB", + stageId: 15, + }, + { + mode: "TC", + stageId: 6, + }, + { + mode: "SZ", + stageId: 10, + }, + { + mode: "RM", + stageId: 12, + }, + { + mode: "CB", + stageId: 16, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 3, + groupId: 1, + list: [ + { + mode: "CB", + stageId: 14, + }, + { + mode: "SZ", + stageId: 7, + }, + { + mode: "TC", + stageId: 19, + }, + { + mode: "RM", + stageId: 2, + }, + { + mode: "CB", + stageId: 8, + }, + ], + count: 5, + type: "BEST_OF", + }, + ], + authorId: 274, + eliminationTeamCount: 8, + createdAt: 1724481176, + }; + + const EIGHT_TEAM_DE_PREPARED: PreparedMapsType = { + maps: [ + { + roundId: 0, + groupId: 0, + list: [ + { + mode: "SZ", + stageId: 18, + }, + { + mode: "CB", + stageId: 0, + }, + { + mode: "TC", + stageId: 10, + }, + ], + count: 3, + type: "BEST_OF", + }, + { + roundId: 3, + groupId: 1, + list: [ + { + mode: "CB", + stageId: 13, + }, + { + mode: "TC", + stageId: 1, + }, + { + mode: "SZ", + stageId: 2, + }, + ], + count: 3, + type: "BEST_OF", + }, + { + roundId: 1, + groupId: 0, + list: [ + { + mode: "RM", + stageId: 4, + }, + { + mode: "SZ", + stageId: 8, + }, + { + mode: "CB", + stageId: 21, + }, + ], + count: 3, + type: "BEST_OF", + }, + { + roundId: 4, + groupId: 1, + list: [ + { + mode: "TC", + stageId: 6, + }, + { + mode: "RM", + stageId: 12, + }, + { + mode: "SZ", + stageId: 7, + }, + ], + count: 3, + type: "BEST_OF", + }, + { + roundId: 2, + groupId: 0, + list: [ + { + mode: "CB", + stageId: 16, + }, + { + mode: "SZ", + stageId: 18, + }, + { + mode: "TC", + stageId: 3, + }, + { + mode: "RM", + stageId: 0, + }, + { + mode: "CB", + stageId: 14, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 5, + groupId: 1, + list: [ + { + mode: "CB", + stageId: 15, + }, + { + mode: "TC", + stageId: 19, + }, + { + mode: "SZ", + stageId: 10, + }, + ], + count: 3, + type: "BEST_OF", + }, + { + roundId: 6, + groupId: 1, + list: [ + { + mode: "RM", + stageId: 2, + }, + { + mode: "TC", + stageId: 1, + }, + { + mode: "SZ", + stageId: 8, + }, + { + mode: "CB", + stageId: 13, + }, + { + mode: "RM", + stageId: 4, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 7, + groupId: 2, + list: [ + { + mode: "RM", + stageId: 6, + }, + { + mode: "SZ", + stageId: 21, + }, + { + mode: "TC", + stageId: 3, + }, + { + mode: "CB", + stageId: 14, + }, + { + mode: "RM", + stageId: 12, + }, + ], + count: 5, + type: "BEST_OF", + }, + { + roundId: 8, + groupId: 2, + list: [ + { + mode: "TC", + stageId: 10, + }, + { + mode: "RM", + stageId: 18, + }, + { + mode: "SZ", + stageId: 7, + }, + { + mode: "CB", + stageId: 0, + }, + { + mode: "TC", + stageId: 19, + }, + ], + count: 5, + type: "BEST_OF", + }, + ], + authorId: 274, + eliminationTeamCount: 8, + createdAt: 1724482944, + }; +}); diff --git a/app/features/tournament-bracket/core/PreparedMaps.ts b/app/features/tournament-bracket/core/PreparedMaps.ts new file mode 100644 index 000000000..7a2e60092 --- /dev/null +++ b/app/features/tournament-bracket/core/PreparedMaps.ts @@ -0,0 +1,150 @@ +import compare from "just-compare"; +import type { PreparedMaps } from "~/db/tables"; +import { nullFilledArray, removeDuplicates } from "~/utils/arrays"; +import type { Tournament } from "./Tournament"; + +/** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same sources */ +export function resolvePreparedForTheBracket({ + preparedByBracket, + bracketIdx, + tournament, +}: { + preparedByBracket?: (PreparedMaps | null)[]; + bracketIdx: number; + tournament: Tournament; +}) { + const bracketMaps = preparedByBracket?.[bracketIdx]; + + // maps exactly for this bracket have been prepared, use them + if (bracketMaps) { + return bracketMaps; + } + + const bracketPreparingFor = tournament.bracketByIdx(bracketIdx)!; + + // lets look for an "equivalent" prepared bracket to use + // e.g. SoS RR -> 4x SE style the SE brackets can share maps + for (const [ + anotherBracketIdx, + bracket, + ] of tournament.ctx.settings.bracketProgression.entries()) { + if ( + bracket.type === bracketPreparingFor.type && + compare( + bracket.sources?.map((s) => s.bracketIdx), + bracketPreparingFor.sources?.map((s) => s.bracketIdx), + ) + ) { + const bracketMaps = preparedByBracket?.[anotherBracketIdx]; + + if (bracketMaps) { + return bracketMaps; + } + } + } + + return null; +} +const ELIMINATION_BRACKET_TEAM_RANGES = [ + { min: 2, max: 2 }, + { min: 3, max: 4 }, + { min: 5, max: 8 }, + { min: 9, max: 16 }, + { min: 17, max: 32 }, + { min: 33, max: 64 }, + { min: 65, max: 128 }, +] as const; + +/** For single elimination and double elimination returns the amount of options that are the "steps" that affect the round count. Takes in currentCount as an argument, filtering out counts below that. */ +export function eliminationTeamCountOptions(currentCount: number) { + return ELIMINATION_BRACKET_TEAM_RANGES.filter( + ({ max }) => max >= currentCount, + ); +} + +/** Validates that given count is a known "max" elimination team count value */ +export function isValidMaxEliminationTeamCount(count: number) { + return ELIMINATION_BRACKET_TEAM_RANGES.some(({ max }) => max === count); +} + +interface TrimPreparedEliminationMapsAgs { + preparedMaps: PreparedMaps | null; + teamCount: number; + tournament: Tournament; + type: "double_elimination" | "single_elimination"; +} + +/** Trim prepared elimination bracket maps to match the actual number. If not prepared or prepared for too few returns null */ +export function trimPreparedEliminationMaps({ + preparedMaps, + teamCount, + ...rest +}: TrimPreparedEliminationMapsAgs) { + if (!preparedMaps) { + // we did not prepare enough maps + return null; + } + + // eliminationTeamCount should exist here, defensive check + if ( + !preparedMaps.eliminationTeamCount || + preparedMaps.eliminationTeamCount < teamCount + ) { + // we did not prepared enough maps + return null; + } + + const isPerfectCountMatch = + preparedMaps.eliminationTeamCount === + eliminationTeamCountOptions(teamCount)[0].max; + + if (isPerfectCountMatch) { + return preparedMaps; + } + + return trimMapsByTeamCount({ preparedMaps, teamCount, ...rest }); +} + +function trimMapsByTeamCount({ + preparedMaps, + teamCount, + tournament, + type, +}: TrimPreparedEliminationMapsAgs & { preparedMaps: PreparedMaps }) { + const actualRounds = tournament.generateMatchesData( + nullFilledArray(teamCount).map((_, i) => i + 1), + type, + ).round; + + const groupIds = removeDuplicates(preparedMaps.maps.map((r) => r.groupId)); + + const result = { ...preparedMaps }; + for (const groupId of groupIds) { + const actualRoundsForGroup = actualRounds.filter( + (r) => r.group_id === groupId, + ); + + const preparedRoundsForGroup = preparedMaps.maps.filter( + (r) => r.groupId === groupId, + ); + + const actualRoundsCount = actualRoundsForGroup.length; + + const trimmedRounds = preparedRoundsForGroup.slice( + preparedRoundsForGroup.length - actualRoundsCount, + ); + + result.maps = result.maps.filter((r) => r.groupId !== groupId); + result.maps.push(...trimmedRounds); + } + + result.maps.sort((a, b) => { + if (a.groupId === b.groupId) { + return a.roundId - b.roundId; + } + + return a.groupId - b.groupId; + }); + + return result; +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index b42682a39..e57451d94 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -164,7 +164,6 @@ export class Tournament { }), ); } else { - const manager = getTournamentManager(); const { teams, relevantMatchesFinished } = sources ? this.resolveTeamsFromSources(sources) : { @@ -184,25 +183,6 @@ export class Tournament { type, }); - if ( - checkedInTeamsWithReplaysAvoided.length >= - TOURNAMENT.ENOUGH_TEAMS_TO_START - ) { - manager.create({ - tournamentId: this.ctx.id, - name, - type, - seeding: - type === "round_robin" - ? checkedInTeamsWithReplaysAvoided - : fillWithNullTillPowerOfTwo(checkedInTeamsWithReplaysAvoided), - settings: this.bracketSettings( - type, - checkedInTeamsWithReplaysAvoided.length, - ), - }); - } - this.brackets.push( Bracket.create({ id: -1 * bracketIdx, @@ -210,7 +190,10 @@ export class Tournament { seeding: checkedInTeamsWithReplaysAvoided, preview: true, name, - data: manager.get.tournamentData(this.ctx.id), + data: this.generateMatchesData( + checkedInTeamsWithReplaysAvoided, + type, + ), type, sources, createdAt: null, @@ -226,6 +209,26 @@ export class Tournament { } } + generateMatchesData(teams: number[], type: TournamentStage["type"]) { + const manager = getTournamentManager(); + + // we need some number but does not matter what it is as the manager only contains one tournament + const virtualTournamentId = 1; + + if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { + manager.create({ + tournamentId: virtualTournamentId, + name: "Virtual", + type, + seeding: + type === "round_robin" ? teams : fillWithNullTillPowerOfTwo(teams), + settings: this.bracketSettings(type, teams.length), + }); + } + + return manager.get.tournamentData(virtualTournamentId); + } + private resolveTeamsFromSources( sources: NonNullable, ) { @@ -808,10 +811,13 @@ export class Tournament { ) { const rounds = bracket.type === "single_elimination" - ? getRounds({ type: "single", bracket }) + ? getRounds({ type: "single", bracketData: bracket.data }) : [ - ...getRounds({ type: "winners", bracket }), - ...getRounds({ type: "losers", bracket }), + ...getRounds({ + type: "winners", + bracketData: bracket.data, + }), + ...getRounds({ type: "losers", bracketData: bracket.data }), ]; const round = rounds.find((round) => round.id === match.round_id); diff --git a/app/features/tournament-bracket/core/rounds.ts b/app/features/tournament-bracket/core/rounds.ts index 8112c6866..aeccaf305 100644 --- a/app/features/tournament-bracket/core/rounds.ts +++ b/app/features/tournament-bracket/core/rounds.ts @@ -1,19 +1,19 @@ +import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import { removeDuplicates } from "~/utils/arrays"; -import type { Bracket as BracketType } from "./Bracket"; export function getRounds(args: { - bracket: BracketType; + bracketData: TournamentManagerDataSet; type: "winners" | "losers" | "single"; }) { - const groupIds = args.bracket.data.group.flatMap((group) => { + const groupIds = args.bracketData.group.flatMap((group) => { if (args.type === "winners" && group.number === 2) return []; if (args.type === "losers" && group.number !== 2) return []; return group.id; }); - let showingBracketReset = args.bracket.data.round.length > 1; - const rounds = args.bracket.data.round + let showingBracketReset = args.bracketData.round.length > 1; + const rounds = args.bracketData.round .flatMap((round) => { if ( typeof round.group_id === "number" && @@ -28,7 +28,7 @@ export function getRounds(args: { const isBracketReset = args.type === "winners" && i === rounds.length - 1; const grandFinalsMatch = args.type === "winners" - ? args.bracket.data.match.find( + ? args.bracketData.match.find( (match) => match.round_id === rounds[rounds.length - 2]?.id, ) : undefined; @@ -38,7 +38,7 @@ export function getRounds(args: { return false; } - const matches = args.bracket.data.match.filter( + const matches = args.bracketData.match.filter( (match) => match.round_id === round.id, ); @@ -51,7 +51,7 @@ export function getRounds(args: { const hasThirdPlaceMatch = args.type === "single" && - removeDuplicates(args.bracket.data.match.map((m) => m.group_id)).length > 1; + removeDuplicates(args.bracketData.match.map((m) => m.group_id)).length > 1; return rounds.map((round, i) => { const name = () => { if ( diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 0fcd74f8c..1170880d5 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -35,10 +35,18 @@ const nTeams = (n: number, startingId: number) => { return teams; }; -export const testTournament = ( - data: TournamentManagerDataSet, - partialCtx?: Partial, -) => { +export const testTournament = ({ + data = { + match: [], + group: [], + round: [], + stage: [], + }, + ctx, +}: { + data?: TournamentManagerDataSet; + ctx?: Partial; +}) => { const participant = removeDuplicates( data.match .flatMap((m) => [m.opponent1?.id, m.opponent2?.id]) @@ -82,7 +90,7 @@ export const testTournament = ( username: "test", id: 1, }, - ...partialCtx, + ...ctx, }, }); }; diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index a7ab8018f..309c1c249 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -28,6 +28,10 @@ interface GenerateTournamentRoundMaplistArgs { pickBanStyle: TournamentRoundMaps["pickBan"]; } +export type TournamentRoundMapList = ReturnType< + typeof generateTournamentRoundMaplist +>; + // TODO: future improvement could be slightly biasing against maps that appear in slots that are not guaranteed to be played export function generateTournamentRoundMaplist( args: GenerateTournamentRoundMaplistArgs, @@ -161,14 +165,16 @@ function modeOrder( return aFreq - bFreq; }); + const biasedModes = modesWithSZBiased(shuffledModes); + const result: ModeShort[] = []; let currentI = 0; while (result.length < count) { - result.push(shuffledModes[currentI]); - modeFrequency.set(shuffledModes[currentI], iteration); + result.push(biasedModes[currentI]); + modeFrequency.set(biasedModes[currentI], iteration); currentI++; - if (currentI >= shuffledModes.length) { + if (currentI >= biasedModes.length) { currentI = 0; } } @@ -176,6 +182,29 @@ function modeOrder( return result; } +function modesWithSZBiased(modes: ModeShort[]) { + // map list without SZ + if (!modes.includes("SZ")) { + return modes; + } + + // not relevant if there are less than 4 modes + if (modes.length < 4) { + return modes; + } + + if (modes[0] === "SZ" || modes[1] === "SZ" || modes[2] === "SZ") { + return modes; + } + + const result: ModeShort[] = modes.filter((mode) => mode !== "SZ"); + + const szIndex = Math.floor(Math.random() * 3); + result.splice(szIndex, 0, "SZ"); + + return result; +} + const serializedMap = (mode: ModeShort, stage: StageId) => `${mode}-${stage}`; function resolveStage( diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index b1b908cd6..745079120 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -14,8 +14,10 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { Menu } from "~/components/Menu"; import { Placement } from "~/components/Placement"; import { Popover } from "~/components/Popover"; +import { CheckmarkIcon } from "~/components/icons/Checkmark"; import { EyeIcon } from "~/components/icons/Eye"; import { EyeSlashIcon } from "~/components/icons/EyeSlash"; +import { MapIcon } from "~/components/icons/Map"; import { sql } from "~/db/sql"; import { useUser } from "~/features/auth/core/user"; import { requireUser } from "~/features/auth/core/user.server"; @@ -49,11 +51,13 @@ import { import { useBracketExpanded, useTournament, + useTournamentPreparedMaps, } from "../../tournament/routes/to.$id"; import { Bracket } from "../components/Bracket"; import { BracketMapListDialog } from "../components/BracketMapListDialog"; import { TournamentTeamActions } from "../components/TournamentTeamActions"; import type { Bracket as BracketType, Standing } from "../core/Bracket"; +import * as PreparedMaps from "../core/PreparedMaps"; import * as Swiss from "../core/Swiss"; import type { Tournament } from "../core/Tournament"; import { @@ -169,6 +173,33 @@ export const action: ActionFunction = async ({ params, request }) => { break; } + case "PREPARE_MAPS": { + validate(tournament.isOrganizer(user)); + + const bracket = tournament.bracketByIdx(data.bracketIdx); + invariant(bracket, "Bracket not found"); + + validate( + !bracket.canBeStarted, + "Bracket can already be started, preparing maps no longer possible", + ); + validate( + bracket.preview, + "Bracket has started, preparing maps no longer possible", + ); + + await TournamentRepository.upsertPreparedMaps({ + bracketIdx: data.bracketIdx, + tournamentId, + maps: { + maps: data.maps, + authorId: user.id, + eliminationTeamCount: data.eliminationTeamCount ?? undefined, + }, + }); + + break; + } case "ADVANCE_BRACKET": { const bracket = tournament.bracketByIdx(data.bracketIdx); validate(bracket, "Bracket not found"); @@ -313,6 +344,9 @@ export default function TournamentBracketsPage() { tournament.hasStarted && tournament.autonomousSubs; + const showPrepareMapsButton = + tournament.isOrganizer(user) && !bracket.canBeStarted && bracket.preview; + const waitingForTeamsText = () => { if (bracketIdx > 0 || tournament.regularCheckInStartInThePast) { return t("tournament:bracket.waiting.checkin", { @@ -385,34 +419,33 @@ export default function TournamentBracketsPage() { ) : null} {bracket.preview && bracket.enoughTeams && - tournament.isOrganizer(user) ? ( + tournament.isOrganizer(user) && + tournament.regularCheckInStartInThePast ? (
    - {tournament.regularCheckInStartInThePast ? ( -
    - - {bracket.participantTournamentTeamIds.length}/ - {totalTeamsAvailableForTheBracket()} teams checked in - {bracket.canBeStarted ? ( - - ) : null} - - {!bracket.canBeStarted ? ( -
    - ⚠️{" "} - {bracketIdx === 0 ? ( - <>Tournament start time is in the future - ) : ( - <>Teams pending from the previous bracket - )}{" "} - (blocks starting) -
    +
    + + {bracket.participantTournamentTeamIds.length}/ + {totalTeamsAvailableForTheBracket()} teams checked in + {bracket.canBeStarted ? ( + ) : null} -
    - ) : null} + + {!bracket.canBeStarted ? ( +
    + ⚠️{" "} + {bracketIdx === 0 ? ( + <>Tournament start time is in the future + ) : ( + <>Teams pending from the previous bracket + )}{" "} + (blocks starting) +
    + ) : null} +
    ) : null}
    @@ -431,6 +464,9 @@ export default function TournamentBracketsPage() { {bracket.type !== "round_robin" && !bracket.preview ? ( ) : null} + {showPrepareMapsButton ? ( + + ) : null}
    {bracket.enoughTeams ? ( @@ -492,12 +528,16 @@ function BracketStarter({ const [dialogOpen, setDialogOpen] = React.useState(false); const isMounted = useIsMounted(); + const close = React.useCallback(() => { + setDialogOpen(false); + }, []); + return ( <> {isMounted ? ( setDialogOpen(false)} + close={close} bracket={bracket} bracketIdx={bracketIdx} /> @@ -514,6 +554,62 @@ function BracketStarter({ ); } +function MapPreparer({ + bracket, + bracketIdx, +}: { + bracket: BracketType; + bracketIdx: number; +}) { + const [dialogOpen, setDialogOpen] = React.useState(false); + const isMounted = useIsMounted(); + const prepared = useTournamentPreparedMaps(); + const tournament = useTournament(); + + const hasPreparedMaps = Boolean( + PreparedMaps.resolvePreparedForTheBracket({ + bracketIdx, + preparedByBracket: prepared, + tournament, + }), + ); + + const close = React.useCallback(() => { + setDialogOpen(false); + }, []); + + return ( + <> + {isMounted ? ( + + ) : null} +
    + {hasPreparedMaps ? ( + + ) : null} + +
    + + ); +} + function AddSubsPopOver() { const { t } = useTranslation(["common", "tournament"]); const [, copyToClipboard] = useCopyToClipboard(); 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 01f99be57..e5231139f 100644 --- a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -710,10 +710,13 @@ function MatchHeader() { ) { const rounds = bracket.type === "single_elimination" - ? getRounds({ type: "single", bracket }) + ? getRounds({ type: "single", bracketData: bracket.data }) : [ - ...getRounds({ type: "winners", bracket }), - ...getRounds({ type: "losers", bracket }), + ...getRounds({ + type: "winners", + bracketData: bracket.data, + }), + ...getRounds({ type: "losers", bracketData: bracket.data }), ]; const round = rounds.find((round) => round.id === match.round_id); diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts index 903cf9a4c..bbda2bf07 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -9,6 +9,7 @@ import { stageId, } from "~/utils/zod"; import { TOURNAMENT } from "../tournament/tournament-constants"; +import * as PreparedMaps from "./core/PreparedMaps"; const activeRosterPlayerIds = z.preprocess(safeJSONParse, z.array(id)); @@ -95,6 +96,7 @@ export const bracketIdx = z.coerce.number().int().min(0).max(100); const tournamentRoundMaps = z.object({ roundId: z.number().int().min(0), + groupId: z.number().int().min(0), list: z .array( z.object({ @@ -114,6 +116,17 @@ export const bracketSchema = z.union([ bracketIdx, maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)), }), + z.object({ + _action: _action("PREPARE_MAPS"), + bracketIdx, + maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)), + eliminationTeamCount: z.coerce + .number() + .optional() + .refine( + (val) => !val || PreparedMaps.isValidMaxEliminationTeamCount(val), + ), + }), z.object({ _action: _action("ADVANCE_BRACKET"), groupId: id, diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 1d36de6eb..936d815d5 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -3,10 +3,12 @@ import { type Insertable, type NotNull, type Transaction, sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { nanoid } from "nanoid"; import { db } from "~/db/sql"; -import type { CastedMatchesInfo, DB, Tables } from "~/db/tables"; +import type { CastedMatchesInfo, DB, PreparedMaps, Tables } from "~/db/tables"; import { Status } from "~/modules/brackets-model"; import { modesShort } from "~/modules/in-game-lists"; +import { nullFilledArray } from "~/utils/arrays"; import { + databaseTimestampNow, databaseTimestampToDate, dateToDatabaseTimestamp, } from "~/utils/dates"; @@ -53,9 +55,15 @@ export async function findById(id: number) { jsonArrayFrom( innerEb .selectFrom("TournamentOrganizationMember") + .innerJoin( + "User", + "TournamentOrganizationMember.userId", + "User.id", + ) .select([ "TournamentOrganizationMember.userId", "TournamentOrganizationMember.role", + "User.username", ]) .whereRef( "TournamentOrganizationMember.organizationId", @@ -282,6 +290,18 @@ export async function findTOSetMapPoolById(tournamentId: number) { }); } +export async function findPreparedMapsById(tournamentId: number) { + return ( + ( + await db + .selectFrom("Tournament") + .select("preparedMaps") + .where("id", "=", tournamentId) + .executeTakeFirst() + )?.preparedMaps ?? undefined + ); +} + const NEXT_TOURNAMENTS_TO_SHOW_WITH_UPCOMING = 2; export async function forShowcase() { const rows = await db @@ -546,6 +566,38 @@ export function removeStaff({ .execute(); } +interface UpsertPreparedMapsArgs { + tournamentId: number; + maps: Omit; + bracketIdx: number; +} + +export function upsertPreparedMaps({ + bracketIdx, + maps, + tournamentId, +}: UpsertPreparedMapsArgs) { + return db.transaction().execute(async (trx) => { + const tournament = await trx + .selectFrom("Tournament") + .select(["Tournament.preparedMaps", "Tournament.settings"]) + .where("Tournament.id", "=", tournamentId) + .executeTakeFirstOrThrow(); + + const preparedMaps: Array = + tournament.preparedMaps ?? + nullFilledArray(tournament.settings.bracketProgression.length); + + preparedMaps[bracketIdx] = { ...maps, createdAt: databaseTimestampNow() }; + + await trx + .updateTable("Tournament") + .set({ preparedMaps: JSON.stringify(preparedMaps) }) + .where("Tournament.id", "=", tournamentId) + .execute(); + }); +} + export function updateCastTwitchAccounts({ tournamentId, castTwitchAccounts, diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index b1153a45b..8e23b6b2a 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -144,7 +144,18 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { tournament.ctx.staff.some( (s) => s.role === "ORGANIZER" && s.id === user?.id, ) || - isAdmin(user); + isAdmin(user) || + tournament.ctx.organization?.members.some( + (m) => m.userId === user?.id && m.role === "ADMIN", + ); + const isTournamentOrganizer = + isTournamentAdmin || + tournament.ctx.staff.some( + (s) => s.role === "ORGANIZER" && s.id === user?.id, + ) || + tournament.ctx.organization?.members.some( + (m) => m.userId === user?.id && m.role === "ORGANIZER", + ); const showFriendCodes = tournamentStartedInTheLastMonth && isTournamentAdmin; return { @@ -154,6 +165,10 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { friendCodes: showFriendCodes ? await TournamentRepository.friendCodesByTournamentId(tournamentId) : undefined, + preparedMaps: + isTournamentOrganizer && !tournament.ctx.isFinalized + ? await TournamentRepository.findPreparedMapsById(tournamentId) + : undefined, }; }; @@ -244,6 +259,7 @@ export default function TournamentLayout() { setBracketExpanded, streamingParticipants: data.streamingParticipants, friendCodes: data.friendCodes, + preparedMaps: data.preparedMaps, } satisfies TournamentContext } /> @@ -259,6 +275,7 @@ type TournamentContext = { setBracketExpanded: (expanded: boolean) => void; friendCode?: string; friendCodes?: SerializeFrom["friendCodes"]; + preparedMaps: SerializeFrom["preparedMaps"]; }; export function useTournament() { @@ -279,3 +296,7 @@ export function useStreamingParticipants() { export function useTournamentFriendCodes() { return useOutletContext().friendCodes; } + +export function useTournamentPreparedMaps() { + return useOutletContext().preparedMaps; +} diff --git a/app/styles/utils.css b/app/styles/utils.css index 7afe61ffd..496f34aae 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -146,6 +146,10 @@ width: var(--s-4); } +.w-6 { + width: var(--s-6); +} + .w-24 { width: var(--s-24); } diff --git a/bun.lockb b/bun.lockb index aff7f9e5a..84c62dddc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/e2e/tournament-bracket.spec.ts b/e2e/tournament-bracket.spec.ts index f3aaffd68..7f933111f 100644 --- a/e2e/tournament-bracket.spec.ts +++ b/e2e/tournament-bracket.spec.ts @@ -813,6 +813,36 @@ test.describe("Tournament bracket", () => { await expect(page.getByText("BYE")).toBeVisible(); }); + test("prepares maps", async ({ page }) => { + const tournamentId = 4; + + await seed(page); + await impersonate(page); + + await navigate({ + page, + url: tournamentBracketsPage({ tournamentId }), + }); + + await page.getByRole("button", { name: "Great White" }).click(); + + await page.getByTestId("prepare-maps-button").click(); + + await page.getByTestId("confirm-finalize-bracket-button").click(); + + await expect(page.getByTestId("prepared-maps-check-icon")).toBeVisible(); + + // we did not prepare maps for group stage + await page.getByRole("button", { name: "Groups stage" }).click(); + + await isNotVisible(page.getByTestId("prepared-maps-check-icon")); + + // should reuse prepared maps from Great White + await page.getByRole("button", { name: "Hammerhead" }).click(); + + await expect(page.getByTestId("prepared-maps-check-icon")).toBeVisible(); + }); + for (const pickBan of ["COUNTERPICK", "BAN_2"]) { for (const mapPickingStyle of ["AUTO_SZ", "TO"]) { test(`ban/pick ${pickBan} (${mapPickingStyle})`, async ({ page }) => { diff --git a/migrations/066-prepared-maps.js b/migrations/066-prepared-maps.js new file mode 100644 index 000000000..2718c23f5 --- /dev/null +++ b/migrations/066-prepared-maps.js @@ -0,0 +1,10 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "Tournament" add "preparedMaps" text`, + ).run(); + db.prepare( + /* sql */ `alter table "Tournament" drop column "showMapListGenerator"`, + ).run(); + })(); +} diff --git a/package.json b/package.json index ddbb4d481..0d8b50e0b 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "just-camel-case": "^6.2.0", "just-capitalize": "^3.2.0", "just-clone": "^6.2.0", + "just-compare": "^2.3.0", "just-random-integer": "^4.2.0", "just-shuffle": "^4.2.0", "kysely": "^0.27.4", @@ -73,7 +74,7 @@ "react-hook-form": "^7.52.2", "react-i18next": "^15.0.1", "react-popper": "^2.3.0", - "react-responsive-masonry": "^2.3.0", + "react-responsive-masonry": "2.2.0", "react-use": "^17.5.1", "react-use-draggable-scroll": "^0.4.7", "reconnecting-websocket": "^4.4.0",