-
-
+
+
+
+
+
+
+ {!inputBracket?.settings.advanceThreshold ? (
+
+
+
+ onChange({
+ bracketId: brackets[0].id,
+ ...source,
+ placements: e.target.value,
+ })
+ }
+ />
+
+ ) : null}
{!inputBracket?.settings.advanceThreshold ? (
-
-
-
- onChange({
- bracketId: brackets[0].id,
- ...source,
- placements: e.target.value,
- })
- }
- />
-
+
+ Use N+ for Nth place and every placement after
+
) : null}
);
diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts
index 120a9ff7b..298e57988 100644
--- a/app/features/tournament-bracket/core/Bracket/Bracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts
@@ -424,7 +424,11 @@ export abstract class Bracket {
return this.teamsPendingCheckIn.includes(team.id);
}
- source(_options: { placements: number[]; advanceThreshold?: number }): {
+ source(_options: {
+ placements: number[];
+ advanceThreshold?: number;
+ rest?: boolean;
+ }): {
relevantMatchesFinished: boolean;
teams: number[];
} {
diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts
index 44232ff27..039983087 100644
--- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts
@@ -12,7 +12,7 @@ export class RoundRobinBracket extends Bracket {
return true;
}
- source({ placements }: { placements: number[] }): {
+ source({ placements, rest }: { placements: number[]; rest?: boolean }): {
relevantMatchesFinished: boolean;
teams: number[];
} {
@@ -24,10 +24,18 @@ export class RoundRobinBracket extends Bracket {
const relevantMatchesFinished =
standings.length === this.participantTournamentTeamIds.length;
+ const maxExplicit = Math.max(...placements);
+ const matchesPlacement = (p: number) =>
+ placements.includes(p) || (rest === true && p >= maxExplicit);
+
if (this.settings?.hasAbDivisions) {
return {
relevantMatchesFinished,
- teams: this.teamsFromPlacementsPerAbDivision(standings, placements),
+ teams: this.teamsFromPlacementsPerAbDivision(
+ standings,
+ placements,
+ rest === true,
+ ),
};
}
@@ -41,7 +49,7 @@ export class RoundRobinBracket extends Bracket {
return {
relevantMatchesFinished,
teams: standings
- .filter((s) => placements.includes(placementNormalized(s.placement)))
+ .filter((s) => matchesPlacement(placementNormalized(s.placement)))
.map((s) => s.team.id),
};
}
@@ -49,19 +57,28 @@ export class RoundRobinBracket extends Bracket {
private teamsFromPlacementsPerAbDivision(
standings: Standing[],
placements: number[],
+ rest: boolean,
): number[] {
const groupIds = R.unique(
standings
.map((s) => s.groupId)
.filter((id): id is number => typeof id === "number"),
);
+ const maxExplicit = Math.max(...placements);
const teams: number[] = [];
for (const groupId of groupIds) {
for (const division of [0, 1] as const) {
const divisionStandings = standings.filter(
(s) => s.groupId === groupId && s.team.abDivision === division,
);
- for (const placement of placements) {
+ const maxPlacement = rest ? divisionStandings.length : maxExplicit;
+ for (let placement = 1; placement <= maxPlacement; placement++) {
+ if (
+ !placements.includes(placement) &&
+ !(rest && placement >= maxExplicit)
+ ) {
+ continue;
+ }
const standing = divisionStandings[placement - 1];
if (standing) teams.push(standing.team.id);
}
diff --git a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts
index b1a6294eb..01b567c43 100644
--- a/app/features/tournament-bracket/core/Bracket/SwissBracket.ts
+++ b/app/features/tournament-bracket/core/Bracket/SwissBracket.ts
@@ -18,9 +18,11 @@ export class SwissBracket extends Bracket {
source({
placements,
advanceThreshold,
+ rest,
}: {
placements: number[];
advanceThreshold?: number;
+ rest?: boolean;
}): {
relevantMatchesFinished: boolean;
teams: number[];
@@ -84,10 +86,14 @@ export class SwissBracket extends Bracket {
return uniquePlacements.indexOf(p) + 1;
};
+ const maxExplicit = Math.max(...placements);
+ const matchesPlacement = (p: number) =>
+ placements.includes(p) || (rest === true && p >= maxExplicit);
+
return {
relevantMatchesFinished,
teams: standings
- .filter((s) => placements.includes(placementNormalized(s.placement)))
+ .filter((s) => matchesPlacement(placementNormalized(s.placement)))
.map((s) => s.team.id),
};
}
diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts
index 85d658d68..d897bcf6a 100644
--- a/app/features/tournament-bracket/core/Progression.test.ts
+++ b/app/features/tournament-bracket/core/Progression.test.ts
@@ -258,6 +258,228 @@ describe("validatedSources - PLACEMENTS_PARSE_ERROR", () => {
});
});
+describe('validatedSources - rest "N+" syntax', () => {
+ const getValidatedBracketsFromPlacements = (placements: string) => {
+ return Progression.validatedBrackets([
+ {
+ id: "1",
+ name: "Bracket 1",
+ type: "round_robin",
+ settings: { teamsPerGroup: 8 },
+ requiresCheckIn: false,
+ },
+ {
+ id: "2",
+ name: "Bracket 2",
+ type: "single_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [
+ {
+ bracketId: "1",
+ placements,
+ },
+ ],
+ },
+ ]);
+ };
+
+ it("parses lone rest placement", () => {
+ const result = getValidatedBracketsFromPlacements(
+ "5+",
+ ) as Progression.ParsedBracket[];
+
+ expect(result[1].sources).toEqual([
+ { bracketIdx: 0, placements: [5], rest: true },
+ ]);
+ });
+
+ it("parses rest from first placement", () => {
+ const result = getValidatedBracketsFromPlacements(
+ "1+",
+ ) as Progression.ParsedBracket[];
+
+ expect(result[1].sources).toEqual([
+ { bracketIdx: 0, placements: [1], rest: true },
+ ]);
+ });
+
+ it("parses rest combined with explicit placements", () => {
+ const result = getValidatedBracketsFromPlacements(
+ "1,2,3-4,5+",
+ ) as Progression.ParsedBracket[];
+
+ expect(result[1].sources).toEqual([
+ { bracketIdx: 0, placements: [1, 2, 3, 4, 5], rest: true },
+ ]);
+ });
+
+ it("parses range-then-rest as a single element", () => {
+ const result = getValidatedBracketsFromPlacements(
+ "1-5+",
+ ) as Progression.ParsedBracket[];
+
+ expect(result[1].sources).toEqual([
+ { bracketIdx: 0, placements: [1, 2, 3, 4, 5], rest: true },
+ ]);
+ });
+
+ it("rejects rest in non-final position", () => {
+ const error = getValidatedBracketsFromPlacements(
+ "5+,6",
+ ) as Progression.ValidationError;
+ expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
+ });
+
+ it("rejects double plus", () => {
+ const error = getValidatedBracketsFromPlacements(
+ "5++",
+ ) as Progression.ValidationError;
+ expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
+ });
+
+ it("rejects lone plus", () => {
+ const error = getValidatedBracketsFromPlacements(
+ "+",
+ ) as Progression.ValidationError;
+ expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
+ });
+
+ it("rejects rest on zero placement", () => {
+ const error = getValidatedBracketsFromPlacements(
+ "0+",
+ ) as Progression.ValidationError;
+ expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
+ });
+
+ it("rejects rest on negative placement", () => {
+ const error = Progression.validatedBrackets([
+ {
+ id: "1",
+ name: "Bracket 1",
+ type: "double_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ },
+ {
+ id: "2",
+ name: "Bracket 2",
+ type: "single_elimination",
+ settings: {},
+ requiresCheckIn: false,
+ sources: [
+ {
+ bracketId: "1",
+ placements: "-1+",
+ },
+ ],
+ },
+ ]) as Progression.ValidationError;
+ expect(error.type).toBe("PLACEMENTS_PARSE_ERROR");
+ });
+
+ it("round-trips lone rest via input format", () => {
+ const validated = getValidatedBracketsFromPlacements(
+ "5+",
+ ) as Progression.ParsedBracket[];
+ const inputFormat = Progression.validatedBracketsToInputFormat(validated);
+ expect(inputFormat[1].sources?.[0].placements).toBe("5+");
+ });
+
+ it("round-trips combined rest via input format", () => {
+ const validated = getValidatedBracketsFromPlacements(
+ "1,2,3-4,5+",
+ ) as Progression.ParsedBracket[];
+ const inputFormat = Progression.validatedBracketsToInputFormat(validated);
+ expect(inputFormat[1].sources?.[0].placements).toBe("1-4,5+");
+ });
+
+ it("destinationByPlacement routes placements beyond the rest threshold", () => {
+ const validated = getValidatedBracketsFromPlacements(
+ "5+",
+ ) as Progression.ParsedBracket[];
+ expect(
+ Progression.destinationByPlacement({
+ sourceBracketIdx: 0,
+ placement: 10,
+ progression: validated,
+ }),
+ ).toBe(1);
+ expect(
+ Progression.destinationByPlacement({
+ sourceBracketIdx: 0,
+ placement: 4,
+ progression: validated,
+ }),
+ ).toBe(null);
+ });
+
+ it("flags SAME_PLACEMENT_TO_MULTIPLE_BRACKETS when two rest sources share a bracket", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: { teamsPerGroup: 8 },
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "1-4" }],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "5+" }],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "6+" }],
+ },
+ ]) as Progression.ValidationError;
+ expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS");
+ });
+
+ it("flags SAME_PLACEMENT_TO_MULTIPLE_BRACKETS when rest overlaps an explicit placement", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: { teamsPerGroup: 8 },
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "1-4,5+" }],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "7" }],
+ },
+ ]) as Progression.ValidationError;
+ expect(error.type).toBe("SAME_PLACEMENT_TO_MULTIPLE_BRACKETS");
+ });
+
+ it("still flags TOO_MANY_PLACEMENTS when rest's explicit max exceeds teamsPerGroup", () => {
+ const error = getValidatedBrackets([
+ {
+ settings: { teamsPerGroup: 4 },
+ type: "round_robin",
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "1-4" }],
+ },
+ {
+ settings: {},
+ type: "single_elimination",
+ sources: [{ bracketId: "0", placements: "5+" }],
+ },
+ ]) as Progression.ValidationError;
+ expect(error.type).toBe("TOO_MANY_PLACEMENTS");
+ });
+});
+
const getValidatedBrackets = (
brackets: (Omit<
Progression.InputBracket,
diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts
index e7ed54969..4361b94e1 100644
--- a/app/features/tournament-bracket/core/Progression.ts
+++ b/app/features/tournament-bracket/core/Progression.ts
@@ -12,6 +12,8 @@ export interface DBSource {
bracketIdx: number;
/** Team placements that join this bracket. E.g. [1, 2] would mean top 1 & 2 teams. [-1] would mean the last placing teams. Can be empty array for Swiss brackets with early advance. */
placements: number[];
+ /** When true, the highest value in `placements` is treated as "and every placement after that". Set by the "N+" rest syntax. Only valid with positive placements. */
+ rest?: boolean;
}
export interface EditableSource {
@@ -142,14 +144,14 @@ export function validatedBracketsToInputFormat(
bracketId: String(source.bracketIdx),
placements:
source.placements.length > 0
- ? placementsToString(source.placements)
+ ? placementsToString(source.placements, source.rest)
: "",
})),
};
});
}
-function placementsToString(placements: number[]): string {
+function placementsToString(placements: number[], rest = false): string {
if (placements.length === 0) return "";
placements.sort((a, b) => a - b);
@@ -159,28 +161,30 @@ function placementsToString(placements: number[]): string {
return placements.join(",");
}
- const ranges: string[] = [];
- let start = placements[0];
- let end = placements[0];
+ const highest = placements[placements.length - 1];
+ const allButHighest = rest ? placements.slice(0, -1) : placements;
- for (let i = 1; i < placements.length; i++) {
- if (placements[i] === end + 1) {
- end = placements[i];
- } else {
- if (start === end) {
- ranges.push(`${start}`);
+ const ranges: string[] = [];
+
+ if (allButHighest.length > 0) {
+ let start = allButHighest[0];
+ let end = allButHighest[0];
+
+ for (let i = 1; i < allButHighest.length; i++) {
+ if (allButHighest[i] === end + 1) {
+ end = allButHighest[i];
} else {
- ranges.push(`${start}-${end}`);
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
+ start = allButHighest[i];
+ end = allButHighest[i];
}
- start = placements[i];
- end = placements[i];
}
+
+ ranges.push(start === end ? `${start}` : `${start}-${end}`);
}
- if (start === end) {
- ranges.push(String(start));
- } else {
- ranges.push(`${start}-${end}`);
+ if (rest) {
+ ranges.push(`${highest}+`);
}
return ranges.join(",");
@@ -353,27 +357,28 @@ function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] {
? dateToDatabaseTimestamp(bracket.startTime)
: undefined,
sources: bracket.sources?.map((source) => {
- const placements = parsePlacements(source.placements);
+ const parsed = parsePlacements(source.placements);
const sourceBracketIdx = brackets.findIndex(
(b) => b.id === source.bracketId,
);
const sourceBracket = brackets[sourceBracketIdx];
// Allow empty placements only for Swiss brackets with early advance
- if (placements && placements.length === 0) {
+ if (parsed && parsed.placements.length === 0) {
const isSwissWithEarlyAdvance =
sourceBracket?.type === "swiss" &&
sourceBracket?.settings?.advanceThreshold;
if (!isSwissWithEarlyAdvance) {
throw { badBracketIdx: bracketIdx };
}
- } else if (placements === null) {
+ } else if (parsed === null) {
throw { badBracketIdx: bracketIdx };
}
return {
bracketIdx: sourceBracketIdx,
- placements: placements ?? [],
+ placements: parsed?.placements ?? [],
+ ...(parsed?.rest ? { rest: true as const } : {}),
};
}),
};
@@ -391,18 +396,21 @@ function toOutputBracketFormat(brackets: InputBracket[]): ParsedBracket[] {
return result;
}
-function parsePlacements(placements: string) {
- // Handle empty string case
+function parsePlacements(
+ placements: string,
+): { placements: number[]; rest: boolean } | null {
if (placements.trim() === "") {
- return [];
+ return { placements: [], rest: false };
}
- const parts = placements.split(",");
+ const parts = placements.split(",").map((p) => p.trim());
const result: number[] = [];
+ let rest = false;
- for (let part of parts) {
- part = part.trim();
+ for (let i = 0; i < parts.length; i++) {
+ const part = parts[i];
+ const isLast = i === parts.length - 1;
const isNegative = part.match(/^-\d+$/);
if (isNegative) {
@@ -410,21 +418,35 @@ function parsePlacements(placements: string) {
continue;
}
+ const restMatch = part.match(/^(\d+)(?:-(\d+))?\+$/);
+ if (restMatch) {
+ if (!isLast || part === "0+") return null;
+ rest = true;
+
+ const start = Number(restMatch[1]);
+ const end = restMatch[2] ? Number(restMatch[2]) : start;
+ if (end < start) return null;
+ for (let n = start; n <= end; n++) {
+ result.push(n);
+ }
+ continue;
+ }
+
const isValid = part.match(/^\d+(-\d+)?$/) && part !== "0";
if (!isValid) return null;
if (part.includes("-")) {
const [start, end] = part.split("-").map(Number);
- for (let i = start; i <= end; i++) {
- result.push(i);
+ for (let n = start; n <= end; n++) {
+ result.push(n);
}
} else {
result.push(Number(part));
}
}
- return result;
+ return { placements: result, rest };
}
function resolvesWinner(brackets: ParsedBracket[]) {
@@ -443,6 +465,11 @@ function resolvesWinner(brackets: ParsedBracket[]) {
function samePlacementToMultipleBrackets(brackets: ParsedBracket[]) {
const map = new Map
();
+ // per source bracketIdx: list of { destinationBracketIdx, restFromPlacement }
+ const restSources = new Map<
+ number,
+ { destinationBracketIdx: number; restFromPlacement: number }[]
+ >();
for (const [bracketIdx, bracket] of brackets.entries()) {
if (!bracket.sources) continue;
@@ -457,18 +484,60 @@ function samePlacementToMultipleBrackets(brackets: ParsedBracket[]) {
map.get(id)!.push(bracketIdx);
}
+
+ if (source.rest && source.placements.length > 0) {
+ const positives = source.placements.filter((p) => p > 0);
+ if (positives.length === 0) continue;
+ const restFromPlacement = Math.max(...positives);
+
+ if (!restSources.has(source.bracketIdx)) {
+ restSources.set(source.bracketIdx, []);
+ }
+ restSources.get(source.bracketIdx)!.push({
+ destinationBracketIdx: bracketIdx,
+ restFromPlacement,
+ });
+ }
}
}
- const result: number[] = [];
+ const result = new Set();
for (const [_, bracketIdxs] of map) {
if (bracketIdxs.length > 1) {
- result.push(...bracketIdxs);
+ for (const idx of bracketIdxs) result.add(idx);
}
}
- return result.length ? result : null;
+ for (const [sourceBracketIdx, restList] of restSources) {
+ // multiple "rest" sources from same bracket = conflict
+ if (restList.length > 1) {
+ for (const { destinationBracketIdx } of restList) {
+ result.add(destinationBracketIdx);
+ }
+ }
+
+ // any other source that claims a placement >= restFromPlacement = conflict
+ const restEntry = restList[0];
+ if (!restEntry) continue;
+ for (const [otherBracketIdx, otherBracket] of brackets.entries()) {
+ if (!otherBracket.sources) continue;
+ for (const otherSource of otherBracket.sources) {
+ if (otherSource.bracketIdx !== sourceBracketIdx) continue;
+ if (otherBracketIdx === restEntry.destinationBracketIdx) continue;
+ if (
+ otherSource.placements.some(
+ (p) => p > 0 && p >= restEntry.restFromPlacement,
+ )
+ ) {
+ result.add(otherBracketIdx);
+ result.add(restEntry.destinationBracketIdx);
+ }
+ }
+ }
+ }
+
+ return result.size > 0 ? [...result] : null;
}
function duplicateNames(brackets: ParsedBracket[]) {
@@ -973,14 +1042,24 @@ export function destinationByPlacement({
);
const destination = destinations.find((destinationBracketIdx) =>
- progression[destinationBracketIdx].sources?.some((source) =>
- source.placements.includes(placement),
+ progression[destinationBracketIdx].sources?.some(
+ (source) =>
+ source.bracketIdx === sourceBracketIdx &&
+ sourceClaimsPlacement(source, placement),
),
);
return destination ?? null;
}
+function sourceClaimsPlacement(source: DBSource, placement: number): boolean {
+ if (source.placements.includes(placement)) return true;
+ if (source.rest && source.placements.length > 0 && placement > 0) {
+ return placement >= Math.max(...source.placements);
+ }
+ return false;
+}
+
export function startingBrackets(progression: ParsedBracket[]): number[] {
return progression
.map((bracket, idx) => ({ bracket, idx }))
diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts
index b143b4bb5..958a420cf 100644
--- a/app/features/tournament-bracket/core/Tournament.ts
+++ b/app/features/tournament-bracket/core/Tournament.ts
@@ -229,6 +229,7 @@ export class Tournament {
sourceBracket.source({
placements: source.placements,
advanceThreshold: sourceBracket.settings?.advanceThreshold,
+ rest: source.rest,
});
if (!relevantMatchesFinished) {
allRelevantMatchesFinished = false;