From 0b005ce30a8ecdf9b8dcbe64af8c00581a0890a7 Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Thu, 6 Aug 2026 17:36:42 +0300
Subject: [PATCH] Many source brackets (#3312)
---
.../calendar-progression-form.test.ts | 69 ++-
.../calendar/calendar-progression-form.ts | 111 ++--
.../BracketProgressionFormFields.tsx | 136 ++++-
.../tournament-bracket/core/Bracket.test.ts | 168 ++++++
.../core/Bracket/Bracket.ts | 27 +
.../core/Bracket/DoubleEliminationBracket.ts | 17 +-
.../core/Bracket/SingleEliminationBracket.ts | 11 +-
.../core/Progression.test.ts | 468 +++++++++++++++-
.../tournament-bracket/core/Progression.ts | 517 ++++++++++++++----
.../tournament-bracket/core/Tournament.ts | 12 +-
.../core/tests/test-utils.ts | 163 ++++++
.../routes/to.$id.brackets.tsx | 76 +--
.../tournament/core/Standings.test.ts | 177 ++++++
app/features/tournament/core/Standings.ts | 35 +-
e2e/pages/calendar/calendar-new-event-page.ts | 16 +
.../tournament/tournament-brackets-page.ts | 4 +
e2e/tournament-bracket-multi-stage.spec.ts | 85 +++
locales/da/tournament.json | 11 +-
locales/de/tournament.json | 11 +-
locales/en/tournament.json | 11 +-
locales/es-ES/analyzer.json | 4 +
locales/es-ES/art.json | 1 +
locales/es-ES/badges.json | 1 +
locales/es-ES/calendar.json | 2 +
locales/es-ES/forms.json | 4 +
locales/es-ES/friends.json | 1 +
locales/es-ES/tournament.json | 13 +-
locales/es-ES/user.json | 3 +
locales/es-US/tournament.json | 11 +-
locales/fr-CA/tournament.json | 11 +-
locales/fr-EU/tournament.json | 11 +-
locales/he/tournament.json | 11 +-
locales/it/tournament.json | 11 +-
locales/ja/tournament.json | 11 +-
locales/ko/tournament.json | 11 +-
locales/nl/tournament.json | 11 +-
locales/pl/tournament.json | 11 +-
locales/pt-BR/tournament.json | 11 +-
locales/ru/tournament.json | 11 +-
locales/zh/tournament.json | 11 +-
40 files changed, 1993 insertions(+), 293 deletions(-)
diff --git a/app/features/calendar/calendar-progression-form.test.ts b/app/features/calendar/calendar-progression-form.test.ts
index 203bf1327..59f483124 100644
--- a/app/features/calendar/calendar-progression-form.test.ts
+++ b/app/features/calendar/calendar-progression-form.test.ts
@@ -112,6 +112,22 @@ describe("progressionToFormValues + formValuesToInputBrackets", () => {
expect(roundTrip(progression)).toEqual(progression);
});
+ it("round-trips a bracket sourcing teams from two brackets", () => {
+ const progression: Progression.ParsedBracket[] = [
+ RR_TO_SE_WITH_UNDERGROUND[0],
+ RR_TO_SE_WITH_UNDERGROUND[2],
+ {
+ ...RR_TO_SE_WITH_UNDERGROUND[1],
+ sources: [
+ { bracketIdx: 0, placements: [1, 2] },
+ { bracketIdx: 1, placements: [1] },
+ ],
+ },
+ ];
+
+ expect(roundTrip(progression)).toEqual(progression);
+ });
+
it("round-trips bracket start time", () => {
const progression: Progression.ParsedBracket[] = [
RR_TO_SE_WITH_UNDERGROUND[0],
@@ -170,13 +186,13 @@ describe("validateBracketProgressionFormValues", () => {
const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND);
formValues.progression[1] = {
...formValues.progression[1],
- placements: "not placements",
+ sources: [{ bracketIdx: "0", placements: "not placements" }],
};
const issues = validationIssues(formValues);
expect(issues).toHaveLength(1);
- expect(issues[0].path).toEqual(["progression", 1, "placements"]);
+ expect(issues[0].path).toEqual(["progression", 1, "sources"]);
expect(issues[0].message).toBe(
"tournament:progression.error.PLACEMENTS_PARSE_ERROR",
);
@@ -198,38 +214,75 @@ describe("validateBracketProgressionFormValues", () => {
const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND);
formValues.progression[1] = {
...formValues.progression[1],
- sourceBracketIdx: "10",
+ sources: [{ bracketIdx: "10", placements: "1,2" }],
};
const issues = validationIssues(formValues);
expect(issues).toHaveLength(1);
- expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]);
+ expect(issues[0].path).toEqual([
+ "progression",
+ 1,
+ "sources",
+ 0,
+ "bracketIdx",
+ ]);
});
it("rejects a non-canonical source bracket idx string", () => {
const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND);
formValues.progression[1] = {
...formValues.progression[1],
- sourceBracketIdx: "00",
+ sources: [{ bracketIdx: "00", placements: "1,2" }],
};
const issues = validationIssues(formValues);
expect(issues).toHaveLength(1);
- expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]);
+ expect(issues[0].path).toEqual([
+ "progression",
+ 1,
+ "sources",
+ 0,
+ "bracketIdx",
+ ]);
});
it("rejects a bracket sourcing itself", () => {
const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND);
formValues.progression[1] = {
...formValues.progression[1],
- sourceBracketIdx: "1",
+ sources: [{ bracketIdx: "1", placements: "1,2" }],
};
const issues = validationIssues(formValues);
expect(issues).toHaveLength(1);
- expect(issues[0].path).toEqual(["progression", 1, "sourceBracketIdx"]);
+ expect(issues[0].path).toEqual([
+ "progression",
+ 1,
+ "sources",
+ 0,
+ "bracketIdx",
+ ]);
+ });
+
+ it("rejects the same source bracket twice for one bracket", () => {
+ const formValues = progressionToFormValues(RR_TO_SE_WITH_UNDERGROUND);
+ formValues.progression[1] = {
+ ...formValues.progression[1],
+ sources: [
+ { bracketIdx: "0", placements: "1,2" },
+ { bracketIdx: "0", placements: "3,4" },
+ ],
+ };
+
+ const issues = validationIssues(formValues);
+
+ expect(issues).toHaveLength(1);
+ expect(issues[0].path).toEqual(["progression", 1, "sources"]);
+ expect(issues[0].message).toBe(
+ "tournament:progression.error.DUPLICATE_SOURCE_BRACKET",
+ );
});
});
diff --git a/app/features/calendar/calendar-progression-form.ts b/app/features/calendar/calendar-progression-form.ts
index f8ef53e75..d11930abe 100644
--- a/app/features/calendar/calendar-progression-form.ts
+++ b/app/features/calendar/calendar-progression-form.ts
@@ -32,11 +32,15 @@ export interface BracketFormValue {
requiresCheckIn: boolean;
}
+export interface ProgressionSourceFormValue {
+ /** Index of the source bracket in the `brackets` form field, as a string (select value). */
+ bracketIdx: string;
+ placements: string | null;
+}
+
export interface ProgressionFormValue {
source: "SIGN_UP" | "BRACKET";
- /** Index of the source bracket in the `brackets` form field, as a string (select value). */
- sourceBracketIdx: string;
- placements: string | null;
+ sources: ProgressionSourceFormValue[];
}
// extracted so their literal item values don't widen to `string` in the
@@ -121,10 +125,9 @@ const bracketFieldset = fieldset({
}),
});
-const progressionEntryFieldset = fieldset({
+const progressionSourceFieldset = fieldset({
fields: z.object({
- source: progressionSourceField,
- sourceBracketIdx: selectDynamic({
+ bracketIdx: selectDynamic({
label: "labels.sourceBracket",
initialValue: "0",
}),
@@ -136,6 +139,17 @@ const progressionEntryFieldset = fieldset({
}),
});
+const progressionEntryFieldset = fieldset({
+ fields: z.object({
+ source: progressionSourceField,
+ sources: array({
+ min: 1,
+ max: TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT - 1,
+ field: progressionSourceFieldset,
+ }),
+ }),
+});
+
export const bracketsFormField = array({
label: "labels.brackets",
max: TOURNAMENT.MAX_BRACKETS_PER_TOURNAMENT,
@@ -166,7 +180,7 @@ export function defaultBracketsFormValues(): {
} {
return {
brackets: [{ ...newBracketFormValue(), name: "Main Bracket" }],
- progression: [{ source: "SIGN_UP", sourceBracketIdx: "0", placements: "" }],
+ progression: [{ source: "SIGN_UP", sources: [newProgressionSource()] }],
};
}
@@ -188,7 +202,12 @@ function newBracketFormValue(): BracketFormValue {
/** Progression form field value appended when a new bracket is added: a follow-up bracket sourcing teams from the first bracket. */
export function newFollowUpProgressionEntry(): ProgressionFormValue {
- return { source: "BRACKET", sourceBracketIdx: "0", placements: "" };
+ return { source: "BRACKET", sources: [newProgressionSource()] };
+}
+
+/** Source form field value of a bracket that takes its teams from the first bracket. */
+export function newProgressionSource(): ProgressionSourceFormValue {
+ return { bracketIdx: "0", placements: "" };
}
/** Converts the `brackets` + `progression` form values into {@link Progression.InputBracket} format ready for validation. */
@@ -217,14 +236,12 @@ export function formValuesToInputBrackets(
settings: settingsFromFormValues(bracket, false),
requiresCheckIn: bracket.requiresCheckIn,
startTime: bracket.startTime ?? undefined,
- sources: [
- {
- bracketId: entry.sourceBracketIdx,
- placements: sourceBracketHasEarlyAdvance(brackets, entry)
- ? ""
- : (entry.placements ?? ""),
- },
- ],
+ sources: entry.sources.map((source) => ({
+ bracketId: source.bracketIdx,
+ placements: sourceBracketHasEarlyAdvance(brackets, source)
+ ? ""
+ : (source.placements ?? ""),
+ })),
};
});
}
@@ -266,18 +283,22 @@ export function progressionToFormValues(
})),
progression: input.map((bracket) => ({
source: bracket.sources ? "BRACKET" : "SIGN_UP",
- sourceBracketIdx: bracket.sources?.[0]?.bracketId ?? "0",
- placements: bracket.sources?.[0]?.placements ?? "",
+ sources: bracket.sources?.length
+ ? bracket.sources.map((source) => ({
+ bracketIdx: source.bracketId,
+ placements: source.placements,
+ }))
+ : [newProgressionSource()],
})),
};
}
-/** Does the source bracket of the given progression entry advance teams via a Swiss early advance threshold (meaning placements are not specified)? */
+/** Does the bracket of the given progression source advance teams via a Swiss early advance threshold (meaning placements are not specified)? */
export function sourceBracketHasEarlyAdvance(
brackets: BracketFormValue[],
- entry: ProgressionFormValue,
+ source: ProgressionSourceFormValue,
) {
- const sourceBracket = brackets[Number(entry.sourceBracketIdx)];
+ const sourceBracket = brackets[Number(source.bracketIdx)];
return sourceBracket?.type === "swiss" && sourceBracket.earlyAdvance;
}
@@ -290,20 +311,28 @@ export function validateBracketProgressionFormValues(
for (const [entryIdx, entry] of progression.entries()) {
if (entryIdx === 0 || entry.source !== "BRACKET") continue;
- const sourceIdx = Number(entry.sourceBracketIdx);
- if (
- !Number.isInteger(sourceIdx) ||
- String(sourceIdx) !== entry.sourceBracketIdx ||
- sourceIdx < 0 ||
- sourceIdx >= brackets.length ||
- sourceIdx === entryIdx
- ) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "forms:errors.invalidSourceBracket",
- path: ["progression", entryIdx, "sourceBracketIdx"],
- });
- return;
+ for (const [sourceRowIdx, source] of entry.sources.entries()) {
+ const sourceIdx = Number(source.bracketIdx);
+ if (
+ !Number.isInteger(sourceIdx) ||
+ String(sourceIdx) !== source.bracketIdx ||
+ sourceIdx < 0 ||
+ sourceIdx >= brackets.length ||
+ sourceIdx === entryIdx
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "forms:errors.invalidSourceBracket",
+ path: [
+ "progression",
+ entryIdx,
+ "sources",
+ sourceRowIdx,
+ "bracketIdx",
+ ],
+ });
+ return;
+ }
}
}
@@ -342,15 +371,19 @@ function progressionErrorPaths(
return [["brackets", error.bracketIdx, "hasAbDivisions"]];
case "SAME_PLACEMENT_TO_MULTIPLE_BRACKETS":
case "GAP_IN_PLACEMENTS":
- return error.bracketIdxs.map((idx) => ["progression", idx, "placements"]);
+ case "CYCLIC_PROGRESSION":
+ return error.bracketIdxs.map((idx) => ["progression", idx, "sources"]);
+ // a bracket can have many sources but the error only identifies the bracket,
+ // so the message attaches to the sources list rather than one source's placements
case "PLACEMENTS_PARSE_ERROR":
case "TOO_MANY_PLACEMENTS":
case "PLACEMENT_TOO_HIGH":
case "NEGATIVE_PROGRESSION":
- case "NO_SE_POSITIVE":
- case "NO_DE_POSITIVE":
+ case "MIXED_POSITIVE_NEGATIVE_PLACEMENTS":
+ case "DUPLICATE_SOURCE_BRACKET":
case "EMPTY_PLACEMENTS_ON_NON_SWISS":
- return [["progression", error.bracketIdx, "placements"]];
+ case "MERGED_STARTING_BRACKETS":
+ return [["progression", error.bracketIdx, "sources"]];
default:
assertUnreachable(error);
}
diff --git a/app/features/calendar/components/BracketProgressionFormFields.tsx b/app/features/calendar/components/BracketProgressionFormFields.tsx
index 17697ff93..b6a2e5fca 100644
--- a/app/features/calendar/components/BracketProgressionFormFields.tsx
+++ b/app/features/calendar/components/BracketProgressionFormFields.tsx
@@ -9,7 +9,9 @@ import type { ArrayItemRenderContext } from "~/form/types";
import {
type BracketFormValue,
newFollowUpProgressionEntry,
+ newProgressionSource,
type ProgressionFormValue,
+ type ProgressionSourceFormValue,
sourceBracketHasEarlyAdvance,
} from "../calendar-progression-form";
import styles from "./BracketProgressionFormFields.module.css";
@@ -224,17 +226,37 @@ function ProgressionEntryFields({
isSourceLocked: boolean;
}) {
const { t } = useTranslation(["forms"]);
- const { index, itemName, values, formValues } = renderContext;
+ const { index, itemName, values, formValues, setItemField } = renderContext;
const entry = values as unknown as ProgressionFormValue;
const brackets = (formValues.brackets ?? []) as BracketFormValue[];
+ const sources = entry.sources ?? [];
const isFirstBracket = index === 0;
- const sourceBracketOptions = brackets.flatMap((bracket, bracketIdx) =>
- bracketIdx === index || !bracket.name
- ? []
- : [{ value: String(bracketIdx), label: bracket.name }],
- );
+ // a newly added row defaults to the first bracket, which is usually already a
+ // source of this bracket, so it gets moved to the first one not sourced yet
+ const handleSourcesChanged = (newValue: unknown) => {
+ const newSources = newValue as ProgressionSourceFormValue[];
+ if (newSources.length <= sources.length) return;
+
+ const usedBracketIdxs = new Set(
+ newSources.slice(0, -1).map((source) => source.bracketIdx),
+ );
+ const unusedBracketIdx = brackets.findIndex(
+ (_, bracketIdx) =>
+ bracketIdx !== index && !usedBracketIdxs.has(String(bracketIdx)),
+ );
+ if (unusedBracketIdx === -1) return;
+
+ setItemField(
+ "sources",
+ newSources.map((source, sourceIdx) =>
+ sourceIdx === newSources.length - 1
+ ? { ...source, bracketIdx: String(unusedBracketIdx) }
+ : source,
+ ),
+ );
+ };
return (
@@ -246,20 +268,19 @@ function ProgressionEntryFields({
disabled={isFirstBracket || isSourceLocked}
/>
{!isFirstBracket && entry.source === "BRACKET" ? (
- <>
-
- {!sourceBracketHasEarlyAdvance(brackets, entry) ? (
-
}
+
+ {(sourceRenderContext: ArrayItemRenderContext) => (
+
- ) : null}
- >
+ )}
+
) : (
{isInvitational
@@ -271,6 +292,52 @@ function ProgressionEntryFields({
);
}
+function SourceFields({
+ renderContext,
+ destinationBracketIdx,
+ isDisabled,
+}: {
+ renderContext: ArrayItemRenderContext;
+ destinationBracketIdx: number;
+ isDisabled: boolean;
+}) {
+ const { index, itemName, values, formValues } = renderContext;
+ const source = values as unknown as ProgressionSourceFormValue;
+ const brackets = (formValues.brackets ?? []) as BracketFormValue[];
+ const progression = (formValues.progression ?? []) as ProgressionFormValue[];
+ const siblingSources = progression[destinationBracketIdx]?.sources ?? [];
+
+ // a bracket can be sourced only once, so the brackets taken by the other rows
+ // are not offered here
+ const bracketOptions = brackets.flatMap((bracket, bracketIdx) =>
+ bracketIdx === destinationBracketIdx ||
+ !bracket.name ||
+ siblingSources.some(
+ (siblingSource, siblingIdx) =>
+ siblingIdx !== index && siblingSource.bracketIdx === String(bracketIdx),
+ )
+ ? []
+ : [{ value: String(bracketIdx), label: bracket.name }],
+ );
+
+ return (
+
+
+ {!sourceBracketHasEarlyAdvance(brackets, source) ? (
+ }
+ />
+ ) : null}
+
+ );
+}
+
function PlacementsSyntaxPopover() {
return (
@@ -307,15 +374,24 @@ function progressionAfterBracketDelete(
): ProgressionFormValue[] {
return progression
.filter((_, idx) => idx !== deletedIdx)
- .map((entry) => {
- const sourceIdx = Number(entry.sourceBracketIdx);
-
- if (sourceIdx === deletedIdx) {
- return { ...entry, sourceBracketIdx: "0" };
- }
- if (sourceIdx > deletedIdx) {
- return { ...entry, sourceBracketIdx: String(sourceIdx - 1) };
- }
- return entry;
- });
+ .map((entry) => ({
+ ...entry,
+ // sources of the deleted bracket are dropped, the rest shift down with it
+ sources: withFallbackSource(
+ (entry.sources ?? [])
+ .filter((source) => Number(source.bracketIdx) !== deletedIdx)
+ .map((source) => {
+ const sourceIdx = Number(source.bracketIdx);
+ return sourceIdx > deletedIdx
+ ? { ...source, bracketIdx: String(sourceIdx - 1) }
+ : source;
+ }),
+ ),
+ }));
+}
+
+function withFallbackSource(sources: ProgressionSourceFormValue[]) {
+ if (sources.length === 0) return [newProgressionSource()];
+
+ return sources;
}
diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts
index 25ae7bcca..ef00fbbfc 100644
--- a/app/features/tournament-bracket/core/Bracket.test.ts
+++ b/app/features/tournament-bracket/core/Bracket.test.ts
@@ -1037,6 +1037,174 @@ describe("single elimination source - underground", () => {
});
});
+describe("single elimination source - positive placements", () => {
+ // 8-team SE without a third place match; lower id always wins so the final
+ // standings are 1st: team 1, 2nd: team 2, tied 3rd: teams 3 & 4, tied 5th: the rest
+ const singleEliminationTournament = ({
+ playedRounds,
+ }: {
+ playedRounds: "all" | "first";
+ }) => {
+ let data = createResolved({
+ type: "single_elimination",
+ seeding: [1, 2, 3, 4, 5, 6, 7, 8],
+ settings: {},
+ });
+
+ if (playedRounds === "first") {
+ for (const match of readyMatches(data, () => true)) {
+ data = reportLowerIdWinner(data, match.id);
+ }
+ } else {
+ let ready = readyMatches(data, () => true);
+ while (ready.length) {
+ for (const match of ready) {
+ data = reportLowerIdWinner(data, match.id);
+ }
+ ready = readyMatches(data, () => true);
+ }
+ }
+
+ return testTournament({
+ ctx: {
+ settings: {
+ bracketProgression: [
+ {
+ type: "single_elimination",
+ name: "SE",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [],
+ },
+ ],
+ },
+ },
+ data,
+ });
+ };
+
+ it("sources the winner when placements are [1]", () => {
+ const tournament = singleEliminationTournament({ playedRounds: "all" });
+
+ const { teams, relevantMatchesFinished } = tournament
+ .bracketByIdx(0)!
+ .source({ placements: [1] });
+
+ expect(relevantMatchesFinished).toBe(true);
+ expect(teams).toEqual([1]);
+ });
+
+ it("sources the top 2 when placements are [1, 2]", () => {
+ const tournament = singleEliminationTournament({ playedRounds: "all" });
+
+ const { teams, relevantMatchesFinished } = tournament
+ .bracketByIdx(0)!
+ .source({ placements: [1, 2] });
+
+ expect(relevantMatchesFinished).toBe(true);
+ expect(teams).toEqual([1, 2]);
+ });
+
+ it("sources both tied semifinal losers when placements are [3]", () => {
+ const tournament = singleEliminationTournament({ playedRounds: "all" });
+
+ const { teams } = tournament.bracketByIdx(0)!.source({ placements: [3] });
+
+ expect([...teams].sort((a, b) => a - b)).toEqual([3, 4]);
+ });
+
+ it("reports relevant matches unfinished while the bracket is underway", () => {
+ const tournament = singleEliminationTournament({ playedRounds: "first" });
+
+ const { teams, relevantMatchesFinished } = tournament
+ .bracketByIdx(0)!
+ .source({ placements: [1] });
+
+ expect(relevantMatchesFinished).toBe(false);
+ expect(teams).toEqual([]);
+ });
+});
+
+describe("double elimination source - positive placements", () => {
+ // 4-team DE; lower id always wins so the grand finals winner is team 1 and no
+ // bracket reset is played, leaving the standings 1st: team 1 ... 4th: team 4
+ const doubleEliminationTournament = ({
+ playedRounds,
+ }: {
+ playedRounds: "all" | "first";
+ }) => {
+ let data = createResolved({
+ type: "double_elimination",
+ seeding: [1, 2, 3, 4],
+ settings: {},
+ });
+
+ if (playedRounds === "first") {
+ for (const match of readyMatches(data, () => true)) {
+ data = reportLowerIdWinner(data, match.id);
+ }
+ } else {
+ let ready = readyMatches(data, () => true);
+ while (ready.length) {
+ for (const match of ready) {
+ data = reportLowerIdWinner(data, match.id);
+ }
+ ready = readyMatches(data, () => true);
+ }
+ }
+
+ return testTournament({
+ ctx: {
+ settings: {
+ bracketProgression: [
+ {
+ type: "double_elimination",
+ name: "DE",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [],
+ },
+ ],
+ },
+ },
+ data,
+ });
+ };
+
+ it("sources the winner when placements are [1]", () => {
+ const tournament = doubleEliminationTournament({ playedRounds: "all" });
+
+ const { teams, relevantMatchesFinished } = tournament
+ .bracketByIdx(0)!
+ .source({ placements: [1] });
+
+ expect(relevantMatchesFinished).toBe(true);
+ expect(teams).toEqual([1]);
+ });
+
+ it("sources the top 2 when placements are [1, 2]", () => {
+ const tournament = doubleEliminationTournament({ playedRounds: "all" });
+
+ const { teams, relevantMatchesFinished } = tournament
+ .bracketByIdx(0)!
+ .source({ placements: [1, 2] });
+
+ expect(relevantMatchesFinished).toBe(true);
+ expect(teams).toEqual([1, 2]);
+ });
+
+ it("reports relevant matches unfinished while the bracket is underway", () => {
+ const tournament = doubleEliminationTournament({ playedRounds: "first" });
+
+ const { teams, relevantMatchesFinished } = tournament
+ .bracketByIdx(0)!
+ .source({ placements: [1] });
+
+ expect(relevantMatchesFinished).toBe(false);
+ expect(teams).toEqual([]);
+ });
+});
+
describe("swiss between rounds", () => {
const SWISS_MAIN_BRACKET = {
type: "swiss" as const,
diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts
index 5312028c9..22bd8eb21 100644
--- a/app/features/tournament-bracket/core/Bracket/Bracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts
@@ -491,6 +491,33 @@ export abstract class Bracket {
teams: number[];
};
+ /** Advances top finishers by their standings placement. Only settled teams appear in
+ * the standings, so placements are matched raw until the full standings resolve and
+ * only then normalized (1,3,5 -> 1,2,3) the way group brackets source. */
+ protected sourceByStandings(placements: number[], rest: boolean) {
+ const standings = this.standings;
+ const relevantMatchesFinished =
+ standings.length === this.participantTournamentTeamIds.length &&
+ this.participantTournamentTeamIds.length > 0;
+
+ const maxExplicit = Math.max(...placements);
+ const matchesPlacement = (placement: number) =>
+ placements.includes(placement) || (rest && placement >= maxExplicit);
+
+ const uniquePlacements = R.unique(standings.map((s) => s.placement));
+ const placementNormalized = (placement: number) =>
+ relevantMatchesFinished
+ ? uniquePlacements.indexOf(placement) + 1
+ : placement;
+
+ return {
+ relevantMatchesFinished,
+ teams: standings
+ .filter((s) => matchesPlacement(placementNormalized(s.placement)))
+ .map((s) => s.team.id),
+ };
+ }
+
teamsWithNames(teams: { id: number }[]) {
return teams.map((team) => {
const name = this.tournament.ctx.teams.find(
diff --git a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts
index f6aa73c9d..54ca5e9fb 100644
--- a/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/DoubleEliminationBracket.ts
@@ -218,8 +218,18 @@ export class DoubleEliminationBracket extends Bracket {
return true;
}
- source({ placements }: { placements: number[] }) {
+ source({ placements, rest }: { placements: number[]; rest?: boolean }) {
invariant(placements.length > 0, "Empty placements not supported");
+ invariant(
+ placements.every((placement) => placement < 0) ||
+ placements.every((placement) => placement > 0),
+ "Mixed positive and negative placements not supported",
+ );
+
+ if (placements.every((placement) => placement > 0)) {
+ return this.sourceByStandings(placements, rest === true);
+ }
+
const resolveLosersGroupId = (data: BracketData) => {
const minGroupId = Math.min(...data.round.map((round) => round.groupId));
@@ -257,11 +267,6 @@ export class DoubleEliminationBracket extends Bracket {
return orderedRoundsIds.slice(0, amountOfRounds);
};
- invariant(
- placements.every((placement) => placement < 0),
- "Positive placements in DE not implemented",
- );
-
const losersGroupId = resolveLosersGroupId(this.data);
const sourceRoundsIds = placementsToRoundsIds(
this.data,
diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts
index 26f4af2be..1fb579169 100644
--- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts
@@ -161,13 +161,18 @@ export class SingleEliminationBracket extends Bracket {
return this.standingsWithoutNonParticipants(resultWithThirdPlaceTiebroken);
}
- source({ placements }: { placements: number[] }) {
+ source({ placements, rest }: { placements: number[]; rest?: boolean }) {
invariant(placements.length > 0, "Empty placements not supported");
invariant(
- placements.every((placement) => placement < 0),
- "Positive placements in SE not implemented",
+ placements.every((placement) => placement < 0) ||
+ placements.every((placement) => placement > 0),
+ "Mixed positive and negative placements not supported",
);
+ if (placements.every((placement) => placement > 0)) {
+ return this.sourceByStandings(placements, rest === true);
+ }
+
// third place match lives in a separate (higher) group; the winners
// group teams get eliminated from is the lowest group id
const mainGroupId = Math.min(...this.data.group.map((group) => group.id));
diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts
index e10a1e687..13f7909c6 100644
--- a/app/features/tournament-bracket/core/Progression.test.ts
+++ b/app/features/tournament-bracket/core/Progression.test.ts
@@ -34,6 +34,12 @@ describe("bracketsToValidationError - valid formats", () => {
Progression.bracketsToValidationError(progressions.swissOneGroup),
).toBeNull();
});
+
+ it("accepts a bracket with many source brackets", () => {
+ expect(
+ Progression.bracketsToValidationError(progressions.multiSourceTopCut),
+ ).toBeNull();
+ });
});
describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => {
@@ -917,8 +923,8 @@ describe("validatedSources - other rules", () => {
expect((error as any).bracketIdx).toEqual(1);
});
- it("handles NO_SE_POSITIVE", () => {
- const error = getValidatedBrackets([
+ it("allows single elimination positive progression", () => {
+ const result = getValidatedBrackets([
{
settings: {},
type: "single_elimination",
@@ -933,9 +939,30 @@ describe("validatedSources - other rules", () => {
},
],
},
+ ]);
+
+ expect(Progression.isBrackets(result)).toBe(true);
+ });
+
+ it("handles MIXED_POSITIVE_NEGATIVE_PLACEMENTS", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "single_elimination",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "0",
+ placements: "1,-1",
+ },
+ ],
+ },
]) as Progression.ValidationError;
- expect(error.type).toBe("NO_SE_POSITIVE");
+ expect(error.type).toBe("MIXED_POSITIVE_NEGATIVE_PLACEMENTS");
expect((error as any).bracketIdx).toEqual(1);
});
@@ -960,8 +987,8 @@ describe("validatedSources - other rules", () => {
expect(Progression.isBrackets(result)).toBe(true);
});
- it("handles NO_DE_POSITIVE", () => {
- const error = getValidatedBrackets([
+ it("allows double elimination positive progression", () => {
+ const result = getValidatedBrackets([
{
settings: {},
type: "double_elimination",
@@ -976,10 +1003,9 @@ describe("validatedSources - other rules", () => {
},
],
},
- ]) as Progression.ValidationError;
+ ]);
- expect(error.type).toBe("NO_DE_POSITIVE");
- expect((error as any).bracketIdx).toEqual(1);
+ expect(Progression.isBrackets(result)).toBe(true);
});
it("handles SWISS_EARLY_ADVANCE_NO_DESTINATION", () => {
@@ -1314,6 +1340,18 @@ describe("isUnderground", () => {
).toBe(true);
});
+ it("redemption bracket feeding the finals is not underground", () => {
+ expect(Progression.isUnderground(0, progressions.multiSourceTopCut)).toBe(
+ false,
+ );
+ expect(Progression.isUnderground(1, progressions.multiSourceTopCut)).toBe(
+ false,
+ );
+ expect(Progression.isUnderground(2, progressions.multiSourceTopCut)).toBe(
+ false,
+ );
+ });
+
it("throws if given idx is out of bounds", () => {
expect(() =>
Progression.isUnderground(1, progressions.singleElimination),
@@ -1359,9 +1397,9 @@ describe("bracketIdxsForStandings", () => {
it("handles low ink", () => {
expect(Progression.bracketIdxsForStandings(progressions.lowInk)).toEqual([
- 3, 1,
+ 3, 2, 1,
0,
- // NOTE: 2 is omitted as it's an "intermediate" bracket
+ // NOTE: 2 is included so that teams eliminated in it are not dropped down to the starting bracket
]);
});
@@ -1400,6 +1438,37 @@ describe("bracketIdxsForStandings", () => {
),
).toEqual([1, 2, 0]); // missing 3 because it's underground
});
+
+ it("keeps a finals bracket sourced positively from a SE redemption bracket", () => {
+ expect(
+ Progression.bracketIdxsForStandings(progressions.multiSourceTopCut),
+ ).toEqual([2, 1, 0]);
+ });
+
+ it("places a redemption bracket above the brackets taking lower placements from the same source", () => {
+ expect(
+ Progression.bracketIdxsForStandings(
+ progressions.multiSourceTopCutWithConsolation,
+ ),
+ ).toEqual([2, 1, 3, 0]);
+ });
+
+ it("orders brackets by the placement of their teams in the shared ancestor bracket", () => {
+ expect(
+ Progression.bracketIdxsForStandings(
+ progressions.poolsToBracketsViaIntermediateBrackets,
+ ),
+ ).toEqual([
+ 2, // Alpha (pools 1)
+ 3, // Beta (pools 2-4, via Redemption)
+ 1, // Redemption (pools 2-4)
+ 4, // Gamma (pools 5-6)
+ 5, // Delta (pools 7-8)
+ 7, // Epsilon (pools 9-11, via Epsilon Seeding)
+ 6, // Epsilon Seeding (pools 9-11)
+ 0, // Day 1 Pools
+ ]);
+ });
});
describe("startingBrackets", () => {
@@ -1566,3 +1635,382 @@ describe("bracketDepth", () => {
).toThrow();
});
});
+
+describe("validatedSources - DUPLICATE_SOURCE_BRACKET", () => {
+ it("flags a destination sourcing the same bracket twice", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "0",
+ placements: "1-2",
+ },
+ {
+ bracketId: "0",
+ placements: "3-4",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+
+ expect(error.type).toBe("DUPLICATE_SOURCE_BRACKET");
+ expect((error as any).bracketIdx).toBe(1);
+ });
+
+ it("accepts different destinations sourcing the same bracket", () => {
+ expect(
+ Progression.bracketsToValidationError(progressions.lowInk),
+ ).toBeNull();
+ });
+});
+
+describe("validatedSources - CYCLIC_PROGRESSION", () => {
+ it("flags two brackets sourcing each other", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "0",
+ placements: "1-2",
+ },
+ {
+ bracketId: "2",
+ placements: "1",
+ },
+ ],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "1",
+ placements: "1",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+
+ expect(error.type).toBe("CYCLIC_PROGRESSION");
+ expect((error as any).bracketIdxs).toEqual([1, 2]);
+ });
+
+ it("flags a bracket sourcing itself", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "1",
+ placements: "1",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+
+ expect(error.type).toBe("CYCLIC_PROGRESSION");
+ expect((error as any).bracketIdxs).toEqual([1]);
+ });
+
+ it("accepts a bracket sourcing one that comes later in the list", () => {
+ const result = getValidatedBrackets([
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "1",
+ placements: "1-4",
+ },
+ ],
+ },
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ ]);
+
+ expect(Progression.isBrackets(result)).toBe(true);
+ });
+
+ it("accepts brackets sharing a source (diamond shaped progression)", () => {
+ expect(
+ Progression.bracketsToValidationError(progressions.lowInk),
+ ).toBeNull();
+ });
+});
+
+describe("validatedSources - MERGED_STARTING_BRACKETS", () => {
+ it("flags a bracket sourcing two starting brackets", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "0",
+ placements: "1-2",
+ },
+ {
+ bracketId: "1",
+ placements: "1-2",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+
+ expect(error.type).toBe("MERGED_STARTING_BRACKETS");
+ expect((error as any).bracketIdx).toBe(2);
+ });
+
+ it("flags a merge that happens through intermediate brackets", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "0",
+ placements: "1-2",
+ },
+ ],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "1",
+ placements: "1-2",
+ },
+ ],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "2",
+ placements: "1",
+ },
+ {
+ bracketId: "3",
+ placements: "1",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+
+ expect(error.type).toBe("MERGED_STARTING_BRACKETS");
+ expect((error as any).bracketIdx).toBe(4);
+ });
+
+ it("reports the bracket where the merge happens, not the ones after it", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "3",
+ placements: "1-2",
+ },
+ ],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [
+ {
+ bracketId: "0",
+ placements: "1-2",
+ },
+ {
+ bracketId: "1",
+ placements: "1-2",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+
+ expect(error.type).toBe("MERGED_STARTING_BRACKETS");
+ expect((error as any).bracketIdx).toBe(3);
+ });
+
+ it("accepts many starting brackets that never merge", () => {
+ expect(
+ Progression.bracketsToValidationError(progressions.manyStartBrackets),
+ ).toBeNull();
+ });
+
+ it("accepts many sources that all come from the same starting bracket", () => {
+ expect(
+ Progression.bracketsToValidationError(progressions.multiSourceTopCut),
+ ).toBeNull();
+ });
+});
+
+describe("sortedSourcesForSeeding", () => {
+ it("orders a direct source above one that took a redemption route", () => {
+ const topCut: Progression.ParsedBracket = progressions.multiSourceTopCut[2];
+
+ const sorted = Progression.sortedSourcesForSeeding(
+ topCut.sources!,
+ progressions.multiSourceTopCut,
+ );
+
+ expect(sorted.map((source) => source.bracketIdx)).toEqual([0, 1]);
+ });
+
+ it("keeps the original order when sources share no ancestor bracket", () => {
+ const progression: Progression.ParsedBracket[] = [
+ {
+ name: "Group A",
+ type: "round_robin",
+ settings: {},
+ requiresCheckIn: false,
+ },
+ {
+ name: "Group B",
+ type: "round_robin",
+ settings: {},
+ requiresCheckIn: false,
+ },
+ {
+ name: "Finals",
+ type: "single_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [
+ { bracketIdx: 1, placements: [1, 2] },
+ { bracketIdx: 0, placements: [1, 2] },
+ ],
+ },
+ ];
+
+ const sorted = Progression.sortedSourcesForSeeding(
+ progression[2].sources!,
+ progression,
+ );
+
+ expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 0]);
+ });
+
+ it("compares at the deepest common ancestor", () => {
+ const progression: Progression.ParsedBracket[] = [
+ {
+ name: "Pools",
+ type: "round_robin",
+ settings: {},
+ requiresCheckIn: false,
+ },
+ {
+ name: "Redemption 1",
+ type: "round_robin",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [{ bracketIdx: 0, placements: [5, 6, 7, 8] }],
+ },
+ {
+ name: "Redemption 2",
+ type: "round_robin",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [{ bracketIdx: 1, placements: [3, 4] }],
+ },
+ {
+ name: "Finals",
+ type: "single_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [
+ { bracketIdx: 2, placements: [1, 2] },
+ { bracketIdx: 1, placements: [1, 2] },
+ ],
+ },
+ ];
+
+ const sorted = Progression.sortedSourcesForSeeding(
+ progression[3].sources!,
+ progression,
+ );
+
+ expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 2]);
+ });
+
+ it("orders teams eliminated from a follow-up bracket above lower direct placements", () => {
+ const progression: Progression.ParsedBracket[] = [
+ {
+ name: "Pools",
+ type: "round_robin",
+ settings: {},
+ requiresCheckIn: false,
+ },
+ {
+ name: "Top Cut",
+ type: "single_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [{ bracketIdx: 0, placements: [1, 2, 3, 4, 5, 6, 7, 8] }],
+ },
+ {
+ name: "Consolation",
+ type: "single_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [
+ { bracketIdx: 0, placements: [9, 10] },
+ { bracketIdx: 1, placements: [-1] },
+ ],
+ },
+ ];
+
+ const sorted = Progression.sortedSourcesForSeeding(
+ progression[2].sources!,
+ progression,
+ );
+
+ expect(sorted.map((source) => source.bracketIdx)).toEqual([1, 0]);
+ });
+});
diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts
index e5817e662..57e59b513 100644
--- a/app/features/tournament-bracket/core/Progression.ts
+++ b/app/features/tournament-bracket/core/Progression.ts
@@ -31,8 +31,6 @@ interface BracketBase {
requiresCheckIn: boolean;
}
-// Note sources is array for future proofing reasons. Currently the array is always of length 1 if it exists.
-
export interface InputBracket extends BracketBase {
id: string;
sources?: EditableSource[];
@@ -91,14 +89,9 @@ export type ValidationError =
type: "NEGATIVE_PROGRESSION";
bracketIdx: number;
}
- // no SE positive placements (single elimination can only source underground brackets)
+ // a single source can not take both top finishers and eliminated teams
| {
- type: "NO_SE_POSITIVE";
- bracketIdx: number;
- }
- // no DE positive placements (might change in the future)
- | {
- type: "NO_DE_POSITIVE";
+ type: "MIXED_POSITIVE_NEGATIVE_PLACEMENTS";
bracketIdx: number;
}
// Swiss bracket with early advance/elimination must have a destination bracket
@@ -125,6 +118,21 @@ export type ValidationError =
| {
type: "EMPTY_PLACEMENTS_ON_NON_SWISS";
bracketIdx: number;
+ }
+ // one destination bracket can source each bracket only once
+ | {
+ type: "DUPLICATE_SOURCE_BRACKET";
+ bracketIdx: number;
+ }
+ // brackets can not source each other in a loop e.g. A sources B and B sources A
+ | {
+ type: "CYCLIC_PROGRESSION";
+ bracketIdxs: number[];
+ }
+ // teams that started in different brackets can never meet, so the routes from many starting brackets can not merge
+ | {
+ type: "MERGED_STARTING_BRACKETS";
+ bracketIdx: number;
};
/** Takes validated brackets and returns them in the format that is ready for user input. */
@@ -152,7 +160,8 @@ export function validatedBracketsToInputFormat(
});
}
-function placementsToString(placements: number[], rest = false): string {
+/** Formats a placements array into the compact user-facing string form, e.g. [1, 2, 3] -> "1-3" and [5, 6] with rest -> "5,6+". */
+export function placementsToString(placements: number[], rest = false): string {
if (placements.length === 0) return "";
placements.sort((a, b) => a - b);
@@ -222,12 +231,37 @@ export function validatedBrackets(
export function bracketsToValidationError(
brackets: ParsedBracket[],
): ValidationError | null {
+ // must be checked first, other validations assume the progression is a directed acyclic graph
+ const cyclicBracketIdxs = cyclicProgression(brackets);
+ if (cyclicBracketIdxs) {
+ return {
+ type: "CYCLIC_PROGRESSION",
+ bracketIdxs: cyclicBracketIdxs,
+ };
+ }
+
+ const mergedStartingBracketsIdx = mergedStartingBrackets(brackets);
+ if (typeof mergedStartingBracketsIdx === "number") {
+ return {
+ type: "MERGED_STARTING_BRACKETS",
+ bracketIdx: mergedStartingBracketsIdx,
+ };
+ }
+
if (!resolvesWinner(brackets)) {
return {
type: "NOT_RESOLVING_WINNER",
};
}
+ const duplicateSourceBracketIdx = duplicateSourceBracket(brackets);
+ if (typeof duplicateSourceBracketIdx === "number") {
+ return {
+ type: "DUPLICATE_SOURCE_BRACKET",
+ bracketIdx: duplicateSourceBracketIdx,
+ };
+ }
+
let faultyBracketIdxs: number[] | null = null;
faultyBracketIdxs = samePlacementToMultipleBrackets(brackets);
@@ -288,18 +322,10 @@ export function bracketsToValidationError(
};
}
- faultyBracketIdx = noSingleEliminationPositive(brackets);
+ faultyBracketIdx = mixedPositiveNegativePlacements(brackets);
if (typeof faultyBracketIdx === "number") {
return {
- type: "NO_SE_POSITIVE",
- bracketIdx: faultyBracketIdx,
- };
- }
-
- faultyBracketIdx = noDoubleEliminationPositive(brackets);
- if (typeof faultyBracketIdx === "number") {
- return {
- type: "NO_DE_POSITIVE",
+ type: "MIXED_POSITIVE_NEGATIVE_PLACEMENTS",
bracketIdx: faultyBracketIdx,
};
}
@@ -672,29 +698,12 @@ function negativeProgression(brackets: ParsedBracket[]) {
return null;
}
-function noSingleEliminationPositive(brackets: ParsedBracket[]) {
+function mixedPositiveNegativePlacements(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
- const sourceBracket = brackets[source.bracketIdx];
if (
- sourceBracket.type === "single_elimination" &&
- source.placements.some((placement) => placement > 0)
- ) {
- return bracketIdx;
- }
- }
- }
-
- return null;
-}
-
-function noDoubleEliminationPositive(brackets: ParsedBracket[]) {
- for (const [bracketIdx, bracket] of brackets.entries()) {
- for (const source of bracket.sources ?? []) {
- const sourceBracket = brackets[source.bracketIdx];
- if (
- sourceBracket.type === "double_elimination" &&
- source.placements.some((placement) => placement > 0)
+ source.placements.some((placement) => placement > 0) &&
+ source.placements.some((placement) => placement < 0)
) {
return bracketIdx;
}
@@ -762,6 +771,22 @@ function swissEarlyAdvanceWithoutDestination(brackets: ParsedBracket[]) {
return null;
}
+function duplicateSourceBracket(brackets: ParsedBracket[]) {
+ for (const [bracketIdx, bracket] of brackets.entries()) {
+ if (!bracket.sources) continue;
+
+ const seen = new Set();
+ for (const source of bracket.sources) {
+ if (seen.has(source.bracketIdx)) {
+ return bracketIdx;
+ }
+ seen.add(source.bracketIdx);
+ }
+ }
+
+ return null;
+}
+
function emptyPlacementsOnNonSwiss(brackets: ParsedBracket[]) {
for (const [bracketIdx, bracket] of brackets.entries()) {
for (const source of bracket.sources ?? []) {
@@ -781,6 +806,78 @@ function emptyPlacementsOnNonSwiss(brackets: ParsedBracket[]) {
return null;
}
+/** Returns the bracket indexes forming a loop of sources or null if the progression has no loops. */
+function cyclicProgression(brackets: ParsedBracket[]) {
+ const visited = new Set();
+ const currentPath: number[] = [];
+
+ const findCycle = (bracketIdx: number): number[] | null => {
+ const pathIdx = currentPath.indexOf(bracketIdx);
+ if (pathIdx !== -1) return currentPath.slice(pathIdx);
+ if (visited.has(bracketIdx)) return null;
+
+ visited.add(bracketIdx);
+ currentPath.push(bracketIdx);
+
+ for (const source of brackets[bracketIdx]?.sources ?? []) {
+ const cycle = findCycle(source.bracketIdx);
+ if (cycle) return cycle;
+ }
+
+ currentPath.pop();
+
+ return null;
+ };
+
+ for (const bracketIdx of brackets.keys()) {
+ const cycle = findCycle(bracketIdx);
+ if (cycle) return cycle.sort((a, b) => a - b);
+ }
+
+ return null;
+}
+
+/** Returns the index of the bracket where routes from many starting brackets merge or null if they never merge. */
+function mergedStartingBrackets(brackets: ParsedBracket[]) {
+ const cache = new Map>();
+
+ const startingAncestors = (bracketIdx: number): Set => {
+ const cached = cache.get(bracketIdx);
+ if (cached) return cached;
+
+ const sources = brackets[bracketIdx]?.sources;
+ const result = new Set();
+
+ if (!sources?.length) {
+ result.add(bracketIdx);
+ } else {
+ for (const source of sources) {
+ for (const ancestorIdx of startingAncestors(source.bracketIdx)) {
+ result.add(ancestorIdx);
+ }
+ }
+ }
+
+ cache.set(bracketIdx, result);
+
+ return result;
+ };
+
+ for (const [bracketIdx, bracket] of brackets.entries()) {
+ if (startingAncestors(bracketIdx).size <= 1) continue;
+
+ // the merge already happened earlier in the progression, that bracket is reported instead
+ const mergedEarlier = (bracket.sources ?? []).some(
+ (source) => startingAncestors(source.bracketIdx).size > 1,
+ );
+ if (mergedEarlier) continue;
+
+ 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,
@@ -818,13 +915,34 @@ export function hasAbDivisionsFinals(brackets: ParsedBracket[]): boolean {
export function isUnderground(idx: number, brackets: ParsedBracket[]) {
invariant(idx < brackets.length, "Bracket index out of bounds");
- const startBrackets = startingBrackets(brackets);
+ const mainBracketIdxs = new Set(
+ startingBrackets(brackets).flatMap((startBracketIdx) =>
+ resolveMainBracketProgression(brackets, startBracketIdx),
+ ),
+ );
- for (const startBracketIdx of startBrackets) {
- if (
- resolveMainBracketProgression(brackets, startBracketIdx).includes(idx)
- ) {
- return false;
+ if (mainBracketIdxs.has(idx)) return false;
+
+ // a bracket whose top finishers advance (transitively) into the main progression
+ // is a redemption style intermediate bracket, not an underground one
+ const queue = [idx];
+ const visited = new Set();
+ while (queue.length > 0) {
+ const currentIdx = queue.shift()!;
+ if (visited.has(currentIdx)) continue;
+ visited.add(currentIdx);
+
+ for (const [destinationIdx, bracket] of brackets.entries()) {
+ const advancesPositively = bracket.sources?.some(
+ (source) =>
+ source.bracketIdx === currentIdx &&
+ (source.placements.length === 0 ||
+ source.placements.some((placement) => placement > 0)),
+ );
+ if (!advancesPositively) continue;
+
+ if (mainBracketIdxs.has(destinationIdx)) return false;
+ queue.push(destinationIdx);
}
}
@@ -839,6 +957,17 @@ export function isUnderground(idx: number, brackets: ParsedBracket[]) {
export function bracketDepth(idx: number, brackets: ParsedBracket[]): number {
invariant(idx < brackets.length, "Bracket index out of bounds");
+ return depthFromStartingBracket(idx, brackets, new Set());
+}
+
+function depthFromStartingBracket(
+ idx: number,
+ brackets: ParsedBracket[],
+ pathToBracket: Set,
+): number {
+ // only possible with an invalid progression, see CYCLIC_PROGRESSION
+ if (pathToBracket.has(idx)) return 0;
+
const bracket = brackets[idx];
if (!bracket.sources || bracket.sources.length === 0) {
@@ -846,7 +975,11 @@ export function bracketDepth(idx: number, brackets: ParsedBracket[]): number {
}
const sourceDepths = bracket.sources.map((source) =>
- bracketDepth(source.bracketIdx, brackets),
+ depthFromStartingBracket(
+ source.bracketIdx,
+ brackets,
+ new Set(pathToBracket).add(idx),
+ ),
);
return Math.max(...sourceDepths) + 1;
@@ -860,6 +993,7 @@ function resolveMainBracketProgression(
let bracketIdxToFind = startBracketIdx;
const result = [startBracketIdx];
+ const visited = new Set([startBracketIdx]);
while (true) {
const bracket = brackets.findIndex((bracket) =>
bracket.sources?.some(
@@ -870,9 +1004,12 @@ function resolveMainBracketProgression(
),
);
- if (bracket === -1) break;
+ // -1 = end of the progression, already visited is only possible
+ // with an invalid progression, see CYCLIC_PROGRESSION
+ if (bracket === -1 || visited.has(bracket)) break;
bracketIdxToFind = bracket;
+ visited.add(bracketIdxToFind);
result.push(bracketIdxToFind);
}
@@ -925,75 +1062,108 @@ export function changedBracketProgressionFormat(
* Returns the order of brackets as is to be considered for standings. Teams from the bracket of lower index are considered to be above those from the lower bracket.
* A participant's standing is the first bracket to appear in order that has the participant in it.
*
- * The order is so that most significant brackets (i.e. finals) appear first.
+ * The order is so that most significant brackets (i.e. finals) appear first. A bracket always appears after every bracket
+ * it advances teams to, so the teams it eliminated end up below the teams that advanced out of it.
+ *
+ * Underground brackets are omitted as they are only used to break ties within their source bracket, see `tiebrokenByUndergroundBrackets`.
*/
export function bracketIdxsForStandings(progression: ParsedBracket[]) {
const bracketsToConsider = bracketsReachableFrom(0, progression);
- const withoutIntermediateBrackets = bracketsToConsider.filter(
- (bracketIdx) => {
- if (bracketIdx === 0) return true;
+ const ordered = destinationsFirstOrder(bracketsToConsider, progression);
- // underground brackets don't make their source bracket an intermediate one
- const undergrounds = new Set(
- undergroundBracketIdxs(bracketIdx, progression),
- );
+ return ordered.filter((bracketIdx) => {
+ const sources = progression[bracketIdx].sources;
- return progression.every(
- (b, idx) =>
- undergrounds.has(idx) ||
- !b.sources?.some((s) => s.bracketIdx === bracketIdx),
- );
- },
- );
+ if (!sources) return true;
- const withoutUnderground = withoutIntermediateBrackets.filter(
- (bracketIdx) => {
- const sources = progression[bracketIdx].sources;
-
- if (!sources) return true;
-
- return !sources.some(
- (source) =>
- progression[source.bracketIdx].type === "double_elimination" ||
- progression[source.bracketIdx].type === "single_elimination",
- );
- },
- );
-
- const minSourcedPlacements = new Map(
- withoutUnderground.map((idx) => [
- idx,
- minSourcedPlacement(progression, idx),
- ]),
- );
-
- return [...withoutUnderground].sort((a, b) => {
- const minA = minSourcedPlacements.get(a)!;
- const minB = minSourcedPlacements.get(b)!;
-
- if (minA === minB) {
- return a - b;
- }
-
- return minA - minB;
+ return !sources.some(
+ (source) =>
+ (progression[source.bracketIdx].type === "double_elimination" ||
+ progression[source.bracketIdx].type === "single_elimination") &&
+ source.placements.some((placement) => placement < 0),
+ );
});
}
-function minSourcedPlacement(
+/**
+ * Orders the given brackets so that every bracket appears after all the brackets it is a source of.
+ * Among the brackets that are free to be placed next, the one whose teams placed the highest in the
+ * deepest bracket they have in common (e.g. a top cut over a consolation bracket) goes first. The comparison
+ * follows the whole route the teams took, so e.g. a bracket taking the low placements of a redemption bracket
+ * can still rank above a bracket taking mid placements straight from the pools that fed that redemption bracket.
+ */
+function destinationsFirstOrder(
+ bracketIdxs: number[],
progression: ParsedBracket[],
- bracketIdx: number,
-): number {
- const sources = progression[bracketIdx].sources;
- if (!sources || sources.length === 0) return Number.POSITIVE_INFINITY;
+): number[] {
+ const included = new Set(bracketIdxs);
- let min = Number.POSITIVE_INFINITY;
- for (const source of sources) {
- for (const placement of source.placements) {
- if (placement < min) min = placement;
+ const sourcedPlacements = new Map(
+ bracketIdxs.map((bracketIdx) => [
+ bracketIdx,
+ ancestorPlacements(bracketIdx, progression),
+ ]),
+ );
+
+ const pendingDestinations = new Map(
+ bracketIdxs.map((bracketIdx) => [
+ bracketIdx,
+ new Set(
+ destinationsFromBracketIdx(bracketIdx, progression).filter(
+ (destinationIdx) => included.has(destinationIdx),
+ ),
+ ),
+ ]),
+ );
+
+ const result: number[] = [];
+ const remaining = new Set(bracketIdxs);
+
+ while (remaining.size > 0) {
+ const withoutPendingDestinations = Array.from(remaining).filter(
+ (bracketIdx) => pendingDestinations.get(bracketIdx)!.size === 0,
+ );
+ // a cyclic progression is invalid but shouldn't cause an infinite loop here
+ const candidates =
+ withoutPendingDestinations.length > 0
+ ? withoutPendingDestinations
+ : Array.from(remaining);
+
+ const next = bestSourcedBracket(candidates, sourcedPlacements, progression);
+
+ result.push(next);
+ remaining.delete(next);
+
+ for (const bracketIdx of remaining) {
+ pendingDestinations.get(bracketIdx)!.delete(next);
}
}
- return min;
+
+ return result;
+}
+
+/** Of the given brackets, the one whose teams took the best route there, ties broken by the lowest bracket index. */
+function bestSourcedBracket(
+ bracketIdxs: number[],
+ sourcedPlacements: Map>,
+ progression: ParsedBracket[],
+): number {
+ let result = bracketIdxs[0];
+
+ for (const bracketIdx of bracketIdxs.slice(1)) {
+ const comparison = compareSourcedPlacements(
+ sourcedPlacements.get(bracketIdx)!,
+ sourcedPlacements.get(result)!,
+ progression,
+ );
+
+ if (comparison < 0 || (comparison === 0 && bracketIdx < result)) {
+ result = bracketIdx;
+ }
+ }
+
+ return result;
}
export function bracketsReachableFrom(
@@ -1096,3 +1266,144 @@ export function startingBrackets(progression: ParsedBracket[]): number[] {
.filter(({ bracket }) => !bracket.sources)
.map(({ idx }) => idx);
}
+
+/**
+ * Orders a bracket's sources for seeding purposes. Teams sourced with a better placement
+ * in a shared ancestor bracket seed above teams that took a longer route there, e.g. if the top cut
+ * sources both the top 2 of "Day 1 Pools" directly and the winners of a "Redemption" bracket
+ * (itself sourcing pools placements 3-4), the direct pools source is ordered first.
+ *
+ * Sources that share no ancestor bracket keep their original relative order.
+ */
+export function sortedSourcesForSeeding(
+ sources: DBSource[],
+ progression: ParsedBracket[],
+): DBSource[] {
+ const placementMaps = sources.map((source) =>
+ sourcePlacementsByBracket(source, progression),
+ );
+
+ return sources
+ .map((source, idx) => ({ source, idx }))
+ .sort((a, b) =>
+ compareSourcedPlacements(
+ placementMaps[a.idx],
+ placementMaps[b.idx],
+ progression,
+ ),
+ )
+ .map(({ source }) => source);
+}
+
+/** Best (lowest positive) placement the source's teams achieved in each bracket on their route, keyed by bracket index. */
+function sourcePlacementsByBracket(
+ source: DBSource,
+ progression: ParsedBracket[],
+): Map {
+ const result = new Map();
+
+ result.set(source.bracketIdx, bestPositivePlacement(source.placements));
+
+ for (const [ancestorIdx, placement] of ancestorPlacements(
+ source.bracketIdx,
+ progression,
+ )) {
+ mergeMinPlacement(result, ancestorIdx, placement);
+ }
+
+ return result;
+}
+
+function ancestorPlacements(
+ bracketIdx: number,
+ progression: ParsedBracket[],
+ visited: Set = new Set(),
+): Map {
+ const result = new Map();
+
+ if (visited.has(bracketIdx)) return result;
+ visited.add(bracketIdx);
+
+ for (const source of progression[bracketIdx].sources ?? []) {
+ mergeMinPlacement(
+ result,
+ source.bracketIdx,
+ bestPositivePlacement(source.placements),
+ );
+
+ for (const [ancestorIdx, placement] of ancestorPlacements(
+ source.bracketIdx,
+ progression,
+ visited,
+ )) {
+ mergeMinPlacement(result, ancestorIdx, placement);
+ }
+ }
+
+ return result;
+}
+
+function bestPositivePlacement(placements: number[]) {
+ const positives = placements.filter((placement) => placement > 0);
+
+ // empty placements = swiss early advancers i.e. the top teams of that bracket
+ if (positives.length === 0 && placements.length === 0) return 1;
+
+ // negative placements only = teams eliminated from the source bracket
+ if (positives.length === 0) return Number.POSITIVE_INFINITY;
+
+ return Math.min(...positives);
+}
+
+function mergeMinPlacement(
+ map: Map,
+ bracketIdx: number,
+ placement: number,
+) {
+ const existing = map.get(bracketIdx);
+ if (existing === undefined || placement < existing) {
+ map.set(bracketIdx, placement);
+ }
+}
+
+/** Compares two routes by the placement they got in the deepest bracket they have in common. */
+function compareSourcedPlacements(
+ placementsA: Map,
+ placementsB: Map,
+ progression: ParsedBracket[],
+): number {
+ const commonBracketIdx = deepestCommonBracket(
+ placementsA,
+ placementsB,
+ progression,
+ );
+ if (commonBracketIdx === null) return 0;
+
+ const placementA = placementsA.get(commonBracketIdx)!;
+ const placementB = placementsB.get(commonBracketIdx)!;
+
+ if (placementA === placementB) return 0;
+
+ return placementA - placementB;
+}
+
+function deepestCommonBracket(
+ placementsA: Map,
+ placementsB: Map,
+ progression: ParsedBracket[],
+): number | null {
+ let result: number | null = null;
+ let resultDepth = -1;
+
+ for (const bracketIdx of placementsA.keys()) {
+ if (!placementsB.has(bracketIdx)) continue;
+
+ const depth = bracketDepth(bracketIdx, progression);
+ if (depth > resultDepth) {
+ result = bracketIdx;
+ resultDepth = depth;
+ }
+ }
+
+ return result;
+}
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts
index f7721ef7b..c777eedcf 100644
--- a/app/features/tournament-bracket/core/Tournament.ts
+++ b/app/features/tournament-bracket/core/Tournament.ts
@@ -364,9 +364,14 @@ export class Tournament {
}
private resolveTeamsFromSources(
- sources: NonNullable,
+ unsortedSources: NonNullable,
bracketIdx: number,
) {
+ const sources = Progression.sortedSourcesForSeeding(
+ unsortedSources,
+ this.ctx.settings.bracketProgression,
+ );
+
const teams: number[] = [];
let allRelevantMatchesFinished = true;
@@ -493,7 +498,10 @@ export class Tournament {
}
const sources: Seeding.FollowUpBracketSource[] = [];
- for (const source of bracket.sources) {
+ for (const source of Progression.sortedSourcesForSeeding(
+ bracket.sources,
+ this.ctx.settings.bracketProgression,
+ )) {
const sourceBracket = this.bracketByIdx(source.bracketIdx);
if (!sourceBracket) {
logger.warn("followUpBracketSeeding: Source bracket not found");
diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts
index b1302d2c2..5daec49c6 100644
--- a/app/features/tournament-bracket/core/tests/test-utils.ts
+++ b/app/features/tournament-bracket/core/tests/test-utils.ts
@@ -335,6 +335,169 @@ export const progressions = {
],
},
],
+ multiSourceTopCut: [
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "round_robin",
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Redemption",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [3, 4],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Top Cut",
+ sources: [
+ {
+ bracketIdx: 1,
+ placements: [1, 2],
+ },
+ {
+ bracketIdx: 0,
+ placements: [1, 2],
+ },
+ ],
+ },
+ ],
+ multiSourceTopCutWithConsolation: [
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "round_robin",
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Redemption",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [3, 4],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Top Cut",
+ sources: [
+ {
+ bracketIdx: 1,
+ placements: [1, 2],
+ },
+ {
+ bracketIdx: 0,
+ placements: [1, 2],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Consolation",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [5, 6, 7, 8],
+ },
+ ],
+ },
+ ],
+ poolsToBracketsViaIntermediateBrackets: [
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "round_robin",
+ name: "Day 1 Pools",
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Redemption",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [2, 3, 4],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Alpha",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [1],
+ },
+ {
+ bracketIdx: 1,
+ placements: [1, 2, 3, 4, 5, 6, 7, 8],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Beta",
+ sources: [
+ {
+ bracketIdx: 1,
+ placements: [9, 10, 11, 12],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Gamma",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [5, 6],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Delta",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [7, 8],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "round_robin",
+ name: "Epsilon Seeding",
+ sources: [
+ {
+ bracketIdx: 0,
+ placements: [9, 10, 11],
+ },
+ ],
+ },
+ {
+ ...DEFAULT_PROGRESSION_ARGS,
+ type: "single_elimination",
+ name: "Epsilon",
+ sources: [
+ {
+ bracketIdx: 6,
+ placements: [1, 2, 3, 4],
+ },
+ ],
+ },
+ ],
swissToTwoSingleEliminationsWithUnderground: [
{
...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 6c71bcbb7..0a22cc505 100644
--- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx
+++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx
@@ -55,6 +55,7 @@ import { TournamentTeamActions } from "../components/TournamentTeamActions";
import * as AbDivisions from "../core/AbDivisions";
import type { Bracket as BracketType } from "../core/Bracket";
import * as PreparedMaps from "../core/PreparedMaps";
+import * as Progression from "../core/Progression";
import type { BracketMeta, Tournament } from "../core/Tournament";
import {
loader,
@@ -154,44 +155,53 @@ function TournamentBracketsView() {
};
const teamsSourceText = (bracket: BracketType) => {
- const firstBracket = tournament.bracketsMeta[0];
+ const progression = tournament.ctx.settings.bracketProgression;
+ const sources = progression[bracket.idx].sources;
+ if (!sources || sources.length === 0) return null;
- if (firstBracket.type === "round_robin" && !bracket.isUnderground) {
- return `Teams that place in the top ${Math.max(
- ...(bracket.sources ?? []).flatMap((s) => s.placements),
- )} of their group will advance to this stage`;
- }
+ const sourceDescriptions = Progression.sortedSourcesForSeeding(
+ sources,
+ progression,
+ ).map((source) => {
+ const sourceBracket = progression[source.bracketIdx];
- if (firstBracket.type === "round_robin" && bracket.isUnderground) {
- const placements = (
- bracket.sources?.flatMap((s) => s.placements) ?? []
- ).sort((a, b) => a - b);
+ if (source.placements.length === 0) {
+ return t("tournament:bracket.sources.earlyAdvancers", {
+ bracket: sourceBracket.name,
+ count: sourceBracket.settings?.advanceThreshold,
+ });
+ }
- return `Teams that don't advance to the final stage can play in this bracket (placements: ${placements.join(", ")})`;
- }
+ if (source.placements.every((placement) => placement < 0)) {
+ return t("tournament:bracket.sources.eliminated", {
+ bracket: sourceBracket.name,
+ count: Math.abs(Math.min(...source.placements)),
+ });
+ }
- if (firstBracket.type === "double_elimination" && bracket.isUnderground) {
- return `Teams that get eliminated in the first ${Math.abs(
- Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
- )} rounds of the losers bracket can play in this bracket`;
- }
+ const isTopN =
+ !source.rest &&
+ Math.min(...source.placements) === 1 &&
+ Math.max(...source.placements) === source.placements.length;
+ if (isTopN) {
+ return t("tournament:bracket.sources.top", {
+ bracket: sourceBracket.name,
+ count: Math.max(...source.placements),
+ });
+ }
- if (firstBracket.type === "single_elimination" && bracket.isUnderground) {
- return `Teams that get eliminated in the first ${Math.abs(
- Math.min(...(bracket.sources ?? []).flatMap((s) => s.placements)),
- )} rounds can play in this bracket`;
- }
+ return t("tournament:bracket.sources.placements", {
+ bracket: sourceBracket.name,
+ placements: Progression.placementsToString(
+ [...source.placements],
+ source.rest,
+ ),
+ });
+ });
- const advanceThreshold = firstBracket.settings?.advanceThreshold;
- if (
- advanceThreshold &&
- tournament.ctx.settings.bracketProgression[bracket.idx].sources?.[0]
- .placements.length === 0
- ) {
- return `Teams that win at least ${advanceThreshold} sets in the Swiss bracket will advance to this stage`;
- }
-
- return null;
+ return t("tournament:bracket.sources.header", {
+ sources: sourceDescriptions.join(", "),
+ });
};
if (tournament.isLeagueSignup) {
@@ -743,7 +753,7 @@ function StartBracketAlert({
? "Tournament start time is in the future"
: bracket.startTime && bracket.startTime > new Date()
? "Bracket start time is in the future"
- : "Teams pending from the previous bracket"}{" "}
+ : "Teams pending from the source brackets"}{" "}
(blocks starting)
) : null}
diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts
index a13eee60f..391a789b7 100644
--- a/app/features/tournament/core/Standings.test.ts
+++ b/app/features/tournament/core/Standings.test.ts
@@ -134,6 +134,22 @@ describe("tournamentStandings", () => {
]);
});
+ it("places teams eliminated in a redemption bracket above the teams of a lower placed bracket", () => {
+ const tournament = groupsToRedemptionAndConsolationTournament();
+
+ const result = tournamentStandings(tournament);
+
+ invariant(result.type === "single");
+ // team 3 lost the redemption bracket, which it reached by placing 3rd in the groups,
+ // so it is above the teams that placed 5th-8th there and went to the consolation bracket
+ expect(result.standings.map((s) => s.team.id)).toEqual([
+ 1, 2, 4, 3, 5, 6, 7, 8,
+ ]);
+ expect(result.standings.map((s) => s.placement)).toEqual([
+ 1, 2, 3, 4, 5, 6, 7, 8,
+ ]);
+ });
+
it("does not break ties with an underground bracket that was never started", () => {
// an underground bracket set in the progression can be skipped altogether
const tournament = singleEliminationWithUndergroundTournament({
@@ -228,6 +244,15 @@ describe("matchesPlayed", () => {
expect(roundRobinMatches).toHaveLength(3);
expect(singleEliminationMatches).toHaveLength(1);
});
+
+ it("includes matches of brackets that are not part of the standings, in the order they were played", () => {
+ const tournament = roundRobinWithRedemptionTournament();
+
+ const matches = matchesPlayed({ tournament, teamId: 4 });
+
+ // 3 round robin matches, the redemption bracket match and the final stage match
+ expect(matches.map((match) => match.bracketIdx)).toEqual([0, 0, 0, 2, 1]);
+ });
});
function roundRobinToSingleEliminationTournament() {
@@ -262,6 +287,158 @@ function roundRobinToSingleEliminationTournament() {
});
}
+function roundRobinWithRedemptionTournament() {
+ const merged = mergeStages(
+ playOutLowerIdWins(
+ createResolved({
+ type: "round_robin",
+ seeding: [1, 2, 3, 4],
+ settings: { groupCount: 1 },
+ }),
+ ),
+ playOut(
+ createResolved({
+ type: "single_elimination",
+ seeding: [3, 4],
+ settings: {},
+ }),
+ (one, two) => one > two,
+ ),
+ playOutLowerIdWins(
+ createResolved({
+ type: "single_elimination",
+ seeding: [1, 2, 4],
+ settings: {},
+ }),
+ ),
+ );
+
+ // the redemption bracket (idx 2) was played before the final stage (idx 1)
+ const stageNames = ["Groups Stage", "Redemption", "Final Stage"];
+ const data = {
+ ...merged,
+ stage: merged.stage.map((stage, stageIdx) => ({
+ ...stage,
+ name: stageNames[stageIdx],
+ createdAt: stageIdx + 1,
+ })),
+ };
+
+ return testTournament({
+ ctx: {
+ settings: {
+ bracketProgression: [
+ {
+ type: "round_robin",
+ name: "Groups Stage",
+ requiresCheckIn: false,
+ settings: {},
+ },
+ {
+ type: "single_elimination",
+ name: "Final Stage",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [
+ { bracketIdx: 0, placements: [1, 2] },
+ { bracketIdx: 2, placements: [1] },
+ ],
+ },
+ {
+ type: "single_elimination",
+ name: "Redemption",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [{ bracketIdx: 0, placements: [3, 4] }],
+ },
+ ],
+ },
+ teams: [1, 2, 3, 4].map((id) =>
+ tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }),
+ ),
+ },
+ data,
+ });
+}
+
+function groupsToRedemptionAndConsolationTournament() {
+ const data = mergeStages(
+ playOutLowerIdWins(
+ createResolved({
+ type: "round_robin",
+ seeding: [1, 2, 3, 4, 5, 6, 7, 8],
+ settings: { groupCount: 1 },
+ }),
+ ),
+ // the higher seed wins so team 4 advances to the top cut and team 3 is eliminated
+ playOut(
+ createResolved({
+ type: "single_elimination",
+ seeding: [3, 4],
+ settings: {},
+ }),
+ (one, two) => one > two,
+ ),
+ playOutLowerIdWins(
+ createResolved({
+ type: "single_elimination",
+ seeding: [1, 2, 4],
+ settings: {},
+ }),
+ ),
+ playOutLowerIdWins(
+ createResolved({
+ type: "single_elimination",
+ seeding: [5, 6, 7, 8],
+ settings: { consolationFinal: true },
+ }),
+ ),
+ );
+
+ return testTournament({
+ ctx: {
+ settings: {
+ bracketProgression: [
+ {
+ type: "round_robin",
+ name: "Groups",
+ requiresCheckIn: false,
+ settings: { groupCount: 1 },
+ },
+ {
+ type: "single_elimination",
+ name: "Redemption",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [{ bracketIdx: 0, placements: [3, 4] }],
+ },
+ {
+ type: "single_elimination",
+ name: "Top Cut",
+ requiresCheckIn: false,
+ settings: {},
+ sources: [
+ { bracketIdx: 1, placements: [1] },
+ { bracketIdx: 0, placements: [1, 2] },
+ ],
+ },
+ {
+ type: "single_elimination",
+ name: "Consolation",
+ requiresCheckIn: false,
+ settings: { thirdPlaceMatch: true },
+ sources: [{ bracketIdx: 0, placements: [5, 6, 7, 8] }],
+ },
+ ],
+ },
+ teams: [1, 2, 3, 4, 5, 6, 7, 8].map((id) =>
+ tournamentCtxTeam(id, { startingBracketIdx: 0, seed: id }),
+ ),
+ },
+ data,
+ });
+}
+
function singleEliminationTournament() {
const data = playOutLowerIdWins(
createResolved({
diff --git a/app/features/tournament/core/Standings.ts b/app/features/tournament/core/Standings.ts
index 013ccedbb..a903b5988 100644
--- a/app/features/tournament/core/Standings.ts
+++ b/app/features/tournament/core/Standings.ts
@@ -86,7 +86,7 @@ export function calculateSPR({
return expectedIndex - actualIndex;
}
-/** Teams matches that contributed to the standings, in the order they were played in */
+/** Every match the team played, in the order they were played in */
export function matchesPlayed({
tournament,
teamId,
@@ -94,32 +94,13 @@ export function matchesPlayed({
tournament: Tournament;
teamId: number;
}) {
- const startingBracketIdx = tournament.teamById(teamId)?.startingBracketIdx;
+ const bracketsInPlayedOrder = R.sortBy(
+ tournament.brackets,
+ (bracket) => bracket.createdAt ?? Number.POSITIVE_INFINITY,
+ (bracket) => bracket.idx,
+ );
- let bracketIdxs: number[];
-
- if (typeof startingBracketIdx !== "number" || startingBracketIdx === 0) {
- bracketIdxs = Progression.bracketIdxsForStandings(
- tournament.ctx.settings.bracketProgression,
- );
- } else {
- const reachableBrackets = Progression.bracketsReachableFrom(
- startingBracketIdx,
- tournament.ctx.settings.bracketProgression,
- );
- const reachableSet = new Set(reachableBrackets);
-
- const allBracketIdxs = tournament.ctx.settings.bracketProgression
- .map((_, idx) => idx)
- .sort((a, b) => b - a);
- bracketIdxs = allBracketIdxs.filter((idx) => reachableSet.has(idx));
- }
-
- const brackets = bracketIdxs
- .reverse()
- .map((bracketIdx) => tournament.bracketByIdx(bracketIdx)!);
-
- const matches = brackets.flatMap((bracket, i) =>
+ const matches = bracketsInPlayedOrder.flatMap((bracket) =>
bracket.data.match
.filter(
(match) =>
@@ -130,7 +111,7 @@ export function matchesPlayed({
)
.map((match) => ({
...match,
- bracketIdx: bracketIdxs[i],
+ bracketIdx: bracket.idx,
})),
);
diff --git a/e2e/pages/calendar/calendar-new-event-page.ts b/e2e/pages/calendar/calendar-new-event-page.ts
index cc481166e..d348a5d81 100644
--- a/e2e/pages/calendar/calendar-new-event-page.ts
+++ b/e2e/pages/calendar/calendar-new-event-page.ts
@@ -25,6 +25,11 @@ export class CalendarNewEventPage {
placementsInputs: page.getByLabel("Placements"),
deleteBracketButtons: page.getByTestId("brackets-remove-item-button"),
signUpSourceRadios: page.getByRole("radio", { name: "Sign-up" }),
+ // the sources array is nested inside a progression item, so its add button
+ // test id is prefixed by the item's path e.g. "progression[1].sources"
+ addSourceButtons: page.locator(
+ '[data-testid$="sources-add-item-button"]',
+ ),
mapPoolTemplateSelect: page.getByLabel("Template"),
clearMapPoolButton: page.getByRole("button", { name: "Clear" }),
};
@@ -117,4 +122,15 @@ export class CalendarNewEventPage {
await this.locators.bracketFormatSelects.last().selectOption(format);
await this.locators.placementsInputs.last().fill(placements);
}
+
+ async renameBracket(nth: number, name: string) {
+ await this.locators.bracketNameInputs.nth(nth).fill(name);
+ }
+
+ /** Adds another source bracket to the last bracket of the progression. The new
+ * row preselects the first bracket not sourced by it yet. */
+ async addSourceToLastBracket(placements: string) {
+ await this.locators.addSourceButtons.last().click();
+ await this.locators.placementsInputs.last().fill(placements);
+ }
}
diff --git a/e2e/pages/tournament/tournament-brackets-page.ts b/e2e/pages/tournament/tournament-brackets-page.ts
index 1af10541c..45d74e072 100644
--- a/e2e/pages/tournament/tournament-brackets-page.ts
+++ b/e2e/pages/tournament/tournament-brackets-page.ts
@@ -30,6 +30,10 @@ export class TournamentBracketsPage {
streamPopover: page.getByTestId("stream-popover"),
streamPopoverStreams: page.getByTestId("tournament-stream"),
finalizeTournamentButton: page.getByTestId("finalize-tournament-button"),
+ finalizeBracketButton: page.getByTestId("finalize-bracket-button"),
+ teamsPendingFromSourcesText: page.getByText(
+ "Teams pending from the source brackets",
+ ),
startRoundButton: page.getByTestId("start-round-button"),
byeTeam: page.getByTestId("bye-team"),
prepareMapsButton: page.getByTestId("prepare-maps-button"),
diff --git a/e2e/tournament-bracket-multi-stage.spec.ts b/e2e/tournament-bracket-multi-stage.spec.ts
index 386fe9d90..6fe198809 100644
--- a/e2e/tournament-bracket-multi-stage.spec.ts
+++ b/e2e/tournament-bracket-multi-stage.spec.ts
@@ -1,3 +1,4 @@
+import { subMinutes } from "date-fns";
import { ADMIN_ID } from "~/features/admin/admin-constants";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import {
@@ -11,6 +12,7 @@ import {
TO_MAP_POOL,
teamSeeds,
} from "./helpers/tournament";
+import { CalendarNewEventPage } from "./pages/calendar/calendar-new-event-page";
import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page";
import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
@@ -299,6 +301,89 @@ test.describe("Tournament bracket multi stage", () => {
await expect(brackets.match(11)).toBeVisible();
});
+ test("plays out a redemption bracket set up in the tournament creation form", async ({
+ page,
+ factories,
+ }) => {
+ test.slow();
+ const organizer = await factories.UserFactory.create(null, {
+ roles: ["TOURNAMENT_ORGANIZER"],
+ });
+
+ await impersonate(page, organizer.id);
+
+ const newTournament = new CalendarNewEventPage(page);
+ await newTournament.gotoNewTournament();
+
+ await newTournament.form.fill("name", "Redemption Arc");
+ // start time in the past so the brackets can be started right away
+ await newTournament.setFirstDate(subMinutes(new Date(), 30));
+
+ await newTournament.form.select("toToolsMode", "TO");
+ await newTournament.selectMapPoolTemplate("preset:SZ");
+
+ // groups of 4: top 2 advance to the finals directly, 3rd placers get
+ // another shot at the last finals spot through the redemption bracket
+ await newTournament.renameBracket(0, "Groups");
+ await newTournament.setBracketFormat(0, "Round robin");
+ await newTournament.addFollowUpBracket({
+ name: "Redemption",
+ format: "Single elimination",
+ placements: "3",
+ });
+ await newTournament.addFollowUpBracket({
+ name: "Finals",
+ format: "Single elimination",
+ placements: "1-2",
+ });
+ await newTournament.addSourceToLastBracket("1");
+
+ await newTournament.form.submit();
+
+ await expect(page).toHaveURL(/\/to\/\d+/);
+ const tournamentId = Number(page.url().match(/\/to\/(\d+)/)![1]);
+
+ await createTeams(factories, tournamentId, teamSeeds(8));
+ await factories.TournamentFactory.playOut(tournamentId, 0);
+
+ const brackets = new TournamentBracketsPage(page);
+ await brackets.goto(tournamentId);
+
+ await brackets.bracketTab("Groups").click();
+ const groups = await brackets.groupStandingsTeamNames(2);
+ const redemptionTeamNames = groups.map((group) => group[2]);
+
+ // the finals can not be started before the redemption bracket has been played out
+ await brackets.bracketTab("Finals").click();
+ await expect(brackets.locators.teamsPendingFromSourcesText).toBeVisible();
+ await isNotVisible(brackets.locators.finalizeBracketButton);
+
+ await brackets.bracketTab("Redemption").click();
+ await brackets.finalize();
+
+ const redemptionMatchId = Number(
+ await brackets.locators.matches.first().getAttribute("data-match-id"),
+ );
+ const redemptionMatch = await brackets.openMatch(redemptionMatchId);
+ await redemptionMatch.openTab("action");
+ await redemptionMatch.reportResultForTeam({
+ teamName: redemptionTeamNames[0],
+ mapsToReport: 3,
+ });
+ await redemptionMatch.backToBracket();
+
+ await brackets.bracketTab("Finals").click();
+ await isNotVisible(brackets.locators.teamsPendingFromSourcesText);
+ await brackets.finalize();
+
+ // the redemption bracket's winner took the last spot in the finals
+ await expect(
+ brackets.locators.bracketsViewer
+ .getByText(redemptionTeamNames[0])
+ .first(),
+ ).toBeVisible();
+ });
+
test("prepares maps (including third place match linking)", async ({
page,
factories,
diff --git a/locales/da/tournament.json b/locales/da/tournament.json
index 5b0deab6d..be9803ac4 100644
--- a/locales/da/tournament.json
+++ b/locales/da/tournament.json
@@ -155,6 +155,11 @@
"bracket.waiting": "Her vil turneringsplanen blive vist, så snart{{count}} hold har registreret sig",
"bracket.waiting.checkin": "Her vil turneringsplanen blive vist, så snart{{count}} hold er tjekket ind",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Denne turneringsplan er en forhåndsvisning og kan blive ændret",
"bracket.progress.thanksForPlaying": "Tak fordi du deltog i {{eventName}}!",
"bracket.progress.match": "Nuværende modstander: {{opponent}}",
@@ -237,13 +242,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/de/tournament.json b/locales/de/tournament.json
index 4788bfee6..18c286714 100644
--- a/locales/de/tournament.json
+++ b/locales/de/tournament.json
@@ -155,6 +155,11 @@
"bracket.waiting": "Bracket wird hier angezeigt, sobald mindestens {{count}} Teams registriert sind",
"bracket.waiting.checkin": "Bracket wird hier angezeigt, sobald mindestens {{count}} Teams eingecheckt sind",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Dieses Bracket ist eine Vorschau und kann sich ändern",
"bracket.progress.thanksForPlaying": "Danke fürs Spielen von {{eventName}}!",
"bracket.progress.match": "Aktueller Gegner: {{opponent}}",
@@ -237,13 +242,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/en/tournament.json b/locales/en/tournament.json
index b1747cf4f..f4e8b8b08 100644
--- a/locales/en/tournament.json
+++ b/locales/en/tournament.json
@@ -155,6 +155,11 @@
"bracket.waiting": "Bracket will be shown here when at least {{count}} teams have registered",
"bracket.waiting.checkin": "Bracket will be shown here when at least {{count}} teams have checked in",
"bracket.waiting.advanced": "Bracket will be shown here when at least {{count}} teams have advanced",
+ "bracket.sources.header": "Teams joining this bracket: {{sources}}",
+ "bracket.sources.top": "{{bracket}} (top {{count}})",
+ "bracket.sources.placements": "{{bracket}} (placements {{placements}})",
+ "bracket.sources.eliminated": "{{bracket}} (eliminated in the first {{count}} rounds)",
+ "bracket.sources.earlyAdvancers": "{{bracket}} (teams that win {{count}} sets)",
"bracket.wip": "This bracket is a preview and subject to change",
"bracket.progress.thanksForPlaying": "Thanks for playing in {{eventName}}!",
"bracket.progress.match": "Current opponent: {{opponent}}",
@@ -237,13 +242,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
"progression.error.NAME_MISSING": "Bracket name missing",
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
- "progression.error.NO_SE_POSITIVE": "Single elimination is not valid for positive progression",
- "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "One source can't mix advancing placements with eliminated teams",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B divisions can only be enabled on round robin brackets",
"progression.error.AB_DIVISIONS_NOT_STARTING": "A/B divisions can only be enabled on starting brackets",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "A/B divisions requires an even number of teams per group",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "Empty placements are only valid when sourcing from a Swiss bracket with early advance",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "Same bracket can be a source only once per bracket",
+ "progression.error.CYCLIC_PROGRESSION": "Brackets can't source each other in a loop",
+ "progression.error.MERGED_STARTING_BRACKETS": "Teams that started in different brackets can't meet",
"lfg.askCaptainToJoinQueue": "Ask your team's captain or a manager to join the queue",
"customFlow.beforeSet": "Before set",
"customFlow.afterMap": "After map",
diff --git a/locales/es-ES/analyzer.json b/locales/es-ES/analyzer.json
index ee7d3f1aa..aaacc957a 100644
--- a/locales/es-ES/analyzer.json
+++ b/locales/es-ES/analyzer.json
@@ -24,6 +24,7 @@
"stat.specialLost": "Especial perdido al ser reventado",
"stat.specialLostSplattedByRP": "Especial perdido al ser reventado por jugador con Castigo Póstumo",
"stat.tenacitySecondsToSpecial_one": "Tiempo para el especial con Ventaja ({{count}} menos)",
+ "stat.tenacitySecondsToSpecial_many": "",
"stat.tenacitySecondsToSpecial_other": "Tiempo para el especial con Ventaja ({{count}} menos)",
"stat.tenacitySecondsToSpecial.explanation": "El tiempo que tarda Ventaja en llenar el medidor especial desde cero mientras tu equipo tiene menos jugadores activos que el rival, ej. {{teamPlayerCount}} contra {{opponentPlayerCount}}. Solo importa la diferencia entre los equipos, por lo que un duelo igualado como 3 contra 3 no carga el medidor en absoluto.",
"stat.whiteInk": "Tiempo sin recuperar tinta después de su uso",
@@ -108,6 +109,7 @@
"damage.header.baseDamage.short": "Base",
"damage.header.distance": "Distancia",
"damage.toSplat_one": "{{count}} golpe para liquidar",
+ "damage.toSplat_many": "",
"damage.toSplat_other": "{{count}} golpes para liquidar",
"damage.NORMAL_MIN": "Mínimo",
"damage.NORMAL_MAX": "Máximo",
@@ -179,6 +181,7 @@
"dmgHtdExplanation": "DPD = Disparos para destruir",
"noDmgData": "No hay información sobre esta arma. Revisa más tarde.",
"perInkTankGrid.header_one": "{{weapon}} disparos después de ×{{count}} arma secundaria usada",
+ "perInkTankGrid.header_many": "",
"perInkTankGrid.header_other": "{{weapon}} disparos después de ×{{count}} armas secundarias usadas",
"bigBubblerExplanation": "La duración de {{weapon}} también aumenta junto con su durabilidad.",
"button.showChart": "Mostrar gráfico",
@@ -200,6 +203,7 @@
"comp.showWeaponGrid": "Mostrar selector de armas",
"comp.hideWeaponGrid": "Ocultar selector de armas",
"comp.hits_one": "{{count}} golpe",
+ "comp.hits_many": "",
"comp.hits_other": "{{count}} golpes",
"comp.enemyRes": "Impermeabilidad del enemigo",
"comp.enemySubDef": "Resistencia Secundaria del enemigo",
diff --git a/locales/es-ES/art.json b/locales/es-ES/art.json
index 30faf3801..9a5ff6b6c 100644
--- a/locales/es-ES/art.json
+++ b/locales/es-ES/art.json
@@ -1,5 +1,6 @@
{
"pendingApproval_one": "Tienes {{count}} imagen esperando aprobación.",
+ "pendingApproval_many": "",
"pendingApproval_other": "Tienes {{count}} imágenes esperando aprobación.",
"madeBy": "Creada por",
"radios.all": "Todos",
diff --git a/locales/es-ES/badges.json b/locales/es-ES/badges.json
index f406ead7a..cdf8651f1 100644
--- a/locales/es-ES/badges.json
+++ b/locales/es-ES/badges.json
@@ -3,6 +3,7 @@
"patreon+": "Supporter+ de sendou.ink en Patreon",
"xp": "Recibido por alcanzar {{xpText}}",
"tournament_one": "Recibido por ganar {{tournament}}",
+ "tournament_many": "",
"tournament_other": "Recibido por ganar {{tournament}} (×{{count}})",
"forYourEvent": "¿Insignia para tu evento?",
"managedBy": "Administrado por <0>0>",
diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json
index 39317dd05..ba063531a 100644
--- a/locales/es-ES/calendar.json
+++ b/locales/es-ES/calendar.json
@@ -10,8 +10,10 @@
"results": "Resultados",
"createMapList": "Crear lista de mapas",
"count.teams_one": "{{count}} equipo",
+ "count.teams_many": "",
"count.teams_other": "{{count}} equipos",
"count.players_one": "{{count}} jugador",
+ "count.players_many": "",
"count.players_other": "{{count}} jugadores",
"forms.dates": "Fechas",
"forms.bracketUrl": "Enlace de cuadros",
diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json
index c210644e9..8440a5803 100644
--- a/locales/es-ES/forms.json
+++ b/locales/es-ES/forms.json
@@ -71,6 +71,7 @@
"errors.customRoleRequired": "Introduce un nombre para el rol personalizado",
"labels.weaponPool": "Selección de armas",
"placeholders.weaponPoolFull": "Selección llena - elimina un arma para añadir más",
+ "placeholders.vodStartTimestamp": "",
"labels.voiceChat": "Puede usar chat de voz",
"labels.languages": "Tus idiomas",
"options.voiceChat.yes": "Sí",
@@ -96,6 +97,7 @@
"labels.scrimManagedByAnyone": "Cualquiera puede gestionar",
"bottomTexts.scrimManagedByAnyone": "Si se activa, todos los usuarios de esta publicación pueden aceptar solicitudes y eliminarla, no solo el propietario.",
"labels.castTwitchAccounts": "Cuentas de Twitch",
+ "placeholders.castTwitchAccounts": "",
"bottomTexts.castTwitchAccounts": "Cuenta de Twitch donde se retransmite el torneo. Los directos de los jugadores se añaden automáticamente basándose en la información de su perfil.",
"labels.scrimMaps": "Mapas",
"labels.scrimMaxDiv": "Div. máxima",
@@ -103,6 +105,7 @@
"labels.scrimMapSource": "Fuente",
"labels.scrimMapPool": "Rotación de escenarios",
"labels.scrimMapsTournament": "Torneo",
+ "placeholders.scrimMapPool": "",
"options.scrimMapSource.POOL": "URL de la rotación",
"options.scrimMapSource.TOURNAMENT": "Torneo",
"options.scrimFlexibility.notFlexible": "Sin flexibilidad",
@@ -447,6 +450,7 @@
"options.patronTier.1": "Support",
"options.patronTier.2": "Supporter",
"options.patronTier.3": "Supporter+",
+ "placeholders.friendCode": "",
"unsavedChanges.title": "Cambios sin guardar",
"unsavedChanges.body": "¿Estás seguro de que quieres salir? Los cambios que has hecho no se guardarán.",
"unsavedChanges.discard": "Salir de la página",
diff --git a/locales/es-ES/friends.json b/locales/es-ES/friends.json
index 734f60e83..2c5dca420 100644
--- a/locales/es-ES/friends.json
+++ b/locales/es-ES/friends.json
@@ -22,5 +22,6 @@
"view.all": "Todos",
"teamMembers.empty": "Aún no hay miembros en el equipo",
"unseenRequests_one": "{{count}} solicitud de amistad sin ver",
+ "unseenRequests_many": "",
"unseenRequests_other": "{{count}} solicitudes de amistad sin ver"
}
diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json
index 83cd631c9..2e8d94eef 100644
--- a/locales/es-ES/tournament.json
+++ b/locales/es-ES/tournament.json
@@ -70,6 +70,7 @@
"pickInfo.default": "Elección de la comunidad",
"pickInfo.default.explanation": "No había un mapa adecuado en los grupos de los participantes. Este mapa fue seleccionado del conjunto de mapas populares.",
"pickInfo.votes_one": "{{count}} voto",
+ "pickInfo.votes_many": "",
"pickInfo.votes_other": "{{count}} votos",
"pickInfo.teamMapList": "Lista de mapas de {{teamName}}",
"pickInfo.counterpick": "Contraselección",
@@ -124,6 +125,7 @@
"actions.addSub": "Añadir sub",
"actions.shareLink": "Comparte enlace de invitación para añadir miembros: {{inviteLink}}",
"actions.sub.prompt_one": "Aún puedes añadir {{count}} sub a tu equipo",
+ "actions.sub.prompt_many": "",
"actions.sub.prompt_other": "Aún puedes añadir {{count}} subs a tu equipo",
"actions.sub.prompt_zero": "Tu equipo está lleno y no puedes añadir más subs",
"actions.finalize": "Finalizando torneo",
@@ -155,6 +157,11 @@
"bracket.waiting": "El cuadro se mostrará aquí cuando al menos {{count}} equipos se hayan inscrito",
"bracket.waiting.checkin": "El cuadro se mostrará aquí cuando al menos {{count}} equipos hayan hecho check-in",
"bracket.waiting.advanced": "El bracket se mostrará aquí cuando al menos {{count}} equipos hayan avanzado",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Este cuadro es temporal y puede cambiar",
"bracket.progress.thanksForPlaying": "¡Gracias por participar en {{eventName}}!",
"bracket.progress.match": "Oponente actual: {{opponent}}",
@@ -237,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "Nombre de cuadro duplicado",
"progression.error.NAME_MISSING": "Falta el nombre del cuadro",
"progression.error.NEGATIVE_PROGRESSION": "La progresión negativa solo es posible en eliminación doble",
- "progression.error.NO_SE_POSITIVE": "La eliminación directa no es válida para la progresión positiva",
- "progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "Las divisiones A/B solo se pueden activar en brackets de todos contra todos",
"progression.error.AB_DIVISIONS_NOT_STARTING": "Las divisiones A/B solo se pueden activar en brackets iniciales",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "Las divisiones A/B requieren un número par de equipos por grupo",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "Las posiciones vacías solo son válidas cuando provienen de un bracket suizo con avance anticipado",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "Pide al capitán de tu equipo o a un manager que se una a la cola",
"customFlow.beforeSet": "Antes del set",
"customFlow.afterMap": "Después del mapa",
diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json
index 11ad4770c..18f18f0e8 100644
--- a/locales/es-ES/user.json
+++ b/locales/es-ES/user.json
@@ -202,8 +202,10 @@
"seasons.summary.bestTournament": "Mejor torneo",
"seasons.summary.opponentSp": "Sendou Power rival",
"seasons.summary.count.sets_one": "{{count}} set",
+ "seasons.summary.count.sets_many": "",
"seasons.summary.count.sets_other": "{{count}} sets",
"seasons.summary.count.maps_one": "{{count}} mapa",
+ "seasons.summary.count.maps_many": "",
"seasons.summary.count.maps_other": "{{count}} mapas",
"seasons.summary.export": "Exportar imagen",
"seasons.summary.export.supporterPerk": "Exportar la imagen del resumen de esta temporada es una ventaja de supporter. Todos pueden exportar la imagen de la última temporada finalizada durante la pretemporada (off-season).",
@@ -226,6 +228,7 @@
"commissions.closed": "Cerradas",
"mutualFriends": "Amigos en común",
"mutualFriends.count_one": "amigo en común",
+ "mutualFriends.count_many": "",
"mutualFriends.count_other": "amigos en común",
"card.viewUserPage": "Ver página de usuario",
"card.sendFriendRequest": "Enviar solicitud de amistad",
diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json
index 6e04fcfda..3984997b4 100644
--- a/locales/es-US/tournament.json
+++ b/locales/es-US/tournament.json
@@ -157,6 +157,11 @@
"bracket.waiting": "Cuadro se muestra aquí cuando al menos {{count}} equipos sean registrados",
"bracket.waiting.checkin": "Cuadro se muestra aquí cuando al menos {{count}} equipos se hagan check-in",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Este cuadro es temporal y puede cambiar",
"bracket.progress.thanksForPlaying": "¡Gracias por participar en {{eventName}}!",
"bracket.progress.match": "Oponente actual: {{opponent}}",
@@ -239,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json
index a567af998..8e8f7f3cf 100644
--- a/locales/fr-CA/tournament.json
+++ b/locales/fr-CA/tournament.json
@@ -157,6 +157,11 @@
"bracket.waiting": "Le bracket sera affiché ici quand au moins {{count}} équipes seront inscrites",
"bracket.waiting.checkin": "",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Ce bracket est un aperçu et sujet à changement",
"bracket.progress.thanksForPlaying": "Merci d'avoir participé à {{eventName}} !",
"bracket.progress.match": "Adversaire actuel: {{opponent}}",
@@ -239,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json
index 5dfdd1033..e4addfa29 100644
--- a/locales/fr-EU/tournament.json
+++ b/locales/fr-EU/tournament.json
@@ -157,6 +157,11 @@
"bracket.waiting": "Le bracket sera affiché ici quand au moins {{count}} équipes seront inscrites",
"bracket.waiting.checkin": "Le bracket sera affiché ici lorsqu'au moins {{count}} équipes se seront enregistrées",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Ce bracket est un aperçu et sujet à changement",
"bracket.progress.thanksForPlaying": "Merci d'avoir participé à {{eventName}} !",
"bracket.progress.match": "Adversaire actuel: {{opponent}}",
@@ -239,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "Duplicate bracket name",
"progression.error.NAME_MISSING": "Bracket name missing",
"progression.error.NEGATIVE_PROGRESSION": "Negative progression only possible for double elimination",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/he/tournament.json b/locales/he/tournament.json
index cc77dc741..ef9ef4658 100644
--- a/locales/he/tournament.json
+++ b/locales/he/tournament.json
@@ -157,6 +157,11 @@
"bracket.waiting": "מערכים יופיעו כאן כאשר לפחות {{count}} צוותים נרשמו",
"bracket.waiting.checkin": "",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "מערך זה הוא תצוגה מקדימה ונתון לשינויים",
"bracket.progress.thanksForPlaying": "תודה ששיחקתם ב-{{eventName}}!",
"bracket.progress.match": "יריב נוכחי: {{opponent}}",
@@ -239,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/it/tournament.json b/locales/it/tournament.json
index a7b63cd84..9deba33c1 100644
--- a/locales/it/tournament.json
+++ b/locales/it/tournament.json
@@ -157,6 +157,11 @@
"bracket.waiting": "Il bracket verrà mostrato qui una volta che {{count}} team si saranno iscritti",
"bracket.waiting.checkin": "Il bracket verrà mostrato qui una volta che {{count}} team avranno completato il check-in",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Questo bracket è un anteprima ed è soggetto a cambiamenti",
"bracket.progress.thanksForPlaying": "Grazie per aver giocato in {{eventName}}!",
"bracket.progress.match": "Avversario attuale: {{opponent}}",
@@ -239,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "Nome bracket duplicato",
"progression.error.NAME_MISSING": "Nome bracket mancante",
"progression.error.NEGATIVE_PROGRESSION": "La progressione negativa è disponibile solo in doppia eliminazione",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json
index 9e32f75a5..5f51b507c 100644
--- a/locales/ja/tournament.json
+++ b/locales/ja/tournament.json
@@ -151,6 +151,11 @@
"bracket.waiting": "ブラケットは、少なくとも {{count}} チームが登録した時点で表示されます",
"bracket.waiting.checkin": "ブラケットは最低{{count}}チームがチェックインしてから表示されるよ",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "このブラケットはまだプレビューで、変更される可能性があります",
"bracket.progress.thanksForPlaying": "{{eventName}} への参加ありがとうございます!",
"bracket.progress.match": "現在の対戦者: {{opponent}}",
@@ -233,13 +238,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "ブラケットの名前が重複しています",
"progression.error.NAME_MISSING": "ブラケットの名前がありません",
"progression.error.NEGATIVE_PROGRESSION": "逆の進行はダブルエリ三ネーションの時のみ可能です",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json
index 206a03945..51bb6d85e 100644
--- a/locales/ko/tournament.json
+++ b/locales/ko/tournament.json
@@ -151,6 +151,11 @@
"bracket.waiting": "",
"bracket.waiting.checkin": "",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "",
"bracket.progress.thanksForPlaying": "",
"bracket.progress.match": "",
@@ -233,13 +238,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json
index 71b5963a0..1d1d050aa 100644
--- a/locales/nl/tournament.json
+++ b/locales/nl/tournament.json
@@ -155,6 +155,11 @@
"bracket.waiting": "",
"bracket.waiting.checkin": "",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "",
"bracket.progress.thanksForPlaying": "",
"bracket.progress.match": "",
@@ -237,13 +242,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json
index ef79292d9..c26c7178d 100644
--- a/locales/pl/tournament.json
+++ b/locales/pl/tournament.json
@@ -159,6 +159,11 @@
"bracket.waiting": "",
"bracket.waiting.checkin": "",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "",
"bracket.progress.thanksForPlaying": "",
"bracket.progress.match": "",
@@ -241,13 +246,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json
index 6e6cb3274..9f2ed4ccb 100644
--- a/locales/pt-BR/tournament.json
+++ b/locales/pt-BR/tournament.json
@@ -157,6 +157,11 @@
"bracket.waiting": "O bracket será mostrado aqui quando ao menos {{count}} times estiverem registrados",
"bracket.waiting.checkin": "O bracket será mostrado aqui quando pelo menos {{count}} times tiverem feito o check-in",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Esse bracket é uma prévia e poderá mudar",
"bracket.progress.thanksForPlaying": "Obrigado por participar do(a) {{eventName}}!",
"bracket.progress.match": "Oponente atual: {{opponent}}",
@@ -239,13 +244,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "",
"progression.error.NAME_MISSING": "",
"progression.error.NEGATIVE_PROGRESSION": "",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json
index 447a01c45..6acae6d3f 100644
--- a/locales/ru/tournament.json
+++ b/locales/ru/tournament.json
@@ -159,6 +159,11 @@
"bracket.waiting": "Сетка будет показана как только {{count}} команд зарегистрируется",
"bracket.waiting.checkin": "Сетка будет показана как только {{count}} команд пройдут чек-ин",
"bracket.waiting.advanced": "",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "Данная сетка является предварительной и может быть изменена.",
"bracket.progress.thanksForPlaying": "Спасибо за участие в {{eventName}}!",
"bracket.progress.match": "Текущий противник: {{opponent}}",
@@ -241,13 +246,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "Дубликат имени сетки",
"progression.error.NAME_MISSING": "Имя сетки отсутствует",
"progression.error.NEGATIVE_PROGRESSION": "Отрицательная прогрессия возможна только в Double Elimination турнирах",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "Double elimination не валидно для позитивной прогрессии",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "",
"progression.error.AB_DIVISIONS_NOT_STARTING": "",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "",
"customFlow.beforeSet": "",
"customFlow.afterMap": "",
diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json
index 6aba89afa..2625f86fa 100644
--- a/locales/zh/tournament.json
+++ b/locales/zh/tournament.json
@@ -153,6 +153,11 @@
"bracket.waiting": "当至少有 {{count}} 支队伍报名后,对战表将在此显示",
"bracket.waiting.checkin": "当至少有 {{count}} 支队伍签到后,对战表将在此显示",
"bracket.waiting.advanced": "当至少有 {{count}} 支队伍晋级后,对战表将在此显示",
+ "bracket.sources.header": "",
+ "bracket.sources.top": "",
+ "bracket.sources.placements": "",
+ "bracket.sources.eliminated": "",
+ "bracket.sources.earlyAdvancers": "",
"bracket.wip": "此对战表为预览版本,可能会有变动",
"bracket.progress.thanksForPlaying": "感谢您参加 {{eventName}}!",
"bracket.progress.match": "当前对手: {{opponent}}",
@@ -235,13 +240,15 @@
"progression.error.DUPLICATE_BRACKET_NAME": "对战表名称重复",
"progression.error.NAME_MISSING": "缺少对战表名称",
"progression.error.NEGATIVE_PROGRESSION": "负序晋级仅在双败淘汰赛中可行",
- "progression.error.NO_SE_POSITIVE": "",
- "progression.error.NO_DE_POSITIVE": "双败淘汰赛不适用于正序晋级",
+ "progression.error.MIXED_POSITIVE_NEGATIVE_PLACEMENTS": "",
"progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "包含提前晋级/淘汰的瑞士轮对战表必须导向另一个对战表",
"progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B 分组只能在循环赛对战表中启用",
"progression.error.AB_DIVISIONS_NOT_STARTING": "A/B 分组只能在初始对战表中启用",
"progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "A/B 分组要求每个小组的队伍数量为偶数",
"progression.error.EMPTY_PLACEMENTS_ON_NON_SWISS": "空名次仅在来源于包含提前晋级的瑞士轮对战表时有效",
+ "progression.error.DUPLICATE_SOURCE_BRACKET": "",
+ "progression.error.CYCLIC_PROGRESSION": "",
+ "progression.error.MERGED_STARTING_BRACKETS": "",
"lfg.askCaptainToJoinQueue": "请让您的队伍队长或管理员加入队列",
"customFlow.beforeSet": "本轮对局前",
"customFlow.afterMap": "本局结束后",