Support SE -> Underground bracket format

This commit is contained in:
Kalle 2026-07-21 19:06:38 +03:00
parent d0d846956c
commit c2d3d2e221
22 changed files with 230 additions and 25 deletions

View File

@ -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<any>("group")!.map((g) => g.id),
);
const firstRoundId = Math.min(
...storage
.select<any>("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),
);
});
});

View File

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

View File

@ -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", () => {

View File

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

View File

@ -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<string, Progression.ParsedBracket[]>;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 分组只能在循环赛对战表中启用",