Support "rest" syntax for bracket progression

Closes #3057
This commit is contained in:
Kalle
2026-05-24 15:50:03 +03:00
parent bb25ba3d8f
commit f8a4a2b493
8 changed files with 411 additions and 74 deletions

View File

@@ -278,6 +278,7 @@ export const bracketProgressionSchema = z.preprocess(
z.object({
bracketIdx: z.number(),
placements: z.array(z.number()),
rest: z.boolean().optional(),
}),
)
.optional(),

View File

@@ -577,40 +577,47 @@ function SourcesSelector({
const inputBracket = brackets.find((b) => b.id === source?.bracketId);
return (
<div className="stack horizontal sm items-end">
<div>
<Label htmlFor={createId("bracket")}>Bracket</Label>
<select
id={createId("bracket")}
value={source?.bracketId ?? brackets[0].id}
onChange={(e) =>
onChange({ placements: "", ...source, bracketId: e.target.value })
}
>
{brackets.map((bracket) => (
<option key={bracket.id} value={bracket.id}>
{bracket.name}
</option>
))}
</select>
<div>
<div className="stack horizontal sm items-end">
<div>
<Label htmlFor={createId("bracket")}>Bracket</Label>
<select
id={createId("bracket")}
value={source?.bracketId ?? brackets[0].id}
onChange={(e) =>
onChange({ placements: "", ...source, bracketId: e.target.value })
}
>
{brackets.map((bracket) => (
<option key={bracket.id} value={bracket.id}>
{bracket.name}
</option>
))}
</select>
</div>
{!inputBracket?.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
) : null}
</div>
{!inputBracket?.settings.advanceThreshold ? (
<div>
<Label htmlFor={createId("placements")}>Placements</Label>
<Input
id={createId("placements")}
placeholder="1,2,3"
value={source?.placements ?? ""}
testId="placements-input"
onChange={(e) =>
onChange({
bracketId: brackets[0].id,
...source,
placements: e.target.value,
})
}
/>
</div>
<FormMessage type="info">
Use N+ for Nth place and every placement after
</FormMessage>
) : null}
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, number[]>();
// 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<number>();
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 }))

View File

@@ -229,6 +229,7 @@ export class Tournament {
sourceBracket.source({
placements: source.placements,
advanceThreshold: sourceBracket.settings?.advanceThreshold,
rest: source.rest,
});
if (!relevantMatchesFinished) {
allRelevantMatchesFinished = false;