mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
Swiss early advance variation (#2527)
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user