mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Prepare maps for brackets ahead of time (#1845)
* Prepared maps works for RR * Refactor generateMatchesData * Refactor rounds * Initial prepare for SE/DE * Share maps between brackets * Todos * resetPreparedMaps * Bias SZ & reset prepared maps * Trimming initial + tests initial * Elimination trimming basic case * Include group id * Implemented all tests * TODOs * Better prepared maps submitted UX * Small CSS tweaks * Done * Remove TODO
This commit is contained in:
parent
fcfc6cf7d1
commit
19435dd75f
|
|
@ -456,8 +456,12 @@ export interface Tournament {
|
|||
settings: ColumnType<TournamentSettings, string, string>;
|
||||
id: GeneratedAlways<number>;
|
||||
mapPickingStyle: TournamentMapPickingStyle;
|
||||
// TODO: remove in migration
|
||||
// showMapListGenerator: Generated<number | null>;
|
||||
/** 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<string[] | null, string | null, string | null>;
|
||||
castedMatchesInfo: ColumnType<
|
||||
CastedMatchesInfo | null,
|
||||
|
|
@ -467,6 +471,13 @@ export interface Tournament {
|
|||
rules: string | null;
|
||||
}
|
||||
|
||||
export interface PreparedMaps {
|
||||
authorId: number;
|
||||
createdAt: number;
|
||||
maps: Array<TournamentRoundMaps & { roundId: number; groupId: number }>;
|
||||
eliminationTeamCount?: number;
|
||||
}
|
||||
|
||||
export interface TournamentBadgeOwner {
|
||||
badgeId: number;
|
||||
userId: number;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [pickBanStyle, setPickBanStyle] =
|
||||
React.useState<TournamentRoundMaps["pickBan"]>();
|
||||
const [countType, setCountType] =
|
||||
React.useState<TournamentRoundMaps["type"]>("BEST_OF");
|
||||
const [maps, setMaps] = React.useState(() =>
|
||||
generateTournamentRoundMaplist({
|
||||
mapCounts: bracket.defaultRoundBestOfs,
|
||||
roundsWithPickBan,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds: bracket.data.round,
|
||||
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,
|
||||
pickBanStyle,
|
||||
}),
|
||||
})
|
||||
: 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 [mapCounts, setMapCounts] = React.useState(
|
||||
() => bracket.defaultRoundBestOfs,
|
||||
});
|
||||
|
||||
const bracketData = isPreparing
|
||||
? teamCountAdjustedBracketData({
|
||||
bracket,
|
||||
tournament,
|
||||
teamCount: eliminationTeamCount,
|
||||
})
|
||||
: bracket.data;
|
||||
const rounds = bracketData.round;
|
||||
const defaultRoundBestOfs = bracket.defaultRoundBestOfs(bracketData);
|
||||
|
||||
const [countType, setCountType] = React.useState<TournamentRoundMaps["type"]>(
|
||||
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,
|
||||
});
|
||||
});
|
||||
const [pickBanStyle, setPickBanStyle] = React.useState(
|
||||
Array.from(maps.values()).find((round) => round.pickBan)?.pickBan,
|
||||
);
|
||||
const [hoveredMap, setHoveredMap] = React.useState<string | null>(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<number> => {
|
||||
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 (
|
||||
<Dialog isOpen={isOpen} close={close} className="w-max">
|
||||
<Dialog isOpen={isOpen} close={close} className="w-full">
|
||||
<fetcher.Form method="post" className="map-list-dialog__container">
|
||||
<input type="hidden" name="bracketIdx" value={bracketIdx} />
|
||||
<input
|
||||
|
|
@ -149,13 +212,35 @@ export function BracketMapListDialog({
|
|||
name="maps"
|
||||
value={JSON.stringify(
|
||||
Array.from(maps.entries()).map(([key, value]) => ({
|
||||
roundId: key,
|
||||
type: countType,
|
||||
...value,
|
||||
roundId: key,
|
||||
groupId: rounds.find((r) => r.id === key)?.group_id,
|
||||
type: countType,
|
||||
})),
|
||||
)}
|
||||
/>
|
||||
{isPreparing &&
|
||||
(bracket.type === "single_elimination" ||
|
||||
bracket.type === "double_elimination") ? (
|
||||
<input
|
||||
type="hidden"
|
||||
name="eliminationTeamCount"
|
||||
value={eliminationTeamCount}
|
||||
/>
|
||||
) : null}
|
||||
<div>
|
||||
<h2 className="text-lg text-center">{bracket.name}</h2>
|
||||
{preparedMaps ? (
|
||||
<div
|
||||
className="text-xs text-center text-lighter"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
Prepared by{" "}
|
||||
{authorIdToUsername(tournament, preparedMaps.authorId)} @{" "}
|
||||
{databaseTimestampToDate(preparedMaps.createdAt).toLocaleString()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{lacksToSetMapPool ? (
|
||||
<div>
|
||||
You need to select map pool in the{" "}
|
||||
|
|
@ -182,7 +267,7 @@ export function BracketMapListDialog({
|
|||
generateTournamentRoundMaplist({
|
||||
mapCounts,
|
||||
pool: tournament.ctx.toSetMapPool,
|
||||
rounds: bracket.data.round,
|
||||
rounds,
|
||||
type: bracket.type,
|
||||
roundsWithPickBan: newRoundsWithPickBan,
|
||||
pickBanStyle,
|
||||
|
|
@ -190,25 +275,60 @@ export function BracketMapListDialog({
|
|||
);
|
||||
}}
|
||||
/>
|
||||
{isPreparing &&
|
||||
(bracket.type === "single_elimination" ||
|
||||
bracket.type === "double_elimination") ? (
|
||||
<EliminationTeamCountSelect
|
||||
count={eliminationTeamCount}
|
||||
realCount={bracketTeamsCount}
|
||||
setCount={(newCount) => {
|
||||
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 ? (
|
||||
<GlobalMapCountInput
|
||||
defaultValue={
|
||||
// beautiful 🥹
|
||||
mapCounts.values().next().value.values().next().value
|
||||
.count
|
||||
}
|
||||
onSetCount={(newCount) => {
|
||||
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 ? (
|
||||
<GlobalCountTypeSelect onSetCountType={setCountType} />
|
||||
<GlobalCountTypeSelect
|
||||
defaultValue={countType}
|
||||
onSetCountType={setCountType}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{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}
|
||||
</div>
|
||||
<div className="stack horizontal md flex-wrap justify-center">
|
||||
{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"}
|
||||
</SubmitButton>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -341,15 +459,158 @@ export function BracketMapListDialog({
|
|||
);
|
||||
}
|
||||
|
||||
function useCloseModalOnSubmit(
|
||||
fetcher: FetcherWithComponents<unknown>,
|
||||
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 (
|
||||
<div>
|
||||
<Label htmlFor="elimination-team-count">Expected teams</Label>
|
||||
<select
|
||||
id="elimination-team-count"
|
||||
onChange={(e) => setCount(Number(e.target.value))}
|
||||
defaultValue={count}
|
||||
>
|
||||
{PreparedMaps.eliminationTeamCountOptions(realCount).map(
|
||||
(teamCountRange) => {
|
||||
const label =
|
||||
teamCountRange.min === teamCountRange.max
|
||||
? teamCountRange.min
|
||||
: `${teamCountRange.min}-${teamCountRange.max}`;
|
||||
|
||||
return (
|
||||
<option key={teamCountRange.max} value={teamCountRange.max}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalMapCountInput({
|
||||
defaultValue = 3,
|
||||
onSetCount,
|
||||
}: {
|
||||
defaultValue?: number;
|
||||
onSetCount: (bestOf: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="count">Count</Label>
|
||||
<select id="count" onChange={(e) => onSetCount(Number(e.target.value))}>
|
||||
<select
|
||||
id="count"
|
||||
onChange={(e) => onSetCount(Number(e.target.value))}
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
<option value="3">3</option>
|
||||
<option value="5">5</option>
|
||||
<option value="7">7</option>
|
||||
|
|
@ -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}
|
||||
>
|
||||
<option value="BEST_OF">Best of</option>
|
||||
<option value="PLAY_ALL">Play all</option>
|
||||
|
|
@ -564,7 +828,7 @@ function MapListRow({
|
|||
return (
|
||||
<li
|
||||
className={clsx("map-list-dialog__map-list-row", {
|
||||
"text-theme-secondary font-bold": serializedMapMode(map) === hoveredMap,
|
||||
"text-theme-secondary underline": serializedMapMode(map) === hoveredMap,
|
||||
})}
|
||||
onMouseEnter={() => onHoverMap(serializedMapMode(map))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
665
app/features/tournament-bracket/core/PreparedMaps.test.ts
Normal file
665
app/features/tournament-bracket/core/PreparedMaps.test.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
150
app/features/tournament-bracket/core/PreparedMaps.ts
Normal file
150
app/features/tournament-bracket/core/PreparedMaps.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<TournamentBracketProgression[number]["sources"]>,
|
||||
) {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -35,10 +35,18 @@ const nTeams = (n: number, startingId: number) => {
|
|||
return teams;
|
||||
};
|
||||
|
||||
export const testTournament = (
|
||||
data: TournamentManagerDataSet,
|
||||
partialCtx?: Partial<TournamentData["ctx"]>,
|
||||
) => {
|
||||
export const testTournament = ({
|
||||
data = {
|
||||
match: [],
|
||||
group: [],
|
||||
round: [],
|
||||
stage: [],
|
||||
},
|
||||
ctx,
|
||||
}: {
|
||||
data?: TournamentManagerDataSet;
|
||||
ctx?: Partial<TournamentData["ctx"]>;
|
||||
}) => {
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,9 +419,9 @@ export default function TournamentBracketsPage() {
|
|||
) : null}
|
||||
{bracket.preview &&
|
||||
bracket.enoughTeams &&
|
||||
tournament.isOrganizer(user) ? (
|
||||
tournament.isOrganizer(user) &&
|
||||
tournament.regularCheckInStartInThePast ? (
|
||||
<div className="stack items-center mb-4">
|
||||
{tournament.regularCheckInStartInThePast ? (
|
||||
<div className="stack sm items-center">
|
||||
<Alert
|
||||
variation="INFO"
|
||||
|
|
@ -412,7 +446,6 @@ export default function TournamentBracketsPage() {
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="stack horizontal mb-4 sm justify-between items-center">
|
||||
|
|
@ -431,6 +464,9 @@ export default function TournamentBracketsPage() {
|
|||
{bracket.type !== "round_robin" && !bracket.preview ? (
|
||||
<CompactifyButton />
|
||||
) : null}
|
||||
{showPrepareMapsButton ? (
|
||||
<MapPreparer bracket={bracket} bracketIdx={bracketIdx} />
|
||||
) : null}
|
||||
</div>
|
||||
{bracket.enoughTeams ? (
|
||||
<Bracket bracket={bracket} bracketIdx={bracketIdx} />
|
||||
|
|
@ -492,12 +528,16 @@ function BracketStarter({
|
|||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMounted ? (
|
||||
<BracketMapListDialog
|
||||
isOpen={dialogOpen}
|
||||
close={() => 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 ? (
|
||||
<BracketMapListDialog
|
||||
isOpen={dialogOpen}
|
||||
close={close}
|
||||
bracket={bracket}
|
||||
bracketIdx={bracketIdx}
|
||||
isPreparing
|
||||
/>
|
||||
) : null}
|
||||
<div className="stack sm horizontal ml-auto">
|
||||
{hasPreparedMaps ? (
|
||||
<CheckmarkIcon
|
||||
className="fill-success w-6"
|
||||
testId="prepared-maps-check-icon"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
size="tiny"
|
||||
variant="outlined"
|
||||
icon={<MapIcon />}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
testId="prepare-maps-button"
|
||||
>
|
||||
Prepare maps
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSubsPopOver() {
|
||||
const { t } = useTranslation(["common", "tournament"]);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PreparedMaps, "createdAt">;
|
||||
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<PreparedMaps | null> =
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<typeof loader>["friendCodes"];
|
||||
preparedMaps: SerializeFrom<typeof loader>["preparedMaps"];
|
||||
};
|
||||
|
||||
export function useTournament() {
|
||||
|
|
@ -279,3 +296,7 @@ export function useStreamingParticipants() {
|
|||
export function useTournamentFriendCodes() {
|
||||
return useOutletContext<TournamentContext>().friendCodes;
|
||||
}
|
||||
|
||||
export function useTournamentPreparedMaps() {
|
||||
return useOutletContext<TournamentContext>().preparedMaps;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,6 +146,10 @@
|
|||
width: var(--s-4);
|
||||
}
|
||||
|
||||
.w-6 {
|
||||
width: var(--s-6);
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: var(--s-24);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
10
migrations/066-prepared-maps.js
Normal file
10
migrations/066-prepared-maps.js
Normal file
|
|
@ -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();
|
||||
})();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user