From 1b6cecf6afb403932a4c2fc72741c22e880f154e Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 21 Sep 2025 09:05:58 +0300 Subject: [PATCH] Swiss early advance variation (#2527) --- app/db/tables.ts | 2 + app/features/calendar/calendar-schemas.ts | 31 +- .../components/BracketProgressionSelector.tsx | 145 ++++++++-- .../components/Bracket/PlacementsTable.tsx | 231 +++++++++++---- .../tournament-bracket/core/Bracket.ts | 43 ++- .../core/Progression.test.ts | 161 +++++++++++ .../tournament-bracket/core/Progression.ts | 65 ++++- .../tournament-bracket/core/Swiss.test.ts | 267 ++++++++++++++++++ app/features/tournament-bracket/core/Swiss.ts | 129 ++++++++- .../tournament-bracket/core/Tournament.ts | 5 +- .../core/tests/test-utils.ts | 20 ++ .../routes/to.$id.brackets.tsx | 9 + app/features/tournament/tournament.css | 55 ++++ locales/en/tournament.json | 3 +- 14 files changed, 1066 insertions(+), 100 deletions(-) diff --git a/app/db/tables.ts b/app/db/tables.ts index b7cef6392..682c08b96 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -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 = [ diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index 6b574ac8a..144a52e43 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -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 diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx index 16a0e6f7f..a850c6fbf 100644 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -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({ ) : null} + {bracket.type === "swiss" ? ( +
+ + + updateBracket({ + settings: { + ...bracket.settings, + advanceThreshold: isSelected ? 3 : undefined, + }, + }) + } + isDisabled={bracket.disabled} + /> + + Teams stop playing once they reach required wins or exceed maximum + losses + +
+ ) : null} + + {bracket.type === "swiss" && bracket.settings.advanceThreshold ? ( +
+ + + + Maximum losses allowed:{" "} + {Swiss.eliminationThreshold({ + roundCount: + bracket.settings.roundCount ?? + TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + advanceThreshold: bracket.settings.advanceThreshold, + }) - 1} + +
+ ) : null} +
{" "} @@ -429,6 +528,8 @@ function SourcesSelector({ return `${id}-${label}`; }; + const inputBracket = brackets.find((b) => b.id === source?.bracketId); + return (
@@ -447,22 +548,24 @@ function SourcesSelector({ ))}
-
- - - onChange({ - bracketId: brackets[0].id, - ...source, - placements: e.target.value, - }) - } - /> -
+ {!inputBracket?.settings.advanceThreshold ? ( +
+ + + onChange({ + bracketId: brackets[0].id, + ...source, + placements: e.target.value, + }) + } + /> +
+ ) : null}
); } diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index c630f15c4..f635335ea 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -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 ( @@ -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 ( - - + + + {bracket.type === "round_robin" ? ( + + ) : null} + {bracket.type === "swiss" ? ( + + ) : null} + {bracket.type === "swiss" ? ( + ) : null} - - - {bracket.type === "round_robin" ? ( + {bracket.type === "swiss" ? ( + + ) : null} + {bracket.type === "round_robin" ? ( + + ) : null} + + + + {!eliminatedRowRendered && + i === standings.length - 1 && + bracket.settings?.advanceThreshold ? ( + ) : null} - {bracket.type === "swiss" ? ( - - ) : null} - {bracket.type === "swiss" ? ( - - ) : null} - - {bracket.type === "swiss" ? ( - - ) : null} - {bracket.type === "round_robin" ? ( - - ) : null} - - - + ); })} @@ -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 ( + + + + ); +} diff --git a/app/features/tournament-bracket/core/Bracket.ts b/app/features/tournament-bracket/core/Bracket.ts index 6050a8256..514a06e79 100644 --- a/app/features/tournament-bracket/core/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket.ts @@ -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. diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 4c787ab38..a9cacd296 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -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: "", + }, + ]); + }); +}); diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index d7afe12ac..812037a9a 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -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, ), ); diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index 4b2984a4f..da483d831 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -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, + ]); + }); + }); + }); }); diff --git a/app/features/tournament-bracket/core/Swiss.ts b/app/features/tournament-bracket/core/Swiss.ts index aef0b6381..8407f5b11 100644 --- a/app/features/tournament-bracket/core/Swiss.ts +++ b/app/features/tournament-bracket/core/Swiss.ts @@ -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; +} diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index cb818040d..574d373b5 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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; } diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 1052d2877..b6a4ddb1d 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -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, diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index ddc45f21e..5c3dbd65b 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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; }; diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index 7d6c4b5c2..d75aaf988 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -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; +} diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 8cbc78e34..154cc8911 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -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" }
- - {s.team.name}{" "} - - {s.team.droppedOut ? ( - - Drop-out + + {renderQualifiedRow ? ( + + ) : null} + {renderEliminatedRow ? ( + + ) : null} +
+ + {s.team.name}{" "} + + {s.team.droppedOut ? ( + + Drop-out + + ) : null} + + + {stats.setWins}/{stats.setLosses} + + {stats.winsAgainstTied} + + {(stats.lossesAgainstTied ?? 0) * -1} + + {stats.opponentSetWinPercentage?.toFixed(2)} + - - {stats.setWins}/{stats.setLosses} - - - {stats.winsAgainstTied} + + {stats.mapWins}/{stats.mapLosses} + + {stats.opponentMapWinPercentage?.toFixed(2)} + + {stats.points} + {team?.seed}
- {(stats.lossesAgainstTied ?? 0) * -1} - - {stats.opponentSetWinPercentage?.toFixed(2)} - - - {stats.mapWins}/{stats.mapLosses} - - - {stats.opponentMapWinPercentage?.toFixed(2)} - - {stats.points} - {team?.seed}
+
+
+ {message} +
+
+