mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
parent
aea1e9ce35
commit
dd4f68158d
19
app/components/icons/Unlink.tsx
Normal file
19
app/components/icons/Unlink.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
export function UnlinkIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<title>Unlink Icon</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.181 8.68a4.503 4.503 0 0 1 1.903 6.405m-9.768-2.782L3.56 14.06a4.5 4.5 0 0 0 6.364 6.365l3.129-3.129m5.614-5.615 1.757-1.757a4.5 4.5 0 0 0-6.364-6.365l-4.5 4.5c-.258.26-.479.541-.661.84m1.903 6.405a4.495 4.495 0 0 1-1.242-.88 4.483 4.483 0 0 1-1.062-1.683m6.587 2.345 5.907 5.907m-5.907-5.907L8.898 8.898M2.991 2.99 8.898 8.9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { sql } from "~/db/sql";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import {
|
||||
queryCurrentTeamRating,
|
||||
queryCurrentUserRating,
|
||||
queryCurrentUserSeedingRating,
|
||||
queryTeamPlayerRatingAverage,
|
||||
} from "~/features/mmr/mmr-utils.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { tournamentIdFromParams } from "~/features/tournament";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
|
||||
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { PreparedMaps } from "../../../db/tables";
|
||||
import { updateTeamSeeds } from "../../tournament/queries/updateTeamSeeds.server";
|
||||
import * as Swiss from "../core/Swiss";
|
||||
import type { Tournament } from "../core/Tournament";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "../core/Tournament.server";
|
||||
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
||||
import { roundMapsFromInput } from "../core/mapList.server";
|
||||
import { tournamentSummary } from "../core/summarizer.server";
|
||||
import { addSummary } from "../queries/addSummary.server";
|
||||
import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server";
|
||||
import { bracketSchema } from "../tournament-bracket-schemas.server";
|
||||
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ params, request }) => {
|
||||
const user = await requireUser(request);
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const data = await parseRequestPayload({ request, schema: bracketSchema });
|
||||
const manager = getServerTournamentManager();
|
||||
|
||||
switch (data._action) {
|
||||
case "START_BRACKET": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
||||
const seeding = bracket.seeding;
|
||||
invariant(seeding, "Seeding not found");
|
||||
|
||||
errorToastIfFalsy(
|
||||
bracket.canBeStarted,
|
||||
"Bracket is not ready to be started",
|
||||
);
|
||||
|
||||
const groupCount = new Set(bracket.data.round.map((r) => r.group_id))
|
||||
.size;
|
||||
|
||||
const settings = tournament.bracketManagerSettings(
|
||||
bracket.settings,
|
||||
bracket.type,
|
||||
seeding.length,
|
||||
);
|
||||
|
||||
const maps = settings.consolationFinal
|
||||
? adjustLinkedRounds({
|
||||
maps: data.maps,
|
||||
thirdPlaceMatchLinked: data.thirdPlaceMatchLinked,
|
||||
})
|
||||
: data.maps;
|
||||
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "round_robin" || bracket.type === "swiss"
|
||||
? bracket.data.round.length / groupCount === maps.length
|
||||
: bracket.data.round.length === maps.length,
|
||||
"Invalid map count",
|
||||
);
|
||||
|
||||
sql.transaction(() => {
|
||||
const stage =
|
||||
bracket.type === "swiss"
|
||||
? createSwissBracketInTransaction(
|
||||
Swiss.create({
|
||||
name: bracket.name,
|
||||
seeding,
|
||||
tournamentId,
|
||||
settings,
|
||||
}),
|
||||
)
|
||||
: manager.create({
|
||||
tournamentId,
|
||||
name: bracket.name,
|
||||
type: bracket.type,
|
||||
seeding:
|
||||
bracket.type === "round_robin"
|
||||
? seeding
|
||||
: fillWithNullTillPowerOfTwo(seeding),
|
||||
settings,
|
||||
});
|
||||
|
||||
updateRoundMaps(
|
||||
roundMapsFromInput({
|
||||
virtualRounds: bracket.data.round,
|
||||
roundsFromDB: manager.get.stageData(stage.id).round,
|
||||
maps,
|
||||
bracket,
|
||||
}),
|
||||
);
|
||||
|
||||
// ensures autoseeding is disabled
|
||||
const isAllSeedsPersisted = tournament.ctx.teams.every(
|
||||
(team) => typeof team.seed === "number",
|
||||
);
|
||||
if (!isAllSeedsPersisted) {
|
||||
updateTeamSeeds({
|
||||
tournamentId: tournament.ctx.id,
|
||||
teamIds: tournament.ctx.teams.map((team) => team.id),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
notify({
|
||||
userIds: seeding.flatMap((tournamentTeamId) =>
|
||||
tournament.teamById(tournamentTeamId)!.members.map((m) => m.userId),
|
||||
),
|
||||
notification: {
|
||||
type: "TO_BRACKET_STARTED",
|
||||
meta: {
|
||||
tournamentId,
|
||||
bracketIdx: data.bracketIdx,
|
||||
bracketName: bracket.name,
|
||||
tournamentName: tournament.ctx.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "PREPARE_MAPS": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
||||
errorToastIfFalsy(
|
||||
!bracket.canBeStarted,
|
||||
"Bracket can already be started, preparing maps no longer possible",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
bracket.preview,
|
||||
"Bracket has started, preparing maps no longer possible",
|
||||
);
|
||||
|
||||
const hasThirdPlaceMatch = tournament.bracketManagerSettings(
|
||||
bracket.settings,
|
||||
bracket.type,
|
||||
data.eliminationTeamCount ?? (bracket.seeding ?? []).length,
|
||||
).consolationFinal;
|
||||
|
||||
await TournamentRepository.upsertPreparedMaps({
|
||||
bracketIdx: data.bracketIdx,
|
||||
tournamentId,
|
||||
maps: {
|
||||
maps: hasThirdPlaceMatch
|
||||
? adjustLinkedRounds({
|
||||
maps: data.maps,
|
||||
thirdPlaceMatchLinked: data.thirdPlaceMatchLinked,
|
||||
})
|
||||
: data.maps,
|
||||
authorId: user.id,
|
||||
eliminationTeamCount: data.eliminationTeamCount ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "ADVANCE_BRACKET": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
errorToastIfFalsy(bracket, "Bracket not found");
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "swiss",
|
||||
"Can't advance non-swiss bracket",
|
||||
);
|
||||
|
||||
const matches = Swiss.generateMatchUps({
|
||||
bracket,
|
||||
groupId: data.groupId,
|
||||
});
|
||||
|
||||
await TournamentRepository.insertSwissMatches(matches);
|
||||
|
||||
break;
|
||||
}
|
||||
case "UNADVANCE_BRACKET": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
errorToastIfFalsy(bracket, "Bracket not found");
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "swiss",
|
||||
"Can't unadvance non-swiss bracket",
|
||||
);
|
||||
errorToastIfFalsyNoFollowUpBrackets(tournament);
|
||||
|
||||
await TournamentRepository.deleteSwissMatches({
|
||||
groupId: data.groupId,
|
||||
roundId: data.roundId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "FINALIZE_TOURNAMENT": {
|
||||
errorToastIfFalsy(
|
||||
tournament.canFinalize(user),
|
||||
"Can't finalize tournament",
|
||||
);
|
||||
|
||||
const _finalStandings = tournament.standings;
|
||||
|
||||
const results = allMatchResultsByTournamentId(tournamentId);
|
||||
invariant(results.length > 0, "No results found");
|
||||
|
||||
const season = currentSeason(tournament.ctx.startTime)?.nth;
|
||||
|
||||
const seedingSkillCountsFor = tournament.skillCountsFor;
|
||||
const summary = tournamentSummary({
|
||||
teams: tournament.ctx.teams,
|
||||
finalStandings: _finalStandings,
|
||||
results,
|
||||
calculateSeasonalStats: tournament.ranked,
|
||||
queryCurrentTeamRating: (identifier) =>
|
||||
queryCurrentTeamRating({ identifier, season: season! }).rating,
|
||||
queryCurrentUserRating: (userId) =>
|
||||
queryCurrentUserRating({ userId, season: season! }).rating,
|
||||
queryTeamPlayerRatingAverage: (identifier) =>
|
||||
queryTeamPlayerRatingAverage({
|
||||
identifier,
|
||||
season: season!,
|
||||
}),
|
||||
queryCurrentSeedingRating: (userId) =>
|
||||
queryCurrentUserSeedingRating({
|
||||
userId,
|
||||
type: seedingSkillCountsFor!,
|
||||
}),
|
||||
seedingSkillCountsFor,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Inserting tournament summary. Tournament id: ${tournamentId}, mapResultDeltas.lenght: ${summary.mapResultDeltas.length}, playerResultDeltas.length ${summary.playerResultDeltas.length}, tournamentResults.length ${summary.tournamentResults.length}, skills.length ${summary.skills.length}, seedingSkills.length ${summary.seedingSkills.length}`,
|
||||
);
|
||||
|
||||
addSummary({
|
||||
tournamentId,
|
||||
summary,
|
||||
season,
|
||||
});
|
||||
|
||||
if (tournament.ranked) {
|
||||
try {
|
||||
refreshUserSkills(season!);
|
||||
} catch (error) {
|
||||
logger.warn("Error refreshing user skills", error);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "BRACKET_CHECK_IN": {
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
||||
const ownTeam = tournament.ownedTeamByUser(user);
|
||||
invariant(ownTeam, "User doesn't have owned team");
|
||||
|
||||
errorToastIfFalsy(bracket.canCheckIn(user), "Not an organizer");
|
||||
|
||||
await TournamentRepository.checkIn({
|
||||
bracketIdx: data.bracketIdx,
|
||||
tournamentTeamId: ownTeam.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "OVERRIDE_BRACKET_PROGRESSION": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const allDestinationBrackets = Progression.destinationsFromBracketIdx(
|
||||
data.sourceBracketIdx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
data.destinationBracketIdx === -1 ||
|
||||
allDestinationBrackets.includes(data.destinationBracketIdx),
|
||||
"Invalid destination bracket",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
allDestinationBrackets.every(
|
||||
(bracketIdx) => tournament.bracketByIdx(bracketIdx)!.preview,
|
||||
),
|
||||
"Can't override progression if follow-up brackets are started",
|
||||
);
|
||||
|
||||
await TournamentRepository.overrideTeamBracketProgression({
|
||||
tournamentTeamId: data.tournamentTeamId,
|
||||
sourceBracketIdx: data.sourceBracketIdx,
|
||||
destinationBracketIdx: data.destinationBracketIdx,
|
||||
tournamentId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function errorToastIfFalsyNoFollowUpBrackets(tournament: Tournament) {
|
||||
const followUpBrackets = tournament.brackets.filter((b) =>
|
||||
b.sources?.some((source) => source.bracketIdx === 0),
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
followUpBrackets.every((b) => b.preview),
|
||||
"Follow-up brackets are already started",
|
||||
);
|
||||
}
|
||||
|
||||
function adjustLinkedRounds({
|
||||
maps,
|
||||
thirdPlaceMatchLinked,
|
||||
}: {
|
||||
maps: Omit<PreparedMaps, "createdAt">["maps"];
|
||||
thirdPlaceMatchLinked: boolean;
|
||||
}): Omit<PreparedMaps, "createdAt">["maps"] {
|
||||
if (thirdPlaceMatchLinked) {
|
||||
const finalsMaps = maps
|
||||
.filter((m) => m.groupId === 0)
|
||||
.sort((a, b) => b.roundId - a.roundId)[0];
|
||||
invariant(finalsMaps, "Missing finals maps");
|
||||
|
||||
return [
|
||||
...maps.filter((m) => m.groupId === 0),
|
||||
{ ...finalsMaps, groupId: 1, roundId: finalsMaps.roundId + 1 },
|
||||
];
|
||||
}
|
||||
|
||||
invariant(
|
||||
maps.some((m) => m.groupId === 1),
|
||||
"Missing 3rd place match maps",
|
||||
);
|
||||
|
||||
return maps;
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { TOURNAMENT } from "../../../tournament/tournament-constants";
|
||||
import type { Bracket as BracketType } from "../../core/Bracket";
|
||||
import { getRounds } from "../../core/rounds";
|
||||
import { Match } from "./Match";
|
||||
|
|
@ -72,11 +73,13 @@ export function EliminationBracketSide(props: EliminationBracketSideProps) {
|
|||
match={match}
|
||||
roundNumber={round.number}
|
||||
isPreview={props.bracket.preview}
|
||||
showSimulation={round.name !== "Bracket Reset"}
|
||||
showSimulation={
|
||||
round.name !== TOURNAMENT.ROUND_NAMES.BRACKET_RESET
|
||||
}
|
||||
bracket={props.bracket}
|
||||
type={
|
||||
round.name === "Grand Finals" ||
|
||||
round.name === "Bracket Reset"
|
||||
round.name === TOURNAMENT.ROUND_NAMES.GRAND_FINALS ||
|
||||
round.name === TOURNAMENT.ROUND_NAMES.BRACKET_RESET
|
||||
? "grands"
|
||||
: props.type === "winners"
|
||||
? "winners"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTournament } from "~/features/tournament/routes/to.$id";
|
|||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { TOURNAMENT } from "../../../tournament/tournament-constants";
|
||||
import { useDeadline } from "./useDeadline";
|
||||
|
||||
export function RoundHeader({
|
||||
|
|
@ -22,11 +23,11 @@ export function RoundHeader({
|
|||
const leagueRoundStartDate = useLeagueWeekStart(roundId);
|
||||
|
||||
const hasDeadline = ![
|
||||
"WB Finals",
|
||||
"Grand Finals",
|
||||
"Bracket Reset",
|
||||
"Finals",
|
||||
].includes(name);
|
||||
TOURNAMENT.ROUND_NAMES.WB_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.GRAND_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.BRACKET_RESET,
|
||||
TOURNAMENT.ROUND_NAMES.FINALS,
|
||||
].includes(name as any);
|
||||
|
||||
const countPrefix = maps?.type === "PLAY_ALL" ? "Play all " : "Bo";
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import { databaseTimestampToDate } from "~/utils/dates";
|
|||
import invariant from "~/utils/invariant";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { calendarEditPage } from "~/utils/urls";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { LinkIcon } from "../../../components/icons/Link";
|
||||
import { UnlinkIcon } from "../../../components/icons/Unlink";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import type { Bracket } from "../core/Bracket";
|
||||
import * as PreparedMaps from "../core/PreparedMaps";
|
||||
import type { Tournament } from "../core/Tournament";
|
||||
|
|
@ -79,6 +83,43 @@ export function BracketMapListDialog({
|
|||
|
||||
return PreparedMaps.eliminationTeamCountOptions(bracketTeamsCount)[0].max;
|
||||
});
|
||||
const [thirdPlaceMatchLinked, setThirdPlaceMatchLinked] = React.useState(
|
||||
() => {
|
||||
if (
|
||||
!tournament.bracketManagerSettings(
|
||||
bracket.settings,
|
||||
bracket.type,
|
||||
eliminationTeamCount ?? 2,
|
||||
).consolationFinal
|
||||
) {
|
||||
return true; // default to true if not applicable or elimination team count not yet set (initial state)
|
||||
}
|
||||
|
||||
if (!preparedMaps?.maps) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if maps were set before infer default from whether finals and third place match have different maps or not
|
||||
|
||||
const finalsMaps = preparedMaps.maps
|
||||
.filter((map) => map.groupId === 0)
|
||||
.sort((a, b) => b.roundId - a.roundId)[0];
|
||||
const thirdPlaceMaps = preparedMaps.maps.find((map) => map.groupId === 1);
|
||||
|
||||
if (!finalsMaps?.list || !thirdPlaceMaps?.list) {
|
||||
logger.error(
|
||||
"Expected both finals and third place match maps to be defined",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return finalsMaps.list.every(
|
||||
(map, i) =>
|
||||
map.mode === thirdPlaceMaps.list![i].mode &&
|
||||
map.stageId === thirdPlaceMaps.list![i].stageId,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const bracketData = isPreparing
|
||||
? teamCountAdjustedBracketData({
|
||||
|
|
@ -145,11 +186,26 @@ export function BracketMapListDialog({
|
|||
}
|
||||
|
||||
if (bracket.type === "single_elimination") {
|
||||
return getRounds({ type: "single", bracketData });
|
||||
const rounds = getRounds({ type: "single", bracketData });
|
||||
|
||||
const hasThirdPlaceMatch = rounds.some((round) => round.group_id === 1);
|
||||
|
||||
if (!thirdPlaceMatchLinked || !hasThirdPlaceMatch) return rounds;
|
||||
|
||||
return rounds
|
||||
.filter((round) => round.group_id !== 1)
|
||||
.map((round) =>
|
||||
round.name === "Finals"
|
||||
? {
|
||||
...round,
|
||||
name: TOURNAMENT.ROUND_NAMES.FINALS_THIRD_PLACE_MATCH_UNIFIED,
|
||||
}
|
||||
: round,
|
||||
);
|
||||
}
|
||||
|
||||
assertUnreachable(bracket.type);
|
||||
}, [bracketData, maps, bracket.type]);
|
||||
}, [bracketData, maps, bracket.type, thirdPlaceMatchLinked]);
|
||||
|
||||
const mapCountsWithGlobalCount = (newCount: number) => {
|
||||
const newMap = new Map(defaultRoundBestOfs);
|
||||
|
|
@ -217,6 +273,11 @@ export function BracketMapListDialog({
|
|||
<Dialog isOpen={isOpen} close={close} className="map-list-dialog__dialog">
|
||||
<fetcher.Form method="post" className="map-list-dialog__container">
|
||||
<input type="hidden" name="bracketIdx" value={bracketIdx} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="thirdPlaceMatchLinked"
|
||||
value={thirdPlaceMatchLinked ? "on" : "off"}
|
||||
/>
|
||||
<input
|
||||
type="hidden"
|
||||
name="maps"
|
||||
|
|
@ -405,12 +466,32 @@ export function BracketMapListDialog({
|
|||
const roundMaps = maps.get(round.id);
|
||||
invariant(roundMaps, "Expected maps to be defined");
|
||||
|
||||
const showUnlinkButton =
|
||||
bracket.type === "single_elimination" &&
|
||||
thirdPlaceMatchLinked === true &&
|
||||
round.name ===
|
||||
TOURNAMENT.ROUND_NAMES.FINALS_THIRD_PLACE_MATCH_UNIFIED;
|
||||
const showLinkButton =
|
||||
bracket.type === "single_elimination" &&
|
||||
thirdPlaceMatchLinked === false &&
|
||||
round.name === TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH;
|
||||
|
||||
return (
|
||||
<RoundMapList
|
||||
key={round.id}
|
||||
name={round.name}
|
||||
maps={roundMaps}
|
||||
onHoverMap={setHoveredMap}
|
||||
unlink={
|
||||
showUnlinkButton
|
||||
? () => setThirdPlaceMatchLinked(false)
|
||||
: undefined
|
||||
}
|
||||
link={
|
||||
showLinkButton
|
||||
? () => setThirdPlaceMatchLinked(true)
|
||||
: undefined
|
||||
}
|
||||
hoveredMap={hoveredMap}
|
||||
includeRoundSpecificSelections={
|
||||
bracket.type !== "round_robin"
|
||||
|
|
@ -768,6 +849,8 @@ function RoundMapList({
|
|||
onHoverMap,
|
||||
onCountChange,
|
||||
onPickBanChange,
|
||||
unlink,
|
||||
link,
|
||||
hoveredMap,
|
||||
includeRoundSpecificSelections,
|
||||
}: {
|
||||
|
|
@ -777,6 +860,8 @@ function RoundMapList({
|
|||
onHoverMap: (map: string | null) => void;
|
||||
onCountChange: (count: number) => void;
|
||||
onPickBanChange?: (hasPickBan: boolean) => void;
|
||||
unlink?: () => void;
|
||||
link?: () => void;
|
||||
hoveredMap: string | null;
|
||||
includeRoundSpecificSelections: boolean;
|
||||
}) {
|
||||
|
|
@ -796,6 +881,28 @@ function RoundMapList({
|
|||
{editing ? "Save" : "Edit"}
|
||||
</Button>
|
||||
</h3>
|
||||
{unlink ? (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
className="mt-1"
|
||||
icon={<UnlinkIcon />}
|
||||
onPress={unlink}
|
||||
>
|
||||
Unlink
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{link ? (
|
||||
<SendouButton
|
||||
size="miniscule"
|
||||
variant="outlined"
|
||||
className="mt-1"
|
||||
icon={<LinkIcon />}
|
||||
onPress={link}
|
||||
>
|
||||
Link
|
||||
</SendouButton>
|
||||
) : null}
|
||||
{editing && includeRoundSpecificSelections ? (
|
||||
<div className="stack xs horizontal">
|
||||
{TOURNAMENT.AVAILABLE_BEST_OF.map((count) => (
|
||||
|
|
|
|||
|
|
@ -992,14 +992,14 @@ export class Tournament {
|
|||
const specifier = () => {
|
||||
if (
|
||||
[
|
||||
"WB Finals",
|
||||
"Grand Finals",
|
||||
"Bracket Reset",
|
||||
"Finals",
|
||||
"LB Finals",
|
||||
"LB Semis",
|
||||
"3rd place match",
|
||||
].includes(round.name)
|
||||
TOURNAMENT.ROUND_NAMES.WB_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.GRAND_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.BRACKET_RESET,
|
||||
TOURNAMENT.ROUND_NAMES.FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.LB_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.LB_SEMIS,
|
||||
TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH,
|
||||
].includes(round.name as any)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,10 @@ export function roundMapsFromInput({
|
|||
|
||||
return expandedMaps.map((map) => {
|
||||
const virtualRound = virtualRounds.find((r) => r.id === map.roundId);
|
||||
invariant(virtualRound, "No virtual round found for map");
|
||||
invariant(
|
||||
virtualRound,
|
||||
`No virtual round found for map with round id: ${map.roundId}`,
|
||||
);
|
||||
|
||||
const realRoundId = roundsFromDB.find(
|
||||
(r) =>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import { TOURNAMENT } from "../../tournament/tournament-constants";
|
||||
|
||||
export function getRounds(args: {
|
||||
bracketData: TournamentManagerDataSet;
|
||||
|
|
@ -59,18 +60,20 @@ export function getRounds(args: {
|
|||
args.type === "winners" &&
|
||||
i === rounds.length - 2
|
||||
) {
|
||||
return "Grand Finals";
|
||||
return TOURNAMENT.ROUND_NAMES.GRAND_FINALS;
|
||||
}
|
||||
|
||||
if (hasThirdPlaceMatch && i === rounds.length - 2) {
|
||||
return "Finals";
|
||||
return TOURNAMENT.ROUND_NAMES.FINALS;
|
||||
}
|
||||
if (hasThirdPlaceMatch && i === rounds.length - 1) {
|
||||
return "3rd place match";
|
||||
return TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH;
|
||||
}
|
||||
|
||||
if (args.type === "winners" && i === rounds.length - 1) {
|
||||
return showingBracketReset ? "Bracket Reset" : "Grand Finals";
|
||||
return showingBracketReset
|
||||
? TOURNAMENT.ROUND_NAMES.BRACKET_RESET
|
||||
: TOURNAMENT.ROUND_NAMES.GRAND_FINALS;
|
||||
}
|
||||
|
||||
const namePrefix =
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { ActionFunction } from "@remix-run/node";
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { sub } from "date-fns";
|
||||
|
|
@ -18,352 +17,33 @@ 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";
|
||||
import {
|
||||
queryCurrentTeamRating,
|
||||
queryCurrentUserRating,
|
||||
queryCurrentUserSeedingRating,
|
||||
queryTeamPlayerRatingAverage,
|
||||
} from "~/features/mmr/mmr-utils.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
import { refreshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server";
|
||||
import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server";
|
||||
import { TOURNAMENT } from "~/features/tournament";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOU_INK_BASE_URL,
|
||||
tournamentBracketsSubscribePage,
|
||||
tournamentJoinPage,
|
||||
} from "~/utils/urls";
|
||||
import { updateTeamSeeds } from "../../tournament/queries/updateTeamSeeds.server";
|
||||
import {
|
||||
useBracketExpanded,
|
||||
useTournament,
|
||||
useTournamentPreparedMaps,
|
||||
} from "../../tournament/routes/to.$id";
|
||||
import { action } from "../actions/to.$id.brackets.server";
|
||||
import { Bracket } from "../components/Bracket";
|
||||
import { BracketMapListDialog } from "../components/BracketMapListDialog";
|
||||
import { TournamentTeamActions } from "../components/TournamentTeamActions";
|
||||
import type { Bracket as BracketType } from "../core/Bracket";
|
||||
import * as PreparedMaps from "../core/PreparedMaps";
|
||||
import * as Swiss from "../core/Swiss";
|
||||
import type { Tournament } from "../core/Tournament";
|
||||
import {
|
||||
clearTournamentDataCache,
|
||||
tournamentFromDB,
|
||||
} from "../core/Tournament.server";
|
||||
import { getServerTournamentManager } from "../core/brackets-manager/manager.server";
|
||||
import { roundMapsFromInput } from "../core/mapList.server";
|
||||
import { tournamentSummary } from "../core/summarizer.server";
|
||||
import { addSummary } from "../queries/addSummary.server";
|
||||
import { allMatchResultsByTournamentId } from "../queries/allMatchResultsByTournamentId.server";
|
||||
import { bracketSchema } from "../tournament-bracket-schemas.server";
|
||||
import {
|
||||
bracketSubscriptionKey,
|
||||
fillWithNullTillPowerOfTwo,
|
||||
} from "../tournament-bracket-utils";
|
||||
import { bracketSubscriptionKey } from "../tournament-bracket-utils";
|
||||
export { action };
|
||||
|
||||
import "../components/Bracket/bracket.css";
|
||||
import "../tournament-bracket.css";
|
||||
|
||||
export const action: ActionFunction = async ({ params, request }) => {
|
||||
const user = await requireUser(request);
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const data = await parseRequestPayload({ request, schema: bracketSchema });
|
||||
const manager = getServerTournamentManager();
|
||||
|
||||
switch (data._action) {
|
||||
case "START_BRACKET": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
||||
const seeding = bracket.seeding;
|
||||
invariant(seeding, "Seeding not found");
|
||||
|
||||
errorToastIfFalsy(
|
||||
bracket.canBeStarted,
|
||||
"Bracket is not ready to be started",
|
||||
);
|
||||
|
||||
const groupCount = new Set(bracket.data.round.map((r) => r.group_id))
|
||||
.size;
|
||||
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "round_robin" || bracket.type === "swiss"
|
||||
? bracket.data.round.length / groupCount === data.maps.length
|
||||
: bracket.data.round.length === data.maps.length,
|
||||
"Invalid map count",
|
||||
);
|
||||
|
||||
sql.transaction(() => {
|
||||
const stage =
|
||||
bracket.type === "swiss"
|
||||
? createSwissBracketInTransaction(
|
||||
Swiss.create({
|
||||
name: bracket.name,
|
||||
seeding,
|
||||
tournamentId,
|
||||
settings: tournament.bracketManagerSettings(
|
||||
bracket.settings,
|
||||
bracket.type,
|
||||
seeding.length,
|
||||
),
|
||||
}),
|
||||
)
|
||||
: manager.create({
|
||||
tournamentId,
|
||||
name: bracket.name,
|
||||
type: bracket.type as "round_robin",
|
||||
seeding:
|
||||
bracket.type === "round_robin"
|
||||
? seeding
|
||||
: fillWithNullTillPowerOfTwo(seeding),
|
||||
settings: tournament.bracketManagerSettings(
|
||||
bracket.settings,
|
||||
bracket.type,
|
||||
seeding.length,
|
||||
),
|
||||
});
|
||||
|
||||
updateRoundMaps(
|
||||
roundMapsFromInput({
|
||||
virtualRounds: bracket.data.round,
|
||||
roundsFromDB: manager.get.stageData(stage.id).round,
|
||||
maps: data.maps,
|
||||
bracket,
|
||||
}),
|
||||
);
|
||||
|
||||
// ensures autoseeding is disabled
|
||||
const isAllSeedsPersisted = tournament.ctx.teams.every(
|
||||
(team) => typeof team.seed === "number",
|
||||
);
|
||||
if (!isAllSeedsPersisted) {
|
||||
updateTeamSeeds({
|
||||
tournamentId: tournament.ctx.id,
|
||||
teamIds: tournament.ctx.teams.map((team) => team.id),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
notify({
|
||||
userIds: seeding.flatMap((tournamentTeamId) =>
|
||||
tournament.teamById(tournamentTeamId)!.members.map((m) => m.userId),
|
||||
),
|
||||
notification: {
|
||||
type: "TO_BRACKET_STARTED",
|
||||
meta: {
|
||||
tournamentId,
|
||||
bracketIdx: data.bracketIdx,
|
||||
bracketName: bracket.name,
|
||||
tournamentName: tournament.ctx.name,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "PREPARE_MAPS": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
||||
errorToastIfFalsy(
|
||||
!bracket.canBeStarted,
|
||||
"Bracket can already be started, preparing maps no longer possible",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
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": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
errorToastIfFalsy(bracket, "Bracket not found");
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "swiss",
|
||||
"Can't advance non-swiss bracket",
|
||||
);
|
||||
|
||||
const matches = Swiss.generateMatchUps({
|
||||
bracket,
|
||||
groupId: data.groupId,
|
||||
});
|
||||
|
||||
await TournamentRepository.insertSwissMatches(matches);
|
||||
|
||||
break;
|
||||
}
|
||||
case "UNADVANCE_BRACKET": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
errorToastIfFalsy(bracket, "Bracket not found");
|
||||
errorToastIfFalsy(
|
||||
bracket.type === "swiss",
|
||||
"Can't unadvance non-swiss bracket",
|
||||
);
|
||||
validateNoFollowUpBrackets(tournament);
|
||||
|
||||
await TournamentRepository.deleteSwissMatches({
|
||||
groupId: data.groupId,
|
||||
roundId: data.roundId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "FINALIZE_TOURNAMENT": {
|
||||
errorToastIfFalsy(
|
||||
tournament.canFinalize(user),
|
||||
"Can't finalize tournament",
|
||||
);
|
||||
|
||||
const _finalStandings = tournament.standings;
|
||||
|
||||
const results = allMatchResultsByTournamentId(tournamentId);
|
||||
invariant(results.length > 0, "No results found");
|
||||
|
||||
const season = currentSeason(tournament.ctx.startTime)?.nth;
|
||||
|
||||
const seedingSkillCountsFor = tournament.skillCountsFor;
|
||||
const summary = tournamentSummary({
|
||||
teams: tournament.ctx.teams,
|
||||
finalStandings: _finalStandings,
|
||||
results,
|
||||
calculateSeasonalStats: tournament.ranked,
|
||||
queryCurrentTeamRating: (identifier) =>
|
||||
queryCurrentTeamRating({ identifier, season: season! }).rating,
|
||||
queryCurrentUserRating: (userId) =>
|
||||
queryCurrentUserRating({ userId, season: season! }).rating,
|
||||
queryTeamPlayerRatingAverage: (identifier) =>
|
||||
queryTeamPlayerRatingAverage({
|
||||
identifier,
|
||||
season: season!,
|
||||
}),
|
||||
queryCurrentSeedingRating: (userId) =>
|
||||
queryCurrentUserSeedingRating({
|
||||
userId,
|
||||
type: seedingSkillCountsFor!,
|
||||
}),
|
||||
seedingSkillCountsFor,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Inserting tournament summary. Tournament id: ${tournamentId}, mapResultDeltas.lenght: ${summary.mapResultDeltas.length}, playerResultDeltas.length ${summary.playerResultDeltas.length}, tournamentResults.length ${summary.tournamentResults.length}, skills.length ${summary.skills.length}, seedingSkills.length ${summary.seedingSkills.length}`,
|
||||
);
|
||||
|
||||
addSummary({
|
||||
tournamentId,
|
||||
summary,
|
||||
season,
|
||||
});
|
||||
|
||||
if (tournament.ranked) {
|
||||
try {
|
||||
refreshUserSkills(season!);
|
||||
} catch (error) {
|
||||
logger.warn("Error refreshing user skills", error);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "BRACKET_CHECK_IN": {
|
||||
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
||||
invariant(bracket, "Bracket not found");
|
||||
|
||||
const ownTeam = tournament.ownedTeamByUser(user);
|
||||
invariant(ownTeam, "User doesn't have owned team");
|
||||
|
||||
errorToastIfFalsy(
|
||||
bracket.canCheckIn(user),
|
||||
"Can't check in to this bracket right now",
|
||||
);
|
||||
|
||||
await TournamentRepository.checkIn({
|
||||
bracketIdx: data.bracketIdx,
|
||||
tournamentTeamId: ownTeam.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "OVERRIDE_BRACKET_PROGRESSION": {
|
||||
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
|
||||
|
||||
const allDestinationBrackets = Progression.destinationsFromBracketIdx(
|
||||
data.sourceBracketIdx,
|
||||
tournament.ctx.settings.bracketProgression,
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
data.destinationBracketIdx === -1 ||
|
||||
allDestinationBrackets.includes(data.destinationBracketIdx),
|
||||
"Invalid destination bracket",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
allDestinationBrackets.every(
|
||||
(bracketIdx) => tournament.bracketByIdx(bracketIdx)!.preview,
|
||||
),
|
||||
"Can't override progression if follow-up brackets are started",
|
||||
);
|
||||
|
||||
await TournamentRepository.overrideTeamBracketProgression({
|
||||
tournamentTeamId: data.tournamentTeamId,
|
||||
sourceBracketIdx: data.sourceBracketIdx,
|
||||
destinationBracketIdx: data.destinationBracketIdx,
|
||||
tournamentId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function validateNoFollowUpBrackets(tournament: Tournament) {
|
||||
const followUpBrackets = tournament.brackets.filter((b) =>
|
||||
b.sources?.some((source) => source.bracketIdx === 0),
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
followUpBrackets.every((b) => b.preview),
|
||||
"Follow-up brackets are already started",
|
||||
);
|
||||
}
|
||||
|
||||
export default function TournamentBracketsPage() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const visibility = useVisibilityChange();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
|
|||
import { sql } from "~/db/sql";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { tournamentIdFromParams } from "~/features/tournament";
|
||||
import { TOURNAMENT, tournamentIdFromParams } from "~/features/tournament";
|
||||
import * as TournamentMatchRepository from "~/features/tournament-bracket/TournamentMatchRepository.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
|
|
@ -787,14 +787,14 @@ function MatchHeader() {
|
|||
const specifier = () => {
|
||||
if (
|
||||
[
|
||||
"WB Finals",
|
||||
"Grand Finals",
|
||||
"Bracket Reset",
|
||||
"Finals",
|
||||
"LB Finals",
|
||||
"LB Semis",
|
||||
"3rd place match",
|
||||
].includes(round.name)
|
||||
TOURNAMENT.ROUND_NAMES.WB_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.GRAND_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.BRACKET_RESET,
|
||||
TOURNAMENT.ROUND_NAMES.FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.LB_FINALS,
|
||||
TOURNAMENT.ROUND_NAMES.LB_SEMIS,
|
||||
TOURNAMENT.ROUND_NAMES.THIRD_PLACE_MATCH,
|
||||
].includes(round.name as any)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
_action,
|
||||
checkboxValueToBoolean,
|
||||
id,
|
||||
modeShort,
|
||||
nullLiteraltoNull,
|
||||
|
|
@ -117,12 +118,14 @@ export const bracketSchema = z.union([
|
|||
z.object({
|
||||
_action: _action("START_BRACKET"),
|
||||
bracketIdx,
|
||||
thirdPlaceMatchLinked: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("PREPARE_MAPS"),
|
||||
bracketIdx,
|
||||
maps: z.preprocess(safeJSONParse, z.array(tournamentRoundMaps)),
|
||||
thirdPlaceMatchLinked: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
eliminationTeamCount: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
|
|
|
|||
|
|
@ -68,13 +68,6 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
"Team id does not match any of the teams you are in",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
!tournament.ctx.teams.some(
|
||||
(team) => team.name === data.teamName && team.id !== data.teamId,
|
||||
),
|
||||
"Team name already taken for this tournament",
|
||||
);
|
||||
|
||||
if (ownTeam) {
|
||||
errorToastIfFalsy(
|
||||
tournament.registrationOpen || data.teamName === ownTeam.name,
|
||||
|
|
@ -106,6 +99,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
tournament.registrationOpen,
|
||||
"Registration is closed",
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
!tournament.ctx.teams.some((team) => team.name === data.teamName),
|
||||
"Team name already taken for this tournament",
|
||||
);
|
||||
|
||||
await TournamentTeamRepository.create({
|
||||
ownerInGameName: await inGameNameIfNeeded({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ export const TOURNAMENT = {
|
|||
SWISS_DEFAULT_GROUP_COUNT: 1,
|
||||
SWISS_DEFAULT_ROUND_COUNT: 5,
|
||||
SE_DEFAULT_HAS_THIRD_PLACE_MATCH: true,
|
||||
ROUND_NAMES: {
|
||||
WB_FINALS: "WB Finals",
|
||||
GRAND_FINALS: "Grand Finals",
|
||||
BRACKET_RESET: "Bracket Reset",
|
||||
FINALS: "Finals",
|
||||
LB_FINALS: "LB Finals",
|
||||
LB_SEMIS: "LB Semis",
|
||||
THIRD_PLACE_MATCH: "3rd place match",
|
||||
FINALS_THIRD_PLACE_MATCH_UNIFIED: "Finals + 3rd place match",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const LEAGUES =
|
||||
|
|
|
|||
|
|
@ -112,11 +112,16 @@
|
|||
margin-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.react-aria-Button.small > .button-icon {
|
||||
.react-aria-Button.small > .sendou-button-icon {
|
||||
width: 1rem;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.react-aria-Button.miniscule > .sendou-button-icon {
|
||||
width: 0.857rem;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.sendou-popover-content {
|
||||
max-width: 20rem;
|
||||
padding: var(--s-2);
|
||||
|
|
|
|||
|
|
@ -884,7 +884,9 @@ test.describe("Tournament bracket", () => {
|
|||
await expect(page.getByText("BYE")).toBeVisible();
|
||||
});
|
||||
|
||||
test("prepares maps", async ({ page }) => {
|
||||
test("prepares maps (including third place match linking)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tournamentId = 4;
|
||||
|
||||
await seed(page);
|
||||
|
|
@ -914,6 +916,30 @@ test.describe("Tournament bracket", () => {
|
|||
await page.getByRole("button", { name: "Hammerhead" }).click();
|
||||
|
||||
await expect(page.getByTestId("prepared-maps-check-icon")).toBeVisible();
|
||||
|
||||
// finally, test third place match linking
|
||||
await page.getByRole("button", { name: "Great White" }).click();
|
||||
|
||||
await page.getByTestId("prepare-maps-button").click();
|
||||
|
||||
await page.getByRole("button", { name: "Unlink" }).click();
|
||||
|
||||
await page.getByRole("button", { name: "Edit" }).last().click();
|
||||
await page.getByLabel("Bo9").click();
|
||||
|
||||
await page.getByTestId("confirm-finalize-bracket-button").click();
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: tournamentBracketsPage({ tournamentId }),
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Great White" }).click();
|
||||
|
||||
await page.getByTestId("prepare-maps-button").click();
|
||||
|
||||
// link button should be visible because we unlinked and made finals and third place match maps different earlier
|
||||
expect(page.getByRole("button", { name: "Link" })).toBeVisible();
|
||||
});
|
||||
|
||||
for (const pickBan of ["COUNTERPICK"]) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user