Many source brackets (#3312)

This commit is contained in:
Kalle
2026-08-06 17:36:42 +03:00
committed by GitHub
parent 58b8312205
commit 0b005ce30a
40 changed files with 1993 additions and 293 deletions

View File

@@ -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",
);
});
});

View File

@@ -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);
}

View File

@@ -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 (
<div className="stack md items-start">
@@ -246,20 +268,19 @@ function ProgressionEntryFields({
disabled={isFirstBracket || isSourceLocked}
/>
{!isFirstBracket && entry.source === "BRACKET" ? (
<>
<FormField
name={`${itemName}.sourceBracketIdx`}
options={sourceBracketOptions}
disabled={isDisabled}
/>
{!sourceBracketHasEarlyAdvance(brackets, entry) ? (
<FormField
name={`${itemName}.placements`}
disabled={isDisabled}
labelPopover={<PlacementsSyntaxPopover />}
<FormField
name={`${itemName}.sources`}
disabled={isDisabled}
onValueChange={handleSourcesChanged}
>
{(sourceRenderContext: ArrayItemRenderContext) => (
<SourceFields
renderContext={sourceRenderContext}
destinationBracketIdx={index}
isDisabled={isDisabled}
/>
) : null}
</>
)}
</FormField>
) : (
<FormMessage type="info">
{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 (
<div className="stack md items-start">
<FormField
name={`${itemName}.bracketIdx`}
options={bracketOptions}
disabled={isDisabled}
/>
{!sourceBracketHasEarlyAdvance(brackets, source) ? (
<FormField
name={`${itemName}.placements`}
disabled={isDisabled}
labelPopover={<PlacementsSyntaxPopover />}
/>
) : null}
</div>
);
}
function PlacementsSyntaxPopover() {
return (
<InfoPopover tiny>
@@ -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;
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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,

View File

@@ -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));

View File

@@ -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]);
});
});

View File

@@ -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<number>();
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<number>();
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<number, Set<number>>();
const startingAncestors = (bracketIdx: number): Set<number> => {
const cached = cache.get(bracketIdx);
if (cached) return cached;
const sources = brackets[bracketIdx]?.sources;
const result = new Set<number>();
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<number>();
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>,
): 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<number, Map<number, number>>,
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<number, number> {
const result = new Map<number, number>();
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<number> = new Set(),
): Map<number, number> {
const result = new Map<number, number>();
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<number, number>,
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<number, number>,
placementsB: Map<number, number>,
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<number, number>,
placementsB: Map<number, number>,
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;
}

View File

@@ -364,9 +364,14 @@ export class Tournament {
}
private resolveTeamsFromSources(
sources: NonNullable<Progression.ParsedBracket["sources"]>,
unsortedSources: NonNullable<Progression.ParsedBracket["sources"]>,
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");

View File

@@ -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,

View File

@@ -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)
</div>
) : null}

View File

@@ -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({

View File

@@ -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,
})),
);

View File

@@ -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);
}
}

View File

@@ -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"),

View File

@@ -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,

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -3,6 +3,7 @@
"patreon+": "Supporter+ de sendou.ink en Patreon",
"xp": "Recibido por alcanzar {{xpText}}",
"tournament_one": "Recibido por ganar {{tournament}}",
"tournament_many": "",
"tournament_other": "Recibido por ganar {{tournament}} (×{{count}})",
"forYourEvent": "¿Insignia para tu evento?",
"managedBy": "Administrado por <0></0>",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

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