Override tournament bracket destination (#1985)
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

* Initial

* Progress

* Done?

* Update seeding nth
This commit is contained in:
Kalle 2024-12-15 12:24:11 +02:00 committed by GitHub
parent d34782e720
commit d3a825bd57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 8378 additions and 329 deletions

View File

@ -57,6 +57,7 @@ export function Button(props: ButtonProps) {
{icon &&
React.cloneElement(icon, {
className: clsx("button-icon", { lonely: !children }),
title: rest.title,
})}
{loading && loadingText ? loadingText : children}
</button>

View File

@ -1,4 +1,7 @@
export function EditIcon({ className }: { className?: string }) {
export function EditIcon({
className,
title,
}: { className?: string; title?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -6,7 +9,7 @@ export function EditIcon({ className }: { className?: string }) {
viewBox="0 0 20 20"
fill="currentColor"
>
<title>Edit Icon</title>
<title>{title ?? "Edit Icon"}</title>
<path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" />
<path
fillRule="evenodd"

View File

@ -688,6 +688,13 @@ export interface TournamentOrganizationSeries {
showLeaderboard: Generated<number>;
}
export interface TournamentBracketProgressionOverride {
sourceBracketIdx: number;
destinationBracketIdx: number;
tournamentTeamId: number;
tournamentId: number;
}
export interface TrustRelationship {
trustGiverUserId: number;
trustReceiverUserId: number;
@ -923,6 +930,7 @@ export interface DB {
TournamentOrganizationMember: TournamentOrganizationMember;
TournamentOrganizationBadge: TournamentOrganizationBadge;
TournamentOrganizationSeries: TournamentOrganizationSeries;
TournamentBracketProgressionOverride: TournamentBracketProgressionOverride;
TrustRelationship: TrustRelationship;
UnvalidatedUserSubmittedImage: UnvalidatedUserSubmittedImage;
UnvalidatedVideo: UnvalidatedVideo;

View File

@ -0,0 +1,352 @@
import { Link, useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Button } from "../../../../components/Button";
import { CheckmarkIcon } from "../../../../components/icons/Checkmark";
import { CrossIcon } from "../../../../components/icons/Cross";
import { EditIcon } from "../../../../components/icons/Edit";
import { logger } from "../../../../utils/logger";
import { tournamentTeamPage } from "../../../../utils/urls";
import { useUser } from "../../../auth/core/user";
import type { Bracket } from "../../core/Bracket";
import * as Progression from "../../core/Progression";
export function PlacementsTable({
groupId,
bracket,
allMatchesFinished,
}: {
groupId: number;
bracket: Bracket;
allMatchesFinished: boolean;
}) {
const user = useUser();
const _standings = bracket
.currentStandings(true)
.filter((s) => s.groupId === groupId);
const missingTeams = bracket.data.match.reduce((acc, cur) => {
if (cur.group_id !== groupId) return acc;
if (
cur.opponent1?.id &&
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
!acc.includes(cur.opponent1.id)
) {
acc.push(cur.opponent1.id);
}
if (
cur.opponent2?.id &&
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
!acc.includes(cur.opponent2.id)
) {
acc.push(cur.opponent2.id);
}
return acc;
}, [] as number[]);
const standings = _standings
.concat(
missingTeams.map((id) => ({
team: bracket.tournament.teamById(id)!,
stats: {
mapLosses: 0,
mapWins: 0,
points: 0,
setLosses: 0,
setWins: 0,
winsAgainstTied: 0,
lossesAgainstTied: 0,
},
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
groupId,
})),
)
.sort((a, b) => {
if (a.placement === b.placement && a.team.seed && b.team.seed) {
return a.team.seed - b.team.seed;
}
return a.placement - b.placement;
});
const destinationBracket = (placement: number) =>
bracket.tournament.brackets.find(
(b) =>
b.id !== bracket.id &&
b.sources?.some(
(s) => s.bracketIdx === 0 && s.placements.includes(placement),
),
);
const possibleDestinationBrackets = Progression.destinationsFromBracketIdx(
bracket.idx,
bracket.tournament.ctx.settings.bracketProgression,
).map((idx) => bracket.tournament.bracketByIdx(idx)!);
const canEditDestination = (() => {
const allDestinationsPreview = possibleDestinationBrackets.every(
(b) => b.preview,
);
return (
bracket.tournament.isOrganizer(user) &&
allDestinationsPreview &&
allMatchesFinished
);
})();
return (
<table className="rr__placements-table" cellSpacing={0}>
<thead>
<tr>
<th>Team</th>
<th>
<abbr title="Set wins and losses">W/L</abbr>
</th>
{bracket.type === "round_robin" ? (
<th>
<abbr title="Wins against tied opponents">TB</abbr>
</th>
) : null}
{bracket.type === "swiss" ? (
<th>
<abbr title="Losses against tied opponents">TB</abbr>
</th>
) : null}
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
{bracket.type === "round_robin" ? (
<th>
<abbr title="Score summed up">Scr</abbr>
</th>
) : null}
{bracket.type === "swiss" ? (
<>
<th>
<abbr title="Buchholz (summed set wins of opponents)">
Buch.
</abbr>
</th>
<th>
<abbr title="Buchholz (summed map wins of opponents)">
Buch. (M)
</abbr>
</th>
</>
) : null}
<th>Seed</th>
<th />
{canEditDestination ? <th /> : null}
</tr>
</thead>
<tbody>
{standings.map((s, i) => {
const stats = s.stats!;
if (!stats) {
logger.error("No stats for team", s.team);
return null;
}
const team = bracket.tournament.teamById(s.team.id);
const dest = destinationBracket(i + 1);
const overridenDestination =
bracket.tournament.ctx.bracketProgressionOverrides.find(
(override) =>
override.sourceBracketIdx === bracket.idx &&
override.tournamentTeamId === s.team.id,
);
const overridenDestinationBracket = overridenDestination
? bracket.tournament.bracketByIdx(
overridenDestination.destinationBracketIdx,
)!
: undefined;
return (
<tr key={s.team.id}>
<td>
<Link
to={tournamentTeamPage({
tournamentId: bracket.tournament.ctx.id,
tournamentTeamId: s.team.id,
})}
>
{s.team.name}{" "}
</Link>
{s.team.droppedOut ? (
<span className="text-warning text-xxxs font-bold">
Drop-out
</span>
) : null}
</td>
<td>
<span>
{stats.setWins}/{stats.setLosses}
</span>
</td>
{bracket.type === "round_robin" ? (
<td>
<span>{stats.winsAgainstTied}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<td>
<span>{(stats.lossesAgainstTied ?? 0) * -1}</span>
</td>
) : null}
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
{bracket.type === "round_robin" ? (
<td>
<span>{stats.points}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<>
<td>
<span>{stats.buchholzSets}</span>
</td>
<td>
<span>{stats.buchholzMaps}</span>
</td>
</>
) : null}
<td>{team?.seed}</td>
<EditableDestination
key={overridenDestinationBracket?.idx}
source={bracket}
destination={dest}
overridenDestination={overridenDestinationBracket}
possibleDestinations={possibleDestinationBrackets}
allMatchesFinished={allMatchesFinished}
canEditDestination={canEditDestination}
tournamentTeamId={s.team.id}
/>
</tr>
);
})}
</tbody>
</table>
);
}
function EditableDestination({
source,
destination,
overridenDestination,
possibleDestinations: _possibleDestinations,
allMatchesFinished,
canEditDestination,
tournamentTeamId,
}: {
source: Bracket;
destination?: Bracket;
overridenDestination?: Bracket;
possibleDestinations: Bracket[];
allMatchesFinished: boolean;
canEditDestination: boolean;
tournamentTeamId: number;
}) {
const fetcher = useFetcher<any>();
const [editingDestination, setEditingDestination] = React.useState(false);
const [newDestinationIdx, setNewDestinationIdx] = React.useState<
number | null
>(overridenDestination?.idx ?? destination?.idx ?? -1);
const handleSubmit = () => {
fetcher.submit(
{
_action: "OVERRIDE_BRACKET_PROGRESSION",
tournamentTeamId,
sourceBracketIdx: source.idx,
destinationBracketIdx: newDestinationIdx,
},
{ method: "post", encType: "application/json" },
);
};
const possibleDestinations = !destination
? (["ELIMINATED", ..._possibleDestinations] as const)
: _possibleDestinations;
if (editingDestination) {
return (
<>
<td>
<select
value={String(newDestinationIdx)}
onChange={(e) => setNewDestinationIdx(Number(e.target.value))}
>
{possibleDestinations.map((b) => (
<option
key={b === "ELIMINATED" ? "ELIMINATED" : b.id}
value={b === "ELIMINATED" ? -1 : b.idx}
>
{b === "ELIMINATED" ? "Eliminated" : b.name}
</option>
))}
</select>
</td>
<td>
<div className="stack horizontal xs">
<Button
variant="minimal"
title="Save destination"
icon={<CheckmarkIcon />}
size="tiny"
onClick={handleSubmit}
/>
<Button
variant="minimal-destructive"
title="Cancel"
size="tiny"
icon={<CrossIcon />}
onClick={() => setEditingDestination(false)}
/>
</div>
</td>
</>
);
}
return (
<>
{allMatchesFinished &&
overridenDestination &&
overridenDestination.idx !== destination?.idx ? (
<td className="text-theme font-bold">
<span> {overridenDestination.name}</span>
</td>
) : destination ? (
<td
className={clsx({
"italic text-lighter": !allMatchesFinished,
})}
>
<span> {destination.name}</span>
</td>
) : (
<td />
)}
{canEditDestination ? (
<td>
<Button
variant="minimal"
title="Edit destination"
icon={<EditIcon />}
size="tiny"
onClick={() => setEditingDestination(true)}
/>
</td>
) : null}
</>
);
}

View File

@ -1,11 +1,8 @@
import { Link } from "@remix-run/react";
import clsx from "clsx";
import type { Match as MatchType } from "~/modules/brackets-model";
import { logger } from "~/utils/logger";
import { tournamentTeamPage } from "~/utils/urls";
import type { Bracket as BracketType } from "../../core/Bracket";
import { groupNumberToLetter } from "../../tournament-bracket-utils";
import { Match } from "./Match";
import { PlacementsTable } from "./PlacementsTable";
import { RoundHeader } from "./RoundHeader";
export function RoundRobinBracket({ bracket }: { bracket: BracketType }) {
@ -118,152 +115,3 @@ function getGroups(bracket: BracketType) {
return result;
}
function PlacementsTable({
groupId,
bracket,
allMatchesFinished,
}: {
groupId: number;
bracket: BracketType;
allMatchesFinished: boolean;
}) {
const _standings = bracket
.currentStandings(true)
.filter((s) => s.groupId === groupId);
const missingTeams = bracket.data.match.reduce((acc, cur) => {
if (cur.group_id !== groupId) return acc;
if (
cur.opponent1?.id &&
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
!acc.includes(cur.opponent1.id)
) {
acc.push(cur.opponent1.id);
}
if (
cur.opponent2?.id &&
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
!acc.includes(cur.opponent2.id)
) {
acc.push(cur.opponent2.id);
}
return acc;
}, [] as number[]);
const standings = _standings
.concat(
missingTeams.map((id) => ({
team: bracket.tournament.teamById(id)!,
stats: {
mapLosses: 0,
mapWins: 0,
points: 0,
setLosses: 0,
setWins: 0,
winsAgainstTied: 0,
},
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
groupId,
})),
)
.sort((a, b) => {
if (a.placement === b.placement && a.team.seed && b.team.seed) {
return a.team.seed - b.team.seed;
}
return a.placement - b.placement;
});
const destinationBracket = (placement: number) =>
bracket.tournament.brackets.find(
(b) =>
b.id !== bracket.id &&
b.sources?.some(
(s) => s.bracketIdx === 0 && s.placements.includes(placement),
),
);
return (
<table className="rr__placements-table" cellSpacing={0}>
<thead>
<tr>
<th>Team</th>
<th>
<abbr title="Set wins and losses">W/L</abbr>
</th>
<th>
<abbr title="Wins against tied opponents">TB</abbr>
</th>
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
<th>
<abbr title="Score summed up">Scr</abbr>
</th>
<th>Seed</th>
<th />
</tr>
</thead>
<tbody>
{standings.map((s, i) => {
const stats = s.stats!;
if (!stats) {
logger.error("No stats for team", s.team);
return null;
}
const team = bracket.tournament.teamById(s.team.id);
const dest = destinationBracket(i + 1);
return (
<tr key={s.team.id}>
<td>
<Link
to={tournamentTeamPage({
tournamentId: bracket.tournament.ctx.id,
tournamentTeamId: s.team.id,
})}
>
{s.team.name}
</Link>
</td>
<td>
<span>
{stats.setWins}/{stats.setLosses}
</span>
</td>
<td>
<span>{stats.winsAgainstTied}</span>
</td>
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
<td>
<span>{stats.points}</span>
</td>
<td>{team?.seed}</td>
{dest ? (
<td
className={clsx({
"italic text-lighter": !allMatchesFinished,
})}
>
<span> {dest.name}</span>
</td>
) : (
<td />
)}
</tr>
);
})}
</tbody>
</table>
);
}

View File

@ -1,4 +1,4 @@
import { Link, useFetcher } from "@remix-run/react";
import { useFetcher } from "@remix-run/react";
import clsx from "clsx";
import { Button } from "~/components/Button";
import { FormWithConfirm } from "~/components/FormWithConfirm";
@ -10,11 +10,10 @@ import {
} from "~/features/tournament/routes/to.$id";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import type { Match as MatchType } from "~/modules/brackets-model";
import { logger } from "~/utils/logger";
import { tournamentTeamPage } from "~/utils/urls";
import type { Bracket as BracketType } from "../../core/Bracket";
import { groupNumberToLetter } from "../../tournament-bracket-utils";
import { Match } from "./Match";
import { PlacementsTable } from "./PlacementsTable";
import { RoundHeader } from "./RoundHeader";
export function SwissBracket({
@ -268,166 +267,3 @@ function getGroups(bracket: BracketType) {
return result;
}
function PlacementsTable({
groupId,
bracket,
allMatchesFinished,
}: {
groupId: number;
bracket: BracketType;
allMatchesFinished: boolean;
}) {
const _standings = bracket
.currentStandings(true)
.filter((s) => s.groupId === groupId);
const missingTeams = bracket.data.match.reduce((acc, cur) => {
if (cur.group_id !== groupId) return acc;
if (
cur.opponent1?.id &&
!_standings.some((s) => s.team.id === cur.opponent1!.id) &&
!acc.includes(cur.opponent1.id)
) {
acc.push(cur.opponent1.id);
}
if (
cur.opponent2?.id &&
!_standings.some((s) => s.team.id === cur.opponent2!.id) &&
!acc.includes(cur.opponent2.id)
) {
acc.push(cur.opponent2.id);
}
return acc;
}, [] as number[]);
const standings = _standings
.concat(
missingTeams.map((id) => ({
team: bracket.tournament.teamById(id)!,
stats: {
mapLosses: 0,
mapWins: 0,
points: 0,
setLosses: 0,
setWins: 0,
winsAgainstTied: 0,
lossesAgainstTied: 0,
},
placement: Math.max(..._standings.map((s) => s.placement)) + 1,
groupId,
})),
)
.sort((a, b) => {
if (a.placement === b.placement && a.team.seed && b.team.seed) {
return a.team.seed - b.team.seed;
}
return a.placement - b.placement;
});
const destinationBracket = (placement: number) =>
bracket.tournament.brackets.find(
(b) =>
b.id !== bracket.id &&
b.sources?.some(
(s) => s.bracketIdx === 0 && s.placements.includes(placement),
),
);
return (
<table className="rr__placements-table" cellSpacing={0}>
<thead>
<tr>
<th>Team</th>
<th>
<abbr title="Set wins and losses">W/L</abbr>
</th>
<th>
<abbr title="Losses against tied opponents">TB</abbr>
</th>
<th>
<abbr title="Map wins and losses">W/L (M)</abbr>
</th>
<th>
<abbr title="Buchholz (summed set wins of opponents)">Buch.</abbr>
</th>
<th>
<abbr title="Buchholz (summed map wins of opponents)">
Buch. (M)
</abbr>
</th>
<th>Seed</th>
<th />
</tr>
</thead>
<tbody>
{standings.map((s, i) => {
const stats = s.stats!;
if (!stats) {
logger.error("No stats for team", s.team);
return null;
}
const team = bracket.tournament.teamById(s.team.id);
const dest = destinationBracket(i + 1);
return (
<tr key={s.team.id}>
<td>
<Link
to={tournamentTeamPage({
tournamentId: bracket.tournament.ctx.id,
tournamentTeamId: s.team.id,
})}
>
{s.team.name}{" "}
</Link>
{s.team.droppedOut ? (
<span className="text-warning text-xxxs font-bold">
Drop-out
</span>
) : null}
</td>
<td>
<span>
{stats.setWins}/{stats.setLosses}
</span>
</td>
<td>
<span>{(stats.lossesAgainstTied ?? 0) * -1}</span>
</td>
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
<td>
<span>{stats.buchholzSets}</span>
</td>
<td>
<span>{stats.buchholzMaps}</span>
</td>
<td>{team?.seed}</td>
{dest ? (
<td
className={clsx({
"italic text-lighter": !allMatchesFinished,
})}
>
<span> {dest.name}</span>
</td>
) : (
<td />
)}
</tr>
);
})}
</tbody>
</table>
);
}

View File

@ -580,3 +580,26 @@ describe("bracketIdxsForStandings", () => {
).toEqual([0]); // missing 1 because it's underground when DE is the source
});
});
describe("destinationsFromBracketIdx", () => {
it("returns correct destination (one destination)", () => {
expect(
Progression.destinationsFromBracketIdx(
0,
progressions.roundRobinToSingleElimination,
),
).toEqual([1]);
});
it("returns correct destination (many destinations)", () => {
expect(
Progression.destinationsFromBracketIdx(0, progressions.lowInk),
).toEqual([1, 2]);
});
it("returns an empty array if no destinations", () => {
expect(
Progression.destinationsFromBracketIdx(0, progressions.singleElimination),
).toEqual([]);
});
});

View File

@ -692,3 +692,22 @@ function bracketsReachableFrom(
return result;
}
export function destinationsFromBracketIdx(
sourceBracketIdx: number,
progression: ParsedBracket[],
): number[] {
const destinations: number[] = [];
for (const [destinationBracketIdx, bracket] of progression.entries()) {
if (!bracket.sources) continue;
for (const source of bracket.sources) {
if (source.bracketIdx === sourceBracketIdx) {
destinations.push(destinationBracketIdx);
}
}
}
return destinations;
}

View File

@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, it, test } from "vitest";
import type { Match } from "~/modules/brackets-model";
import { Tournament } from "./Tournament";
import {
@ -7,6 +7,7 @@ import {
PADDLING_POOL_255_TOP_CUT_INITIAL_MATCHES,
PADDLING_POOL_257,
} from "./tests/mocks";
import { SWIM_OR_SINK_167 } from "./tests/mocks-sos";
describe("Follow-up bracket progression", () => {
const tournamentPP257 = new Tournament(PADDLING_POOL_257());
@ -178,3 +179,179 @@ describe("Follow-up bracket progression", () => {
expect(different, "Amount of different matches is incorrect").toBe(2);
});
});
describe("Bracket progression override", () => {
it("handles no override", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167(),
});
expect(tournament.brackets[1].participantTournamentTeamIds).toHaveLength(
11,
);
expect(tournament.brackets[2].participantTournamentTeamIds).toHaveLength(
11,
);
expect(tournament.brackets[3].participantTournamentTeamIds).toHaveLength(
11,
);
expect(tournament.brackets[4].participantTournamentTeamIds).toHaveLength(
11,
);
});
it("overrides causing the team to go to another bracket", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167([
{
tournamentTeamId: 14809,
destinationBracketIdx: 1,
sourceBracketIdx: 0,
},
]),
});
expect(
tournament.brackets[1].participantTournamentTeamIds.includes(14809),
).toBeTruthy();
});
it("overrides causing the team not to go to their original bracket", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167([
{
tournamentTeamId: 14809,
destinationBracketIdx: 1,
sourceBracketIdx: 0,
},
]),
});
expect(
tournament.brackets[2].participantTournamentTeamIds.includes(14809),
).toBeFalsy();
});
it("ignores -1 override (used to indicate no progression)", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167([
{
tournamentTeamId: 14809,
destinationBracketIdx: -1,
sourceBracketIdx: 0,
},
]),
});
expect(tournament.brackets[1].participantTournamentTeamIds).toHaveLength(
11,
);
expect(tournament.brackets[2].participantTournamentTeamIds).toHaveLength(
11,
);
expect(tournament.brackets[3].participantTournamentTeamIds).toHaveLength(
11,
);
expect(tournament.brackets[4].participantTournamentTeamIds).toHaveLength(
11,
);
});
it("override teams seeded at the end", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167([
{
tournamentTeamId: 14809,
destinationBracketIdx: 1,
sourceBracketIdx: 0,
},
]),
});
expect(tournament.brackets[1].seeding?.at(-1)).toBe(14809);
});
it("if redundant override, still in the right bracket", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167([
{
tournamentTeamId: 14809,
destinationBracketIdx: 2,
sourceBracketIdx: 0,
},
]),
});
expect(
tournament.brackets[2].participantTournamentTeamIds.includes(14809),
).toBeTruthy();
});
it("redundants override does not affect the seed", () => {
const tournamentTeamId = 14735;
const tournament = new Tournament({
...SWIM_OR_SINK_167(),
});
const tournamentWOverride = new Tournament({
...SWIM_OR_SINK_167([
{
tournamentTeamId,
destinationBracketIdx: 2,
sourceBracketIdx: 0,
},
]),
});
const seedingIdx =
tournament.brackets[2].seeding?.indexOf(tournamentTeamId);
const seedingIdxWOverride =
tournamentWOverride.brackets[2].seeding?.indexOf(tournamentTeamId);
expect(typeof seedingIdx === "number").toBeTruthy();
expect(seedingIdx).toBe(seedingIdxWOverride);
});
// note there is also logic for avoiding replays
it("override teams seeded according to their placement in the source bracket", () => {
const tournament = new Tournament({
...SWIM_OR_SINK_167([
// throw these to different brackets to avoid replays
{
tournamentTeamId: 14657,
destinationBracketIdx: 2,
sourceBracketIdx: 0,
},
{
tournamentTeamId: 14800,
destinationBracketIdx: 2,
sourceBracketIdx: 0,
},
{
tournamentTeamId: 14743,
destinationBracketIdx: 2,
sourceBracketIdx: 0,
},
// ---
{
tournamentTeamId: 14737,
destinationBracketIdx: 1,
sourceBracketIdx: 0,
},
{
tournamentTeamId: 14809,
destinationBracketIdx: 1,
sourceBracketIdx: 0,
},
{
tournamentTeamId: 14796,
destinationBracketIdx: 1,
sourceBracketIdx: 0,
},
]),
});
expect(tournament.brackets[1].seeding?.at(-3)).toBe(14809);
expect(tournament.brackets[1].seeding?.at(-2)).toBe(14796);
expect(tournament.brackets[1].seeding?.at(-1)).toBe(14737);
});
});

View File

@ -162,7 +162,7 @@ export class Tournament {
);
} else if (type === "swiss") {
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources)
? this.resolveTeamsFromSources(sources, bracketIdx)
: {
teams: this.ctx.teams.map((team) => team.id),
relevantMatchesFinished: true,
@ -209,7 +209,7 @@ export class Tournament {
);
} else {
const { teams, relevantMatchesFinished } = sources
? this.resolveTeamsFromSources(sources)
? this.resolveTeamsFromSources(sources, bracketIdx)
: {
teams: this.ctx.teams.map((team) => team.id),
relevantMatchesFinished: true,
@ -261,23 +261,75 @@ export class Tournament {
private resolveTeamsFromSources(
sources: NonNullable<Progression.ParsedBracket["sources"]>,
bracketIdx: number,
) {
const teams: number[] = [];
let allRelevantMatchesFinished = true;
for (const { bracketIdx, placements } of sources) {
const sourceBracket = this.bracketByIdx(bracketIdx);
for (const source of sources) {
const sourceBracket = this.bracketByIdx(source.bracketIdx);
invariant(sourceBracket, "Bracket not found");
const { teams: sourcedTeams, relevantMatchesFinished } =
sourceBracket.source(placements);
sourceBracket.source(source.placements);
if (!relevantMatchesFinished) {
allRelevantMatchesFinished = false;
}
teams.push(...sourcedTeams);
const excludedOverridenTeams = sourcedTeams.filter(
(teamId) =>
!this.ctx.bracketProgressionOverrides.some(
(override) =>
override.sourceBracketIdx === source.bracketIdx &&
override.tournamentTeamId === teamId &&
// "no progression" override
override.destinationBracketIdx !== -1 &&
// redundant override
override.destinationBracketIdx !== bracketIdx,
),
);
teams.push(...excludedOverridenTeams);
}
return { teams, relevantMatchesFinished: allRelevantMatchesFinished };
const teamsFromOverride: { id: number; sourceBracketIdx: number }[] = [];
for (const source of sources) {
for (const override of this.ctx.bracketProgressionOverrides) {
if (override.sourceBracketIdx !== source.bracketIdx) continue;
if (override.destinationBracketIdx !== bracketIdx) continue;
teamsFromOverride.push({
id: override.tournamentTeamId,
sourceBracketIdx: source.bracketIdx,
});
}
}
const overridesWithoutRepeats = teamsFromOverride
.filter(({ id }) => !teams.includes(id))
.sort((a, b) => {
if (a.sourceBracketIdx !== b.sourceBracketIdx) return 0;
const bracket = this.bracketByIdx(a.sourceBracketIdx);
if (!bracket) return 0;
const aStanding = bracket.standings.find(
(standing) => standing.team.id === a.id,
);
const bStanding = bracket.standings.find(
(standing) => standing.team.id === b.id,
);
if (!aStanding || !bStanding) return 0;
return aStanding.placement - bStanding.placement;
})
.map(({ id }) => id);
return {
teams: teams.concat(overridesWithoutRepeats),
relevantMatchesFinished: allRelevantMatchesFinished,
};
}
private avoidReplaysOfPreviousBracketOpponent(

File diff suppressed because it is too large Load Diff

View File

@ -1981,6 +1981,7 @@ export const PADDLING_POOL_257 = () =>
organization: null,
tags: null,
eventId: 1352,
bracketProgressionOverrides: [],
settings: {
bracketProgression: [
{
@ -7882,6 +7883,7 @@ export const PADDLING_POOL_255 = () =>
organization: null,
tags: null,
eventId: 1286,
bracketProgressionOverrides: [],
settings: {
bracketProgression: [
{
@ -14119,6 +14121,7 @@ export const IN_THE_ZONE_32 = ({
organization: null,
tags: null,
eventId: 1134,
bracketProgressionOverrides: [],
settings: {
bracketProgression: [
{

View File

@ -71,6 +71,7 @@ export const testTournament = ({
isFinalized: 0,
name: "test",
castTwitchAccounts: [],
bracketProgressionOverrides: [],
subCounts: [],
staff: [],
tieBreakerMapPool: [],

View File

@ -29,6 +29,7 @@ import {
import { currentSeason } from "~/features/mmr/season";
import { refreshUserSkills } from "~/features/mmr/tiered.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";
@ -173,6 +174,8 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "ADVANCE_BRACKET": {
validate(tournament.isOrganizer(user));
const bracket = tournament.bracketByIdx(data.bracketIdx);
validate(bracket, "Bracket not found");
validate(bracket.type === "swiss", "Can't advance non-swiss bracket");
@ -187,6 +190,8 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "UNADVANCE_BRACKET": {
validate(tournament.isOrganizer(user));
const bracket = tournament.bracketByIdx(data.bracketIdx);
validate(bracket, "Bracket not found");
validate(bracket.type === "swiss", "Can't unadvance non-swiss bracket");
@ -267,6 +272,33 @@ export const action: ActionFunction = async ({ params, request }) => {
});
break;
}
case "OVERRIDE_BRACKET_PROGRESSION": {
validate(tournament.isOrganizer(user));
const allDestinationBrackets = Progression.destinationsFromBracketIdx(
data.sourceBracketIdx,
tournament.ctx.settings.bracketProgression,
);
validate(
data.destinationBracketIdx === -1 ||
allDestinationBrackets.includes(data.destinationBracketIdx),
"Invalid destination bracket",
);
validate(
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);
}

View File

@ -148,6 +148,12 @@ export const bracketSchema = z.union([
_action: _action("BRACKET_CHECK_IN"),
bracketIdx,
}),
z.object({
_action: _action("OVERRIDE_BRACKET_PROGRESSION"),
tournamentTeamId: id,
sourceBracketIdx: bracketIdx,
destinationBracketIdx: z.union([bracketIdx, z.literal(-1)]),
}),
]);
export const matchPageParamsSchema = z.object({ mid: id });

View File

@ -134,6 +134,20 @@ export async function findById(id: number) {
.where("TournamentSub.tournamentId", "=", id)
.groupBy("TournamentSub.visibility"),
).as("subCounts"),
jsonArrayFrom(
eb
.selectFrom("TournamentBracketProgressionOverride")
.select([
"TournamentBracketProgressionOverride.sourceBracketIdx",
"TournamentBracketProgressionOverride.destinationBracketIdx",
"TournamentBracketProgressionOverride.tournamentTeamId",
])
.whereRef(
"TournamentBracketProgressionOverride.tournamentId",
"=",
"Tournament.id",
),
).as("bracketProgressionOverrides"),
exists(
selectFrom("TournamentResult")
.where("TournamentResult.tournamentId", "=", id)
@ -695,6 +709,29 @@ export function updateProgression({
});
}
export function overrideTeamBracketProgression({
tournamentId,
tournamentTeamId,
sourceBracketIdx,
destinationBracketIdx,
}: {
tournamentId: number;
tournamentTeamId: number;
sourceBracketIdx: number;
destinationBracketIdx: number;
}) {
// set in migration: unique("sourceBracketIdx", "tournamentTeamId") on conflict replace
return db
.insertInto("TournamentBracketProgressionOverride")
.values({
tournamentId,
tournamentTeamId,
sourceBracketIdx,
destinationBracketIdx,
})
.execute();
}
export function updateTeamName({
tournamentTeamId,
name,

Binary file not shown.

View File

@ -0,0 +1,21 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/*sql*/ `
create table "TournamentBracketProgressionOverride" (
"sourceBracketIdx" integer not null,
"destinationBracketIdx" integer not null,
"tournamentTeamId" integer not null,
"tournamentId" integer not null,
unique("sourceBracketIdx", "tournamentTeamId") on conflict replace,
foreign key ("tournamentTeamId") references "TournamentTeam"("id") on delete cascade,
foreign key ("tournamentId") references "Tournament"("id") on delete cascade
) strict
`,
).run();
db.prepare(
/*sql*/ `create index tournament_bracket_progression_override_tournament_id on "TournamentBracketProgressionOverride"("tournamentId")`,
).run();
})();
}