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>", 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": "本局结束后",