diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 3a85b9553..5cb8e15bb 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -819,3 +819,77 @@ describe("double elimination standings - projected ties", () => { } }); }); + +describe("single elimination source - underground", () => { + // 8-team SE played out fully. The four first-round losers tie for last, so + // sourcing [-1] should feed exactly those teams into an underground bracket. + const playedSingleEliminationTournament = () => { + const storage = new InMemoryDatabase(); + const manager = new BracketsManager(storage); + + manager.create({ + name: "SE", + tournamentId: 1, + type: "single_elimination", + seeding: [1, 2, 3, 4, 5, 6, 7, 8], + settings: {}, + }); + + const winnersGroupId = Math.min( + ...storage.select("group")!.map((g) => g.id), + ); + const firstRoundId = Math.min( + ...storage + .select("round")! + .filter((r) => r.group_id === winnersGroupId) + .map((r) => r.id), + ); + + // lower id wins, so the higher id in each first-round match is the loser + const firstRoundLoserIds = readyMatches( + storage, + (m) => m.round_id === firstRoundId, + ).map((m) => Math.max(m.opponent1.id, m.opponent2.id)); + + let ready = readyMatches(storage, (m) => m.group_id === winnersGroupId); + while (ready.length) { + for (const match of ready) { + reportLowerIdWinner(storage, manager, match.id); + } + ready = readyMatches(storage, (m) => m.group_id === winnersGroupId); + } + + const tournament = testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "single_elimination", + name: "SE", + requiresCheckIn: false, + settings: {}, + sources: [], + }, + ], + }, + }, + data: manager.get.tournamentData(1), + }); + + return { tournament, firstRoundLoserIds }; + }; + + it("sources the first-round losers when placements are [-1]", () => { + const { tournament, firstRoundLoserIds } = + playedSingleEliminationTournament(); + + const { teams, relevantMatchesFinished } = tournament + .bracketByIdx(0)! + .source({ placements: [-1] }); + + expect(relevantMatchesFinished).toBe(true); + expect([...teams].sort((a, b) => a - b)).toEqual( + [...firstRoundLoserIds].sort((a, b) => a - b), + ); + }); +}); diff --git a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts index 3cb528cf3..eec96a6fb 100644 --- a/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/SingleEliminationBracket.ts @@ -161,4 +161,61 @@ export class SingleEliminationBracket extends Bracket { return this.standingsWithoutNonParticipants(resultWithThirdPlaceTiebroken); } + + source({ placements }: { placements: number[] }) { + invariant(placements.length > 0, "Empty placements not supported"); + invariant( + placements.every((placement) => placement < 0), + "Positive placements in SE not implemented", + ); + + // 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)); + + const orderedRoundsIds = this.data.round + .filter((round) => round.group_id === mainGroupId) + .map((round) => round.id) + .sort((a, b) => a - b); + + const amountOfRounds = Math.abs(Math.min(...placements)); + + const sourceRoundsIds = orderedRoundsIds.slice(0, amountOfRounds).sort( + // teams who made it further in the bracket get higher seed + (a, b) => b - a, + ); + + const teams: number[] = []; + let relevantMatchesFinished = true; + for (const roundId of sourceRoundsIds) { + const roundsMatches = this.data.match.filter( + (match) => match.round_id === roundId, + ); + + for (const match of roundsMatches) { + // BYE + if (!match.opponent1 || !match.opponent2) { + continue; + } + if ( + match.opponent1?.result !== "win" && + match.opponent2?.result !== "win" + ) { + relevantMatchesFinished = false; + continue; + } + + const loser = + match.opponent1?.result === "win" ? match.opponent2 : match.opponent1; + invariant(loser?.id, "Loser id not found"); + + teams.push(loser.id); + } + } + + return { + relevantMatchesFinished, + teams, + }; + } } diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 608c2ad61..2f93d1d3a 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -917,7 +917,7 @@ describe("validatedSources - other rules", () => { expect((error as any).bracketIdx).toEqual(1); }); - it("handles NO_SE_SOURCE", () => { + it("handles NO_SE_POSITIVE", () => { const error = getValidatedBrackets([ { settings: {}, @@ -935,10 +935,31 @@ describe("validatedSources - other rules", () => { }, ]) as Progression.ValidationError; - expect(error.type).toBe("NO_SE_SOURCE"); + expect(error.type).toBe("NO_SE_POSITIVE"); expect((error as any).bracketIdx).toEqual(1); }); + it("allows single elimination to source an underground bracket", () => { + const result = getValidatedBrackets([ + { + settings: {}, + type: "single_elimination", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "-1", + }, + ], + }, + ]); + + expect(Progression.isBrackets(result)).toBe(true); + }); + it("handles NO_DE_POSITIVE", () => { const error = getValidatedBrackets([ { @@ -1278,6 +1299,21 @@ describe("isUnderground", () => { ); }); + it("handles SE w/ underground bracket", () => { + expect( + Progression.isUnderground( + 0, + progressions.singleEliminationWithUnderground, + ), + ).toBe(false); + expect( + Progression.isUnderground( + 1, + progressions.singleEliminationWithUnderground, + ), + ).toBe(true); + }); + it("throws if given idx is out of bounds", () => { expect(() => Progression.isUnderground(1, progressions.singleElimination), @@ -1348,6 +1384,14 @@ describe("bracketIdxsForStandings", () => { ), ).toEqual([0]); // missing 1 because it's underground when DE is the source }); + + it("handles SE w/ underground bracket", () => { + expect( + Progression.bracketIdxsForStandings( + progressions.singleEliminationWithUnderground, + ), + ).toEqual([0]); // missing 1 because it's underground when SE is the source + }); }); describe("startingBrackets", () => { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index db571e288..0ece5d0f4 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -90,9 +90,9 @@ export type ValidationError = type: "NEGATIVE_PROGRESSION"; bracketIdx: number; } - // single elimination is not a valid source bracket (might change in the future) + // no SE positive placements (single elimination can only source underground brackets) | { - type: "NO_SE_SOURCE"; + type: "NO_SE_POSITIVE"; bracketIdx: number; } // no DE positive placements (might change in the future) @@ -287,10 +287,10 @@ export function bracketsToValidationError( }; } - faultyBracketIdx = noSingleEliminationAsSource(brackets); + faultyBracketIdx = noSingleEliminationPositive(brackets); if (typeof faultyBracketIdx === "number") { return { - type: "NO_SE_SOURCE", + type: "NO_SE_POSITIVE", bracketIdx: faultyBracketIdx, }; } @@ -671,11 +671,14 @@ function negativeProgression(brackets: ParsedBracket[]) { return null; } -function noSingleEliminationAsSource(brackets: ParsedBracket[]) { +function noSingleEliminationPositive(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") { + if ( + sourceBracket.type === "single_elimination" && + source.placements.some((placement) => placement > 0) + ) { return bracketIdx; } } @@ -944,7 +947,8 @@ export function bracketIdxsForStandings(progression: ParsedBracket[]) { return !sources.some( (source) => - progression[source.bracketIdx].type === "double_elimination", + progression[source.bracketIdx].type === "double_elimination" || + progression[source.bracketIdx].type === "single_elimination", ); }, ); diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 33eb8e15e..c4e837ca0 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -256,4 +256,21 @@ export const progressions = { ], }, ], + singleEliminationWithUnderground: [ + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + }, + { + ...DEFAULT_PROGRESSION_ARGS, + type: "single_elimination", + name: "Underground", + sources: [ + { + bracketIdx: 0, + placements: [-1], + }, + ], + }, + ], } satisfies Record; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 76722fc06..18bd920f2 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -180,6 +180,15 @@ export default function TournamentBracketsPage() { )} rounds of the losers bracket can play in this bracket`; } + if ( + tournament.brackets[0].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`; + } + const advanceThreshold = tournament.brackets[0].settings?.advanceThreshold; if ( advanceThreshold && diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 332da44b9..b6d6edcd2 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -229,7 +229,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 57e554245..7e13e0d11 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -229,7 +229,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 75be99f46..675d43b0e 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -229,7 +229,7 @@ "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_SOURCE": "Single elimination is not a valid source bracket", + "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.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", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 68851ed6c..3308d75b6 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -231,7 +231,7 @@ "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_SOURCE": "La eliminación simple no es un cuadro de origen válido", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva", "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": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 8803131fe..cc1a36f08 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -231,7 +231,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index d5e4f4286..a2423508c 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -231,7 +231,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 7ede81611..47bfad31b 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -231,7 +231,7 @@ "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_SOURCE": "Single elimination is not a valid source bracket", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index e8cd53a99..95bc25713 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -231,7 +231,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 656942b25..eca48fac0 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -231,7 +231,7 @@ "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_SOURCE": "Eliminazione singola non è un bracket sorgente valido", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index d2b2dae7f..62ef547fc 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -225,7 +225,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "ブラケットの名前が重複しています", "progression.error.NAME_MISSING": "ブラケットの名前がありません", "progression.error.NEGATIVE_PROGRESSION": "逆の進行はダブルエリ三ネーションの時のみ可能です", - "progression.error.NO_SE_SOURCE": "シングルエリ三ネーションは妥当なブラケットではないです", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index d40f8f8b6..0a3c9c316 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -225,7 +225,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 6aeb4185a..cca0641ba 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -229,7 +229,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 3fcd2765f..d2cc29849 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -233,7 +233,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 7be4d0185..7faf8cdf3 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -231,7 +231,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "", "progression.error.NAME_MISSING": "", "progression.error.NEGATIVE_PROGRESSION": "", - "progression.error.NO_SE_SOURCE": "", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 826af65b1..4f4044891 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -233,7 +233,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "Дубликат имени сетки", "progression.error.NAME_MISSING": "Имя сетки отсутствует", "progression.error.NEGATIVE_PROGRESSION": "Отрицательная прогрессия возможна только в Double Elimination турнирах", - "progression.error.NO_SE_SOURCE": "Single elimination не валидная сетка-исток", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "Double elimination не валидно для позитивной прогрессии", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 67e5238d5..bca8a13d9 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -227,7 +227,7 @@ "progression.error.DUPLICATE_BRACKET_NAME": "对战表名称重复", "progression.error.NAME_MISSING": "缺少对战表名称", "progression.error.NEGATIVE_PROGRESSION": "负序晋级仅在双败淘汰赛中可行", - "progression.error.NO_SE_SOURCE": "单败淘汰赛不是有效的来源对战表", + "progression.error.NO_SE_POSITIVE": "", "progression.error.NO_DE_POSITIVE": "双败淘汰赛不适用于正序晋级", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "包含提前晋级/淘汰的瑞士轮对战表必须导向另一个对战表", "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B 分组只能在循环赛对战表中启用",