diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index 931c3956b..a08da1c9f 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -278,6 +278,7 @@ export const bracketProgressionSchema = z.preprocess( z.object({ bracketIdx: z.number(), placements: z.array(z.number()), + rest: z.boolean().optional(), }), ) .optional(), diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx index 3b476e346..65593e517 100644 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -577,40 +577,47 @@ function SourcesSelector({ const inputBracket = brackets.find((b) => b.id === source?.bracketId); return ( -
-
- - +
+
+
+ + +
+ {!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;