Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions apps/api/src/bracket/bracket-generator.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class BracketGenerator {
case "round_robin":
return this.generateRoundRobin(stage, participantCount);
case "double_elimination":
return this.generateDoubleElimination(stage, participantCount);
case "swiss":
throw new NotImplementedException(
"Format wird ab M2/M3 unterstuetzt: " + stage.type,
Expand Down Expand Up @@ -147,6 +148,135 @@ export class BracketGenerator {
return { type: stage.type, name: stage.name, groups: [group] };
}

/**
* Double-elimination for power-of-two fields (4, 8, 16, ...). One stage, one
* group, with sequentially numbered rounds: the Winner Bracket (WB) first,
* then the Loser Bracket (LB), then a single Grand Final (no bracket reset).
*
* Layout for k = log2(N):
* - WB rounds 1..k, with N/2^w matches in round w. Round 1 seats the seeds
* in the same mirror order as single-elimination; later rounds feed from
* the two WB winners below them.
* - LB rounds k+1 .. k+2(k-1) (group round numbers), 2(k-1) rounds total.
* LB round lr (1-based within the LB) has N/2^(ceil(lr/2)+1) matches.
* lr=1 pairs consecutive WB-R1 losers.
* lr even (minor) pairs each LB survivor with a fresh WB loser dropping
* down from WB round (lr/2 + 1).
* lr odd >=3 pairs LB survivors against each other (major round).
* - Grand Final: WB champion vs LB champion.
*
* Total matches = 2N-2. Only power-of-two N is supported.
*/
private generateDoubleElimination(stage: StageSetup, participantCount: number): GeneratedStage {
const count = Math.max(participantCount, 2);
if (!isPowerOfTwo(count) || count !== participantCount || participantCount < 2) {
throw new NotImplementedException(
"Double-Elimination unterstuetzt aktuell nur Teilnehmerzahlen, die eine Zweierpotenz sind (4, 8, 16, ...)",
);
}

const size = count;
const k = Math.log2(size);

// WB round-1 seed slots: identical mirror expansion to single-elimination.
let slots: number[] = [1, 2];
while (slots.length < size) {
const sum = slots.length * 2 + 1;
const next: number[] = [];
for (const s of slots) {
next.push(s);
next.push(sum - s);
}
slots = next;
}

const rounds: GeneratedRound[] = [];

// --- Winner Bracket (rounds 1..k) ---------------------------------------
const wbR1Matches: GeneratedMatch[] = [];
for (let i = 0; i < size / 2; i++) {
wbR1Matches.push({
number: i + 1,
opponent1: { kind: "participant", participantIndex: slots[2 * i] - 1 },
opponent2: { kind: "participant", participantIndex: slots[2 * i + 1] - 1 },
});
}
rounds.push({ number: 1, name: this.wbRoundName(1, k), matches: wbR1Matches });

for (let w = 2; w <= k; w++) {
const matchCount = size / Math.pow(2, w);
const matches: GeneratedMatch[] = [];
for (let m = 1; m <= matchCount; m++) {
matches.push({
number: m,
opponent1: this.winnerSource(w - 1, 2 * m - 1),
opponent2: this.winnerSource(w - 1, 2 * m),
});
}
rounds.push({ number: w, name: this.wbRoundName(w, k), matches });
}

// --- Loser Bracket (group rounds k+1 .. k+2(k-1)) -----------------------
const lbRoundCount = 2 * (k - 1);
for (let lr = 1; lr <= lbRoundCount; lr++) {
const groupRound = k + lr;
const prevLbRound = k + lr - 1; // group round number of the previous LB round
const matchCount = size / Math.pow(2, Math.ceil(lr / 2) + 1);
const matches: GeneratedMatch[] = [];

if (lr === 1) {
// Pair consecutive WB round-1 losers.
for (let m = 1; m <= matchCount; m++) {
matches.push({
number: m,
opponent1: this.loserSource(1, 2 * m - 1),
opponent2: this.loserSource(1, 2 * m),
});
}
} else if (lr % 2 === 0) {
// Minor round: LB survivor vs WB loser dropping from WB round (lr/2 + 1).
const wbRound = lr / 2 + 1;
for (let m = 1; m <= matchCount; m++) {
matches.push({
number: m,
opponent1: this.winnerSource(prevLbRound, m),
opponent2: this.loserSource(wbRound, m),
});
}
} else {
// Major round: pair consecutive LB survivors from the previous LB round.
for (let m = 1; m <= matchCount; m++) {
matches.push({
number: m,
opponent1: this.winnerSource(prevLbRound, 2 * m - 1),
opponent2: this.winnerSource(prevLbRound, 2 * m),
});
}
}

rounds.push({ number: groupRound, name: this.lbRoundName(lr, lbRoundCount), matches });
}

// --- Grand Final --------------------------------------------------------
const wbFinalRound = k;
const lbFinalRound = k + lbRoundCount;
const gfRound = lbFinalRound + 1;
rounds.push({
number: gfRound,
name: "Grand Final",
matches: [
{
number: 1,
opponent1: this.winnerSource(wbFinalRound, 1),
opponent2: this.winnerSource(lbFinalRound, 1),
},
],
});

const group: GeneratedGroup = { number: 1, rounds };
return { type: stage.type, name: stage.name, groups: [group] };
}

/** A real entrant becomes a participant slot; an absent seed becomes a bye. */
private seedSlot(seed: number, participantCount: number): GeneratedSlot {
if (seed <= participantCount) {
Expand All @@ -159,6 +289,10 @@ export class BracketGenerator {
return { kind: "source", source: { type: "winner_of", round, match } };
}

private loserSource(round: number, match: number): GeneratedSlot {
return { kind: "source", source: { type: "loser_of", round, match } };
}

private roundName(round: number, totalRounds: number): string {
if (round === totalRounds) {
return "Finale";
Expand All @@ -168,4 +302,24 @@ export class BracketGenerator {
}
return "Runde " + round;
}

/** WB round display name; the last WB round (k) is the WB Finale. */
private wbRoundName(round: number, totalWbRounds: number): string {
if (round === totalWbRounds) {
return "WB Finale";
}
return "WB Runde " + round;
}

/** LB round display name (lr is 1-based within the LB); last LB round is the Finale. */
private lbRoundName(lr: number, totalLbRounds: number): string {
if (lr === totalLbRounds) {
return "LB Finale";
}
return "LB Runde " + lr;
}
}

function isPowerOfTwo(n: number): boolean {
return n >= 1 && (n & (n - 1)) === 0;
}
141 changes: 139 additions & 2 deletions apps/api/src/bracket/bracket-generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,146 @@ describe("round_robin (circle method)", () => {
});
});

describe("double_elimination", () => {
const winnerSrc = (round: number, match: number): GeneratedSlot => ({
kind: "source",
source: { type: "winner_of", round, match },
});
const loserSrc = (round: number, match: number): GeneratedSlot => ({
kind: "source",
source: { type: "loser_of", round, match },
});
const participant = (participantIndex: number): GeneratedSlot => ({
kind: "participant",
participantIndex,
});

it("N=4 → exact oracle structure (6 = 2N-2 matches)", () => {
const group = gen.generateStage(stage("double_elimination"), 4).groups[0];

expect(group.rounds.map((r) => r.number)).toEqual([1, 2, 3, 4, 5]);
expect(group.rounds.map((r) => r.name)).toEqual([
"WB Runde 1",
"WB Finale",
"LB Runde 1",
"LB Finale",
"Grand Final",
]);
expect(group.rounds.map((r) => r.matches.length)).toEqual([2, 1, 1, 1, 1]);

const [wb1, wbF, lb1, lbF, gf] = group.rounds;

// 1 WB Runde 1: m1 [participant 0, participant 3], m2 [participant 1, participant 2]
expect(wb1.matches[0].opponent1).toEqual(participant(0));
expect(wb1.matches[0].opponent2).toEqual(participant(3));
expect(wb1.matches[1].opponent1).toEqual(participant(1));
expect(wb1.matches[1].opponent2).toEqual(participant(2));

// 2 WB Finale: m1 [winner_of(1,1), winner_of(1,2)]
expect(wbF.matches[0].opponent1).toEqual(winnerSrc(1, 1));
expect(wbF.matches[0].opponent2).toEqual(winnerSrc(1, 2));

// 3 LB Runde 1: m1 [loser_of(1,1), loser_of(1,2)]
expect(lb1.matches[0].opponent1).toEqual(loserSrc(1, 1));
expect(lb1.matches[0].opponent2).toEqual(loserSrc(1, 2));

// 4 LB Finale: m1 [winner_of(3,1), loser_of(2,1)]
expect(lbF.matches[0].opponent1).toEqual(winnerSrc(3, 1));
expect(lbF.matches[0].opponent2).toEqual(loserSrc(2, 1));

// 5 Grand Final: m1 [winner_of(2,1), winner_of(4,1)]
expect(gf.matches[0].opponent1).toEqual(winnerSrc(2, 1));
expect(gf.matches[0].opponent2).toEqual(winnerSrc(4, 1));
});

it("N=8 → exact oracle structure (14 = 2N-2 matches)", () => {
const group = gen.generateStage(stage("double_elimination"), 8).groups[0];

expect(group.rounds.map((r) => r.number)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
expect(group.rounds.map((r) => r.name)).toEqual([
"WB Runde 1",
"WB Runde 2",
"WB Finale",
"LB Runde 1",
"LB Runde 2",
"LB Runde 3",
"LB Finale",
"Grand Final",
]);
expect(group.rounds.map((r) => r.matches.length)).toEqual([4, 2, 1, 2, 2, 1, 1, 1]);

const byNumber = (n: number) => group.rounds.find((r) => r.number === n)!;

// 1 WB Runde 1: seed order [1,8,4,5,2,7,3,6] → participantIndex [0,7,3,4,1,6,2,5]
const wb1 = byNumber(1);
expect(wb1.matches[0].opponent1).toEqual(participant(0));
expect(wb1.matches[0].opponent2).toEqual(participant(7));
expect(wb1.matches[1].opponent1).toEqual(participant(3));
expect(wb1.matches[1].opponent2).toEqual(participant(4));
expect(wb1.matches[2].opponent1).toEqual(participant(1));
expect(wb1.matches[2].opponent2).toEqual(participant(6));
expect(wb1.matches[3].opponent1).toEqual(participant(2));
expect(wb1.matches[3].opponent2).toEqual(participant(5));

// 2 WB Runde 2
const wb2 = byNumber(2);
expect(wb2.matches[0].opponent1).toEqual(winnerSrc(1, 1));
expect(wb2.matches[0].opponent2).toEqual(winnerSrc(1, 2));
expect(wb2.matches[1].opponent1).toEqual(winnerSrc(1, 3));
expect(wb2.matches[1].opponent2).toEqual(winnerSrc(1, 4));

// 3 WB Finale
const wbF = byNumber(3);
expect(wbF.matches[0].opponent1).toEqual(winnerSrc(2, 1));
expect(wbF.matches[0].opponent2).toEqual(winnerSrc(2, 2));

// 4 LB Runde 1: pair consecutive WB-R1 losers
const lb1 = byNumber(4);
expect(lb1.matches[0].opponent1).toEqual(loserSrc(1, 1));
expect(lb1.matches[0].opponent2).toEqual(loserSrc(1, 2));
expect(lb1.matches[1].opponent1).toEqual(loserSrc(1, 3));
expect(lb1.matches[1].opponent2).toEqual(loserSrc(1, 4));

// 5 LB Runde 2 (minor): LB survivors vs WB-R2 losers, straight pairing
const lb2 = byNumber(5);
expect(lb2.matches[0].opponent1).toEqual(winnerSrc(4, 1));
expect(lb2.matches[0].opponent2).toEqual(loserSrc(2, 1));
expect(lb2.matches[1].opponent1).toEqual(winnerSrc(4, 2));
expect(lb2.matches[1].opponent2).toEqual(loserSrc(2, 2));

// 6 LB Runde 3 (major): LB survivors paired
const lb3 = byNumber(6);
expect(lb3.matches[0].opponent1).toEqual(winnerSrc(5, 1));
expect(lb3.matches[0].opponent2).toEqual(winnerSrc(5, 2));

// 7 LB Finale: LB survivor vs WB-final loser
const lbF = byNumber(7);
expect(lbF.matches[0].opponent1).toEqual(winnerSrc(6, 1));
expect(lbF.matches[0].opponent2).toEqual(loserSrc(3, 1));

// 8 Grand Final
const gf = byNumber(8);
expect(gf.matches[0].opponent1).toEqual(winnerSrc(3, 1));
expect(gf.matches[0].opponent2).toEqual(winnerSrc(7, 1));
});

it("total match count is 2N-2 for N=4, 8, 16", () => {
for (const n of [4, 8, 16]) {
const group = gen.generateStage(stage("double_elimination"), n).groups[0];
const total = group.rounds.reduce((sum, r) => sum + r.matches.length, 0);
expect(total).toBe(2 * n - 2);
}
});

it("throws for non-power-of-two participant counts", () => {
expect(() => gen.generateStage(stage("double_elimination"), 6)).toThrow(
"Double-Elimination unterstuetzt aktuell nur Teilnehmerzahlen, die eine Zweierpotenz sind (4, 8, 16, ...)",
);
});
});

describe("unsupported formats", () => {
it("throws for double_elimination and swiss", () => {
expect(() => gen.generateStage(stage("double_elimination"), 4)).toThrow();
it("throws for swiss", () => {
expect(() => gen.generateStage(stage("swiss"), 4)).toThrow();
});
});
Loading