Linked third place match (#2133)

* Initial

* Done

* fix
This commit is contained in:
Kalle 2025-03-07 22:36:10 +02:00 committed by GitHub
parent aea1e9ce35
commit dd4f68158d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 584 additions and 365 deletions

View 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>
);
}

View File

@ -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;
}

View File

@ -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"

View File

@ -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";

View File

@ -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) => (

View File

@ -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 "";
}

View File

@ -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) =>

View File

@ -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 =

View File

@ -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();

View File

@ -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 "";
}

View File

@ -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()

View File

@ -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({

View File

@ -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 =

View File

@ -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);

View File

@ -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"]) {