Swiss early advance variation (#2527)
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

This commit is contained in:
Kalle
2025-09-21 09:05:58 +03:00
committed by GitHub
parent ffc0b5a59b
commit 1b6cecf6af
14 changed files with 1066 additions and 100 deletions

View File

@@ -623,6 +623,8 @@ export interface TournamentStageSettings {
groupCount?: number;
// SWISS
roundCount?: number;
/** (Swiss only) Number of wins required for a team to advance early. When set, teams advance at this win count and are eliminated at (roundCount - advanceThreshold + 1) losses. */
advanceThreshold?: number;
}
export const TOURNAMENT_STAGE_TYPES = [

View File

@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { type CalendarEventTag, TOURNAMENT_STAGE_TYPES } from "~/db/tables";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import "~/styles/calendar-new.css";
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
@@ -158,12 +159,30 @@ export const bracketProgressionSchema = z.preprocess(
z.object({
type: z.enum(TOURNAMENT_STAGE_TYPES),
name: z.string().min(1).max(TOURNAMENT.BRACKET_NAME_MAX_LENGTH),
settings: z.object({
thirdPlaceMatch: z.boolean().optional(),
teamsPerGroup: z.number().int().optional(),
groupCount: z.number().int().optional(),
roundCount: z.number().int().optional(),
}),
settings: z
.object({
thirdPlaceMatch: z.boolean().optional(),
teamsPerGroup: z.number().int().optional(),
groupCount: z.number().int().optional(),
roundCount: z.number().int().optional(),
advanceThreshold: z.number().int().optional(),
})
.refine(
(settings) => {
if (settings.advanceThreshold) {
return Swiss.isValidAdvanceThreshold({
roundCount:
settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
advanceThreshold: settings.advanceThreshold,
});
}
return true;
},
{
message: "Invalid advance threshold for the given round count",
path: ["advanceThreshold"],
},
),
requiresCheckIn: z.boolean(),
startTime: z.number().optional(),
sources: z

View File

@@ -10,6 +10,7 @@ import { PlusIcon } from "~/components/icons/Plus";
import { Label } from "~/components/Label";
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import * as Progression from "~/features/tournament-bracket/core/Progression";
import * as Swiss from "~/features/tournament-bracket/core/Swiss";
import { defaultBracketSettings } from "../../tournament/tournament-utils";
const defaultBracket = (): Progression.InputBracket => ({
@@ -98,8 +99,26 @@ export function BracketProgressionSelector({
bracket={bracket}
brackets={brackets}
onChange={(newBracket) => {
const newBrackets = [...brackets];
const newBrackets = structuredClone(brackets);
newBrackets[i] = newBracket;
if (newBracket.settings.advanceThreshold) {
const destinationIdx = newBrackets.findIndex((b) =>
b.sources?.some(
(source) => source.bracketId === newBracket.id,
),
);
if (destinationIdx !== -1) {
newBrackets[destinationIdx].sources = newBrackets[
destinationIdx
].sources?.map((source) => ({
...source,
placements: "",
}));
}
}
setBrackets(newBrackets);
}}
onDelete={
@@ -345,14 +364,26 @@ function TournamentFormatBracketSelector({
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT
}
onChange={(e) =>
onChange={(e) => {
const newRoundCount = Number(e.target.value);
const currentAdvanceThreshold =
bracket.settings.advanceThreshold;
updateBracket({
settings: {
...bracket.settings,
roundCount: Number(e.target.value),
roundCount: newRoundCount,
advanceThreshold:
currentAdvanceThreshold &&
!Swiss.isValidAdvanceThreshold({
roundCount: newRoundCount,
advanceThreshold: currentAdvanceThreshold,
})
? 3
: currentAdvanceThreshold,
},
})
}
});
}}
className="w-max"
name="swissRoundCount"
id="swissRoundCount"
@@ -368,6 +399,74 @@ function TournamentFormatBracketSelector({
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor={createId("earlyAdvance")}>
Early advance/elimination
</Label>
<SendouSwitch
id={createId("earlyAdvance")}
isSelected={Boolean(bracket.settings.advanceThreshold)}
onChange={(isSelected) =>
updateBracket({
settings: {
...bracket.settings,
advanceThreshold: isSelected ? 3 : undefined,
},
})
}
isDisabled={bracket.disabled}
/>
<FormMessage type="info">
Teams stop playing once they reach required wins or exceed maximum
losses
</FormMessage>
</div>
) : null}
{bracket.type === "swiss" && bracket.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("advanceThreshold")}>
Wins needed to advance
</Label>
<select
value={bracket.settings.advanceThreshold}
onChange={(e) => {
const newThreshold = Number(e.target.value);
updateBracket({
settings: {
...bracket.settings,
advanceThreshold: newThreshold,
},
});
}}
className="w-max"
name="advanceThreshold"
id={createId("advanceThreshold")}
disabled={bracket.disabled}
>
{Swiss.validAdvanceThresholdOptions({
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
}).map((threshold) => (
<option key={threshold} value={threshold}>
{threshold}
</option>
))}
</select>
<FormMessage type="info">
Maximum losses allowed:{" "}
{Swiss.eliminationThreshold({
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
advanceThreshold: bracket.settings.advanceThreshold,
}) - 1}
</FormMessage>
</div>
) : null}
<div>
<div className="stack horizontal sm">
<Label htmlFor={createId("source")}>Source</Label>{" "}
@@ -429,6 +528,8 @@ function SourcesSelector({
return `${id}-${label}`;
};
const inputBracket = brackets.find((b) => b.id === source?.bracketId);
return (
<div className="stack horizontal sm items-end">
<div>
@@ -447,22 +548,24 @@ function SourcesSelector({
))}
</select>
</div>
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
{!inputBracket?.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
) : null}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { Link, useFetcher } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import invariant from "~/utils/invariant";
import { SendouButton } from "../../../../components/elements/Button";
import { CheckmarkIcon } from "../../../../components/icons/Checkmark";
import { CrossIcon } from "../../../../components/icons/Cross";
@@ -8,8 +9,10 @@ import { EditIcon } from "../../../../components/icons/Edit";
import { logger } from "../../../../utils/logger";
import { tournamentTeamPage } from "../../../../utils/urls";
import { useUser } from "../../../auth/core/user";
import { TOURNAMENT } from "../../../tournament/tournament-constants";
import type { Bracket } from "../../core/Bracket";
import * as Progression from "../../core/Progression";
import * as Swiss from "../../core/Swiss";
export function PlacementsTable({
groupId,
@@ -73,8 +76,28 @@ export function PlacementsTable({
return a.placement - b.placement;
});
const destinationBracket = (placement: number) =>
bracket.tournament.brackets.find(
const destinationBracket = (placement: number) => {
if (bracket.type === "swiss" && bracket.settings?.advanceThreshold) {
const standing = standings[placement - 1];
const stats = standing.stats;
invariant(stats);
return Swiss.calculateTeamStatus({
advanceThreshold: bracket.settings.advanceThreshold,
losses: stats.setLosses,
wins: stats.setWins,
roundCount:
bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
}) === "advanced"
? bracket.tournament.brackets.find((otherBracket) =>
otherBracket.sources?.some(
(source) => source.bracketIdx === bracket.idx,
),
)
: undefined;
}
return bracket.tournament.brackets.find(
(b) =>
b.idx ===
Progression.destinationByPlacement({
@@ -83,6 +106,7 @@ export function PlacementsTable({
progression: bracket.tournament.ctx.settings.bracketProgression,
}),
);
};
const possibleDestinationBrackets = Progression.destinationsFromBracketIdx(
bracket.idx,
@@ -102,6 +126,9 @@ export function PlacementsTable({
);
})();
let qualifiedRowRendered = false;
let eliminatedRowRendered = false;
return (
<table className="rr__placements-table" cellSpacing={0}>
<thead>
@@ -175,70 +202,122 @@ export function PlacementsTable({
return overridenDestinationBracket?.idx;
};
const renderQualifiedRow =
!qualifiedRowRendered &&
bracket.settings?.advanceThreshold &&
s.stats &&
s.stats.setWins < bracket.settings.advanceThreshold;
const renderEliminatedRow =
!eliminatedRowRendered &&
bracket.settings?.advanceThreshold &&
s.stats &&
Swiss.calculateTeamStatus({
advanceThreshold: bracket.settings.advanceThreshold,
losses: s.stats.setLosses,
wins: s.stats.setWins,
roundCount:
bracket.settings.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
}) === "eliminated";
if (renderQualifiedRow) qualifiedRowRendered = true;
if (renderEliminatedRow) eliminatedRowRendered = true;
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
<React.Fragment key={s.team.id}>
{renderQualifiedRow ? (
<SwissDividerRow
key="qualified"
type="qualified"
threshold={bracket.settings!.advanceThreshold!}
// TODO: get columns from the tiebreakers array
columnCount={8 + (canEditDestination ? 1 : 0)}
/>
) : null}
{renderEliminatedRow ? (
<SwissDividerRow
key="eliminated"
type="eliminated"
threshold={bracket.settings!.advanceThreshold!}
// TODO: get columns from the tiebreakers array
columnCount={8 + (canEditDestination ? 1 : 0)}
/>
) : null}
<tr>
<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}
{bracket.type === "swiss" ? (
<td>
<span>{stats.opponentSetWinPercentage?.toFixed(2)}</span>
</td>
) : null}
</td>
<td>
<span>
{stats.setWins}/{stats.setLosses}
</span>
</td>
{bracket.type === "round_robin" ? (
<td>
<span>{stats.winsAgainstTied}</span>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
{bracket.type === "swiss" ? (
<td>
<span>{stats.opponentMapWinPercentage?.toFixed(2)}</span>
</td>
) : null}
{bracket.type === "round_robin" ? (
<td>
<span>{stats.points}</span>
</td>
) : null}
<td>{team?.seed}</td>
<EditableDestination
key={key()}
source={bracket}
destination={dest}
overridenDestination={overridenDestinationBracket}
possibleDestinations={possibleDestinationBrackets}
allMatchesFinished={allMatchesFinished}
canEditDestination={canEditDestination}
tournamentTeamId={s.team.id}
/>
</tr>
{!eliminatedRowRendered &&
i === standings.length - 1 &&
bracket.settings?.advanceThreshold ? (
<SwissDividerRow
key="eliminated"
type="eliminated"
threshold={bracket.settings.advanceThreshold}
// TODO: get columns from the tiebreakers array
columnCount={8 + (canEditDestination ? 1 : 0)}
/>
) : null}
{bracket.type === "swiss" ? (
<td>
<span>{(stats.lossesAgainstTied ?? 0) * -1}</span>
</td>
) : null}
{bracket.type === "swiss" ? (
<td>
<span>{stats.opponentSetWinPercentage?.toFixed(2)}</span>
</td>
) : null}
<td>
<span>
{stats.mapWins}/{stats.mapLosses}
</span>
</td>
{bracket.type === "swiss" ? (
<td>
<span>{stats.opponentMapWinPercentage?.toFixed(2)}</span>
</td>
) : null}
{bracket.type === "round_robin" ? (
<td>
<span>{stats.points}</span>
</td>
) : null}
<td>{team?.seed}</td>
<EditableDestination
key={key()}
source={bracket}
destination={dest}
overridenDestination={overridenDestinationBracket}
possibleDestinations={possibleDestinationBrackets}
allMatchesFinished={allMatchesFinished}
canEditDestination={canEditDestination}
tournamentTeamId={s.team.id}
/>
</tr>
</React.Fragment>
);
})}
</tbody>
@@ -356,3 +435,35 @@ function EditableDestination({
</>
);
}
function SwissDividerRow({
type,
threshold,
columnCount,
}: {
type: "qualified" | "eliminated";
threshold: number;
columnCount: number;
}) {
const isQualified = type === "qualified";
const message = isQualified
? `Qualified (@ ${threshold} wins)`
: `Eliminated (@ ${threshold} losses)`;
return (
<tr className="tournament__standings__divider-row">
<td colSpan={columnCount} className="tournament__standings__divider">
<div
className={clsx("tournament__standings__divider-content", {
"tournament__standings__divider--qualified": isQualified,
"tournament__standings__divider--eliminated": !isQualified,
})}
>
<div className="tournament__standings__divider-line" />
<span className="tournament__standings__divider-text">{message}</span>
<div className="tournament__standings__divider-line" />
</div>
</td>
</tr>
);
}

View File

@@ -11,6 +11,7 @@ import { cutToNDecimalPlaces } from "../../../utils/number";
import { fillWithNullTillPowerOfTwo } from "../tournament-bracket-utils";
import { getTournamentManager } from "./brackets-manager";
import * as Progression from "./Progression";
import { calculateTeamStatus } from "./Swiss";
import type { OptionalIdObject, Tournament } from "./Tournament";
import type { TournamentDataTeam } from "./Tournament.server";
import type { BracketMapCounts } from "./toMapList";
@@ -372,7 +373,7 @@ export abstract class Bracket {
return this.teamsPendingCheckIn.includes(team.id);
}
source(_placements: number[]): {
source(_options: { placements: number[]; advanceThreshold?: number }): {
relevantMatchesFinished: boolean;
teams: number[];
} {
@@ -789,7 +790,8 @@ class DoubleEliminationBracket extends Bracket {
return true;
}
source(placements: number[]) {
source({ placements }: { placements: number[] }) {
invariant(placements.length > 0, "Empty placements not supported");
const resolveLosersGroupId = (data: TournamentManagerDataSet) => {
const minGroupId = Math.min(...data.round.map((round) => round.group_id));
@@ -881,10 +883,11 @@ class RoundRobinBracket extends Bracket {
return true;
}
source(placements: number[]): {
source({ placements }: { placements: number[] }): {
relevantMatchesFinished: boolean;
teams: number[];
} {
invariant(placements.length > 0, "Empty placements not supported");
if (placements.some((p) => p < 0)) {
throw new Error("Negative placements not implemented");
}
@@ -1150,14 +1153,25 @@ class SwissBracket extends Bracket {
return false;
}
source(placements: number[]): {
source({
placements,
advanceThreshold,
}: {
placements: number[];
advanceThreshold?: number;
}): {
relevantMatchesFinished: boolean;
teams: number[];
} {
invariant(
advanceThreshold || placements.length > 0,
"Placements or advanceThreshold required",
);
if (placements.some((p) => p < 0)) {
throw new Error("Negative placements not implemented");
}
const standings = this.standings;
const relevantMatchesFinished = this.data.round.every((round) => {
const roundsMatches = this.data.match.filter(
(match) => match.round_id === round.id,
@@ -1180,6 +1194,27 @@ class SwissBracket extends Bracket {
});
});
if (advanceThreshold) {
return {
relevantMatchesFinished,
teams: standings
.map((standing) => ({
...standing,
status: calculateTeamStatus({
advanceThreshold,
wins: standing.stats?.setWins ?? 0,
losses: standing.stats?.setLosses ?? 0,
roundCount:
this.settings?.roundCount ??
TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT,
}),
}))
.filter((t) => t.status === "advanced")
.map((t) => t.team.id),
};
}
// Standard Swiss logic without early advance/elimination
const uniquePlacements = R.unique(standings.map((s) => s.placement));
// 1,3,5 -> 1,2,3 e.g.

View File

@@ -173,6 +173,89 @@ describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => {
expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
});
it("allows empty string placements for Swiss brackets with early advance", () => {
const result = Progression.validatedBrackets([
{
id: "1",
name: "Swiss Bracket",
type: "swiss",
settings: {
advanceThreshold: 3,
},
requiresCheckIn: false,
},
{
id: "2",
name: "Final Bracket",
type: "single_elimination",
settings: {},
requiresCheckIn: false,
sources: [
{
bracketId: "1",
placements: "",
},
],
},
]) as Progression.ParsedBracket[];
expect(result[1].sources).toEqual([{ bracketIdx: 0, placements: [] }]);
});
it("parsing fails with empty string placements for non-Swiss brackets", () => {
const error = Progression.validatedBrackets([
{
id: "1",
name: "Round Robin Bracket",
type: "round_robin",
settings: {},
requiresCheckIn: false,
},
{
id: "2",
name: "Final Bracket",
type: "single_elimination",
settings: {},
requiresCheckIn: false,
sources: [
{
bracketId: "1",
placements: "",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
});
it("parsing fails with empty string placements for Swiss brackets without early advance", () => {
const error = Progression.validatedBrackets([
{
id: "1",
name: "Swiss Bracket",
type: "swiss",
settings: {},
requiresCheckIn: false,
},
{
id: "2",
name: "Final Bracket",
type: "single_elimination",
settings: {},
requiresCheckIn: false,
sources: [
{
bracketId: "1",
placements: "",
},
],
},
]) as Progression.ValidationError;
expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
});
});
const getValidatedBrackets = (
@@ -446,6 +529,46 @@ describe("validatedSources - other rules", () => {
expect(error.type).toBe("NO_DE_POSITIVE");
expect((error as any).bracketIdx).toEqual(1);
});
it("handles SWISS_EARLY_ADVANCE_NO_DESTINATION", () => {
// Swiss bracket with early advance but no destination
const error = getValidatedBrackets([
{
settings: {
advanceThreshold: 3,
},
type: "swiss",
},
]) as Progression.ValidationError;
expect(error.type).toBe("SWISS_EARLY_ADVANCE_NO_DESTINATION");
expect((error as any).bracketIdx).toEqual(0);
});
it("allows Swiss early advance when bracket has destination", () => {
// Swiss bracket with early advance that leads to another bracket should be valid
const result = getValidatedBrackets([
{
settings: {
advanceThreshold: 3,
},
type: "swiss",
},
{
settings: {},
type: "single_elimination",
sources: [
{
bracketId: "0",
placements: "1-4",
},
],
},
]);
// Should be valid (no error returned)
expect(Array.isArray(result)).toBe(true);
});
});
describe("isFinals", () => {
@@ -469,6 +592,10 @@ describe("isFinals", () => {
expect(Progression.isFinals(3, progressions.lowInk)).toBe(true);
});
it("handles swiss (early advance", () => {
expect(Progression.isFinals(1, progressions.swissEarlyAdvance)).toBe(true);
});
it("many starter brackets", () => {
expect(Progression.isFinals(0, progressions.manyStartBrackets)).toBe(false);
expect(Progression.isFinals(1, progressions.manyStartBrackets)).toBe(false);
@@ -653,3 +780,37 @@ describe("destinationByPlacement", () => {
expect(result).toBe(3);
});
});
describe("validatedBracketsToInputFormat", () => {
it("converts empty placements back to empty string", () => {
const parsedBrackets: Progression.ParsedBracket[] = [
{
type: "swiss",
settings: { advanceThreshold: 3 },
name: "Swiss Bracket",
requiresCheckIn: false,
},
{
type: "single_elimination",
settings: {},
name: "Final Bracket",
requiresCheckIn: false,
sources: [
{
bracketIdx: 0,
placements: [],
},
],
},
];
const result = Progression.validatedBracketsToInputFormat(parsedBrackets);
expect(result[1].sources).toEqual([
{
bracketId: "0",
placements: "",
},
]);
});
});

View File

@@ -10,7 +10,7 @@ import invariant from "../../../utils/invariant";
export interface DBSource {
/** Index of the bracket where the teams come from */
bracketIdx: number;
/** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. */
/** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. Can be empty array for Swiss brackets with early advance. */
placements: number[];
}
@@ -92,6 +92,11 @@ export type ValidationError =
| {
type: "NO_DE_POSITIVE";
bracketIdx: number;
}
// Swiss bracket with early advance/elimination must have a destination bracket
| {
type: "SWISS_EARLY_ADVANCE_NO_DESTINATION";
bracketIdx: number;
};
/** Takes validated brackets and returns them in the format that is ready for user input. */
@@ -110,7 +115,10 @@ export function validatedBracketsToInputFormat(
: undefined,
sources: bracket.sources?.map((source) => ({
bracketId: String(source.bracketIdx),
placements: placementsToString(source.placements),
placements:
source.placements.length > 0
? placementsToString(source.placements)
: "",
})),
};
});
@@ -258,6 +266,14 @@ export function bracketsToValidationError(
};
}
faultyBracketIdx = swissEarlyAdvanceWithoutDestination(brackets);
if (typeof faultyBracketIdx === "number") {
return {
type: "SWISS_EARLY_ADVANCE_NO_DESTINATION",
bracketIdx: faultyBracketIdx,
};
}
return null;
}
@@ -273,13 +289,26 @@ function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] {
: undefined,
sources: bracket.sources?.map((source) => {
const placements = parsePlacements(source.placements);
if (!placements) {
const sourceBracketIdx = brackets.findIndex(
(b) => b.id === source.bracketId,
);
const sourceBracket = brackets[sourceBracketIdx];
// Allow empty placements only for Swiss brackets with early advance
if (placements && placements.length === 0) {
const isSwissWithEarlyAdvance =
sourceBracket?.type === "swiss" &&
sourceBracket?.settings?.advanceThreshold;
if (!isSwissWithEarlyAdvance) {
throw { badBracketIdx: bracketIdx };
}
} else if (placements === null) {
throw { badBracketIdx: bracketIdx };
}
return {
bracketIdx: brackets.findIndex((b) => b.id === source.bracketId),
placements,
bracketIdx: sourceBracketIdx,
placements: placements ?? [],
};
}),
};
@@ -298,6 +327,11 @@ function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] {
}
function parsePlacements(placements: string) {
// Handle empty string case
if (placements.trim() === "") {
return [];
}
const parts = placements.split(",");
const result: number[] = [];
@@ -516,6 +550,24 @@ function noDoubleEliminationPositive(brackets: ParsedBracket[]) {
return null;
}
function swissEarlyAdvanceWithoutDestination(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
if (bracket.type === "swiss" && bracket.settings.advanceThreshold) {
const hasDestination = brackets.some((otherBracket) =>
otherBracket.sources?.some(
(source) => source.bracketIdx === bracketIdx,
),
);
if (!hasDestination) {
return bracketIdx;
}
}
}
return null;
}
/** Takes the return type of `Progression.validatedBrackets` as an input and narrows the type to a successful validation */
export function isBrackets(
input: ParsedBracket[] | ValidationError,
@@ -555,7 +607,8 @@ function resolveMainBracketProgression(brackets: ParsedBracket[]) {
const bracket = brackets.findIndex((bracket) =>
bracket.sources?.some(
(source) =>
source.placements.includes(1) &&
// empty array is the swiss early advance case
(source.placements.includes(1) || source.placements.length === 0) &&
source.bracketIdx === bracketIdxToFind,
),
);

View File

@@ -262,4 +262,271 @@ describe("Swiss", () => {
},
);
});
describe("calculateTeamStatus()", () => {
it("returns 'advanced' when team has enough wins", () => {
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 0,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 4,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
});
it("returns 'eliminated' when team has too many losses", () => {
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 1,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
});
it("returns 'active' when team can still advance or be eliminated", () => {
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 2,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
expect(
Swiss.calculateTeamStatus({
wins: 1,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 0,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 1,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("active");
});
it("handles different tournament configurations", () => {
// 4-round tournament with advance threshold 2
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 0,
advanceThreshold: 2,
roundCount: 4,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 3,
advanceThreshold: 2,
roundCount: 4,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 1,
losses: 2,
advanceThreshold: 2,
roundCount: 4,
}),
).toBe("active");
// 6-round tournament with advance threshold 4
expect(
Swiss.calculateTeamStatus({
wins: 4,
losses: 1,
advanceThreshold: 4,
roundCount: 6,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 3,
advanceThreshold: 4,
roundCount: 6,
}),
).toBe("eliminated");
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 2,
advanceThreshold: 4,
roundCount: 6,
}),
).toBe("active");
});
it("handles edge cases correctly", () => {
// Team reaches advance threshold exactly
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 2,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("advanced");
// Team reaches elimination threshold exactly
expect(
Swiss.calculateTeamStatus({
wins: 2,
losses: 3,
advanceThreshold: 3,
roundCount: 5,
}),
).toBe("eliminated");
// Tournament where advance threshold equals round count
expect(
Swiss.calculateTeamStatus({
wins: 3,
losses: 0,
advanceThreshold: 3,
roundCount: 3,
}),
).toBe("advanced");
expect(
Swiss.calculateTeamStatus({
wins: 0,
losses: 3,
advanceThreshold: 3,
roundCount: 3,
}),
).toBe("eliminated");
});
});
describe("Threshold validation utilities", () => {
describe("maxAdvanceThreshold()", () => {
it("calculates maximum advance threshold correctly", () => {
expect(Swiss.maxAdvanceThreshold({ roundCount: 3 })).toBe(3); // ceil(3/2) + 1 = 2 + 1 = 3
expect(Swiss.maxAdvanceThreshold({ roundCount: 4 })).toBe(3); // ceil(4/2) + 1 = 2 + 1 = 3
expect(Swiss.maxAdvanceThreshold({ roundCount: 5 })).toBe(4); // ceil(5/2) + 1 = 3 + 1 = 4
expect(Swiss.maxAdvanceThreshold({ roundCount: 6 })).toBe(4); // ceil(6/2) + 1 = 3 + 1 = 4
expect(Swiss.maxAdvanceThreshold({ roundCount: 7 })).toBe(5); // ceil(7/2) + 1 = 4 + 1 = 5
});
});
describe("isValidAdvanceThreshold()", () => {
it("validates correct thresholds", () => {
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 3 }),
).toBe(true);
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 4, advanceThreshold: 2 }),
).toBe(true);
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 6, advanceThreshold: 4 }),
).toBe(true);
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 3, advanceThreshold: 2 }),
).toBe(true);
});
it("rejects invalid thresholds", () => {
// Threshold too high
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 5 }),
).toBe(false); // equals round count
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 6 }),
).toBe(false); // exceeds round count
// Threshold too low
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 0 }),
).toBe(false);
expect(
Swiss.isValidAdvanceThreshold({
roundCount: 3,
advanceThreshold: -1,
}),
).toBe(false);
});
it("handles edge cases", () => {
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 3, advanceThreshold: 2 }),
).toBe(true); // minimum valid
expect(
Swiss.isValidAdvanceThreshold({ roundCount: 5, advanceThreshold: 4 }),
).toBe(true); // maximum valid for 5 rounds
});
});
describe("validAdvanceThresholdOptions()", () => {
it("returns correct options for different round counts", () => {
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 3 })).toEqual([
2, 3,
]);
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 5 })).toEqual([
2, 3, 4,
]);
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 8 })).toEqual([
2, 3, 4, 5,
]);
});
it("handles minimal round counts", () => {
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 2 })).toEqual([
2,
]);
expect(Swiss.validAdvanceThresholdOptions({ roundCount: 1 })).toEqual([
2,
]);
});
});
});
});

View File

@@ -197,10 +197,35 @@ export function generateMatchUps({
});
// teams who have dropped out are not considered
const standingsWithoutDropouts = groupsStandings.filter(
let standingsWithoutDropouts = groupsStandings.filter(
(s) => !s.team.droppedOut,
);
// filter out teams that have advanced or been eliminated if early advance/elimination is enabled
if (typeof bracket.settings?.advanceThreshold === "number") {
const roundCount =
bracket.settings.roundCount ?? TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT;
const advanceThreshold = bracket.settings.advanceThreshold;
standingsWithoutDropouts = standingsWithoutDropouts.filter((standing) => {
const wins = standing.stats?.setWins ?? 0;
const losses = standing.stats?.setLosses ?? 0;
const status = calculateTeamStatus({
wins,
losses,
advanceThreshold,
roundCount,
});
return status === "active";
});
}
// if there are fewer than 2 active teams, no more matches can be generated
if (standingsWithoutDropouts.length < 2) {
return err("Not enough active teams to generate matches");
}
const teamsThatHaveHadByes = groupsMatches
.filter((m) => m.opponent2 === null)
.map((m) => m.opponent1?.id);
@@ -263,6 +288,13 @@ interface SwissPairingTeam {
// adapted from https://github.com/slashinfty/tournament-pairings
export function pairUp(players: SwissPairingTeam[]) {
if (players.length < 2) {
throw new Error("Need at least two players to pair up");
}
if (players.length === 2) {
return [{ opponentOne: players[0].id, opponentTwo: players[1].id }];
}
// uncomment to add a new test case to PAIR_UP_TEST_CASES
// console.log(players);
@@ -377,3 +409,98 @@ export function pairUp(players: SwissPairingTeam[]) {
return matches;
}
export type SwissTeamStatus = "active" | "advanced" | "eliminated";
/**
* Calculates whether a team should advance, be eliminated, or remain active
* in a Swiss tournament with early advance/elimination rules.
*
* @returns The team's status: "advanced" if they've secured advancement,
* "eliminated" if they can no longer mathematically advance, or "active" if still competing
*
* @example
* // In a 5-round Swiss where teams need 3 wins to advance:
* calculateTeamStatus({ wins: 3, losses: 1, advanceThreshold: 3, roundCount: 5 }) // "advanced"
* calculateTeamStatus({ wins: 2, losses: 3, advanceThreshold: 3, roundCount: 5 }) // "eliminated"
* calculateTeamStatus({ wins: 2, losses: 2, advanceThreshold: 3, roundCount: 5 }) // "active"
*/
export function calculateTeamStatus({
wins,
losses,
advanceThreshold,
roundCount,
}: {
/** Number of matches the team has won */
wins: number;
/** Number of matches the team has lost */
losses: number;
/** Number of wins required to advance to the next stage */
advanceThreshold: number;
/** Total number of rounds in the Swiss stage */
roundCount: number;
}): SwissTeamStatus {
if (wins >= advanceThreshold) {
return "advanced";
}
if (losses >= eliminationThreshold({ roundCount, advanceThreshold })) {
return "eliminated";
}
return "active";
}
/**
* Calculates the maximum valid advance threshold for a given round count.
* The threshold must allow for meaningful play - teams need a chance to both advance and be eliminated.
*/
export function maxAdvanceThreshold({ roundCount }: { roundCount: number }) {
return Math.ceil(roundCount / 2) + 1;
}
/**
* Calculates the maximum losses allowed before elimination given an advance threshold and round count.
*/
export function eliminationThreshold({
roundCount,
advanceThreshold,
}: {
roundCount: number;
advanceThreshold: number;
}) {
return roundCount - advanceThreshold + 1;
}
/**
* Validates if an advance threshold is valid for the given round count.
*/
export function isValidAdvanceThreshold({
roundCount,
advanceThreshold,
}: {
roundCount: number;
advanceThreshold: number;
}) {
return validAdvanceThresholdOptions({ roundCount }).includes(
advanceThreshold,
);
}
/**
* Returns a list of valid advance threshold options for a given round count.
* Starts from 2 wins minimum up to the calculated maximum.
*/
export function validAdvanceThresholdOptions({
roundCount,
}: {
roundCount: number;
}) {
const result: number[] = [];
for (let i = 2; i <= Math.min(maxAdvanceThreshold({ roundCount }), 5); i++) {
result.push(i);
}
return result;
}

View File

@@ -272,7 +272,10 @@ export class Tournament {
invariant(sourceBracket, "Bracket not found");
const { teams: sourcedTeams, relevantMatchesFinished } =
sourceBracket.source(source.placements);
sourceBracket.source({
placements: source.placements,
advanceThreshold: sourceBracket.settings?.advanceThreshold,
});
if (!relevantMatchesFinished) {
allRelevantMatchesFinished = false;
}

View File

@@ -266,6 +266,26 @@ export const progressions = {
},
},
],
swissEarlyAdvance: [
{
...DEFAULT_PROGRESSION_ARGS,
type: "swiss",
settings: {
advanceThreshold: 3,
},
},
{
...DEFAULT_PROGRESSION_ARGS,
type: "single_elimination",
name: "B1",
sources: [
{
bracketIdx: 0,
placements: [],
},
],
},
],
doubleEliminationWithUnderground: [
{
...DEFAULT_PROGRESSION_ARGS,

View File

@@ -134,6 +134,15 @@ export default function TournamentBracketsPage() {
)} rounds of the losers bracket can play in this bracket`;
}
const advanceThreshold = tournament.brackets[0].settings?.advanceThreshold;
if (
advanceThreshold &&
tournament.ctx.settings.bracketProgression[bracketIdx].sources?.[0]
.placements.length === 0
) {
return `Teams that win at least ${advanceThreshold} sets in the Swiss bracket will advance to this stage`;
}
return null;
};

View File

@@ -694,3 +694,58 @@
width: 1rem;
margin-block-end: 1px;
}
.tournament__standings__divider-row {
background: transparent !important;
height: auto;
}
.tournament__standings__divider {
padding-block: var(--s-2);
background: transparent;
}
.tournament__standings__divider-content {
display: flex;
align-items: center;
width: 100%;
gap: var(--s-2);
}
.tournament__standings__divider-line {
flex: 1;
height: 4px;
border-radius: var(--rounded);
}
.tournament__standings__divider-text {
font-size: var(--fonts-xs);
font-weight: var(--bold);
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
padding: var(--s-0-5) var(--s-1-5);
border-radius: var(--rounded);
}
.tournament__standings__divider--qualified
.tournament__standings__divider-line {
background-color: var(--theme-success);
}
.tournament__standings__divider--qualified
.tournament__standings__divider-text {
color: var(--theme-success);
background-color: transparent;
}
.tournament__standings__divider--eliminated
.tournament__standings__divider-line {
background-color: var(--theme-error);
}
.tournament__standings__divider--eliminated
.tournament__standings__divider-text {
color: var(--theme-error);
background-color: transparent;
}

View File

@@ -137,5 +137,6 @@
"progression.error.NAME_MISSING": "Bracket name missing",
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
"progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket",
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression"
"progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket"
}