mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-28 14:18:04 -05:00
Many source brackets (#3312)
This commit is contained in:
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "本局结束后",
|
||||
|
||||
Reference in New Issue
Block a user