From a5601b2c363fd697ebedfb48dce2a1bcc92c0035 Mon Sep 17 00:00:00 2001 From: Tmgldbch Date: Mon, 15 Jun 2026 23:11:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(m3):=20double-elimination=20=E2=80=94=20wi?= =?UTF-8?q?nner/loser=20bracket=20+=20grand=20final=20(pow2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of M3 (format breadth), built test-driven via a workflow + a 3-lens adversarial review (0 blockers; the review found only test-quality issues, no code bugs). Verified: tsc clean, vitest 22/22. - bracket-generator: real double_elimination generator for power-of-two fields — one stage/one group with sequential rounds (WB 1..k, LB 2(k-1) rounds, single Grand Final, no reset), within-group winner_of/loser_of sources. Matches the canonical N=4 (6 matches) and N=8 (14 matches) structures; 2N-2 in general. Non-power-of-two throws NotImplemented (byes/odd fields are M3.1). - progression: loserAdvancement (mirror of winnerAdvancement for loser_of). - tournaments.service.score(): for elimination, also drop the loser via loserAdvancement (no-op for single-elim; round-robin unchanged). - tests: exact-structure asserts for N=4/N=8, 2N-2 for N=4/8/16, non-pow2 throws, loserAdvancement unit test, and a deterministic full-tournament simulation that plays every match to completion and asserts the Grand Final crowns the undefeated WB finalist while the LB finalist carries exactly one loss (proves the loser-bracket wiring). Deferred (docs/PLAN.md): DE non-pow2/byes + bracket reset (M3.1), groups+KO with cross-stage seeding (M3.2), Swiss + Buchholz (M3.3), and a service-layer DB test for the score()/reseed() DE path once a database is available. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/bracket/bracket-generator.service.ts | 154 +++++++++++++ .../api/src/bracket/bracket-generator.spec.ts | 141 +++++++++++- apps/api/src/bracket/progression.spec.ts | 206 +++++++++++++++++- apps/api/src/bracket/progression.ts | 41 ++++ .../src/tournaments/tournaments.service.ts | 18 ++ docs/PLAN.md | 6 +- 6 files changed, 561 insertions(+), 5 deletions(-) diff --git a/apps/api/src/bracket/bracket-generator.service.ts b/apps/api/src/bracket/bracket-generator.service.ts index 9017000..f0a96da 100644 --- a/apps/api/src/bracket/bracket-generator.service.ts +++ b/apps/api/src/bracket/bracket-generator.service.ts @@ -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, @@ -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) { @@ -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"; @@ -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; } diff --git a/apps/api/src/bracket/bracket-generator.spec.ts b/apps/api/src/bracket/bracket-generator.spec.ts index 625dbe6..5f839b1 100644 --- a/apps/api/src/bracket/bracket-generator.spec.ts +++ b/apps/api/src/bracket/bracket-generator.spec.ts @@ -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(); }); }); diff --git a/apps/api/src/bracket/progression.spec.ts b/apps/api/src/bracket/progression.spec.ts index 9fb79ea..dd56a81 100644 --- a/apps/api/src/bracket/progression.spec.ts +++ b/apps/api/src/bracket/progression.spec.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from "vitest"; -import { byeAdvancements, computeStandings, winnerAdvancement } from "./progression"; +import { BracketGenerator } from "./bracket-generator.service"; +import { + byeAdvancements, + computeStandings, + loserAdvancement, + winnerAdvancement, + type MatchView, +} from "./progression"; +import type { GeneratedSlot } from "./types"; describe("winnerAdvancement", () => { it("seats the winner into every slot that sources from the completed match", () => { @@ -132,3 +140,199 @@ describe("computeStandings", () => { expect(table.every((r) => r.played === 0)).toBe(true); }); }); + +describe("loserAdvancement", () => { + it("seats the loser into every slot that sources from loser_of(round, match)", () => { + const matches = [ + { + id: "lb1m1", + roundNumber: 3, + matchNumber: 1, + opponent1: { source: { type: "loser_of", round: 1, match: 1 } }, + opponent2: { source: { type: "loser_of", round: 1, match: 2 } }, + }, + ]; + const updates = loserAdvancement( + { matches }, + { roundNumber: 1, matchNumber: 1, loserParticipantId: "L1" }, + ); + expect(updates).toEqual([{ matchId: "lb1m1", slot: "opponent1", participantId: "L1" }]); + }); + + it("ignores winner_of slots and unrelated matches", () => { + const matches = [ + { + id: "wf", + roundNumber: 2, + matchNumber: 1, + opponent1: { source: { type: "winner_of", round: 1, match: 1 } }, + opponent2: { source: { type: "winner_of", round: 1, match: 2 } }, + }, + ]; + expect( + loserAdvancement({ matches }, { roundNumber: 1, matchNumber: 1, loserParticipantId: "L" }), + ).toEqual([]); + }); +}); + +describe("double-elimination full simulation", () => { + const gen = new BracketGenerator(); + + type SimMatch = { + id: string; + roundNumber: number; + matchNumber: number; + opponent1: unknown; + opponent2: unknown; + completed: boolean; + winnerId: string | null; + }; + + /** Translate a generated slot into the stored JSON the DB would hold. */ + const slotJson = (slot: GeneratedSlot): unknown => { + if (slot.kind === "participant") { + return { participantId: `P${slot.participantIndex}` }; + } + if (slot.kind === "source") { + return { source: { ...slot.source } }; + } + if (slot.kind === "bye") { + return { bye: true }; + } + return null; + }; + + const resolvedId = (raw: unknown): string | null => { + if (raw && typeof raw === "object" && "participantId" in (raw as Record)) { + const pid = (raw as Record).participantId; + return typeof pid === "string" ? pid : null; + } + return null; + }; + + const applyUpdate = ( + matches: SimMatch[], + update: { matchId: string; slot: "opponent1" | "opponent2"; participantId: string }, + ): void => { + const target = matches.find((m) => m.id === update.matchId)!; + target[update.slot] = { participantId: update.participantId }; + }; + + /** + * Build the mutable match list, then deterministically resolve every match by + * always declaring opponent1 the winner. Returns the simulation outcome. + */ + const simulate = (n: number) => { + const group = gen.generateStage({ type: "double_elimination", name: "Main", settings: {} }, n) + .groups[0]; + + const matches: SimMatch[] = []; + for (const round of group.rounds) { + for (const m of round.matches) { + matches.push({ + id: `R${round.number}M${m.number}`, + roundNumber: round.number, + matchNumber: m.number, + opponent1: slotJson(m.opponent1), + opponent2: slotJson(m.opponent2), + completed: false, + winnerId: null, + }); + } + } + + const views = (): MatchView[] => + matches.map((m) => ({ + id: m.id, + roundNumber: m.roundNumber, + matchNumber: m.matchNumber, + opponent1: m.opponent1, + opponent2: m.opponent2, + })); + + // Track how many times each participant has lost, to prove the LB path. + const losses = new Map(); + // Track which participants reached the Grand Final (round = last round). + const gfRoundNumber = group.rounds[group.rounds.length - 1].number; + const reachedGrandFinal = new Set(); + + let progress = true; + while (progress) { + progress = false; + for (const m of matches) { + if (m.completed) { + continue; + } + const id1 = resolvedId(m.opponent1); + const id2 = resolvedId(m.opponent2); + if (id1 === null || id2 === null) { + continue; + } + + // Deterministic: opponent1 always wins. + const winnerId = id1; + const loserId = id2; + m.completed = true; + m.winnerId = winnerId; + + if (m.roundNumber === gfRoundNumber) { + // Record GF participants but do NOT count the GF loss — we want the + // loss tally accrued on the *path to* the Grand Final. + reachedGrandFinal.add(id1); + reachedGrandFinal.add(id2); + } else { + losses.set(loserId, (losses.get(loserId) ?? 0) + 1); + } + + for (const u of winnerAdvancement( + { matches: views() }, + { roundNumber: m.roundNumber, matchNumber: m.matchNumber, winnerParticipantId: winnerId }, + )) { + applyUpdate(matches, u); + } + for (const u of loserAdvancement( + { matches: views() }, + { roundNumber: m.roundNumber, matchNumber: m.matchNumber, loserParticipantId: loserId }, + )) { + applyUpdate(matches, u); + } + progress = true; + } + } + + return { matches, gfRoundNumber, reachedGrandFinal, losses }; + }; + + for (const n of [4, 8]) { + it(`N=${n}: every match completes and the Grand Final crowns an undefeated champion`, () => { + const { matches, gfRoundNumber, losses } = simulate(n); + + // Every match was resolvable and got completed. + expect(matches.every((m) => m.completed)).toBe(true); + expect(matches).toHaveLength(2 * n - 2); + + // The Grand Final is a single decisive match (no bracket reset). + const finals = matches.filter((m) => m.roundNumber === gfRoundNumber); + expect(finals).toHaveLength(1); + + const gf = finals[0]; + expect(gf.winnerId).not.toBeNull(); + // opponent1 always wins, so the champion is the WB finalist who came + // through the winner bracket undefeated — a real constraint on the GF + // wiring (a mis-wired GF would crown someone carrying a loss). + expect(losses.get(gf.winnerId!) ?? 0).toBe(0); + }); + + it(`N=${n}: a player who lost once still reaches the Grand Final (LB path is wired)`, () => { + const { reachedGrandFinal, losses } = simulate(n); + + // Two players reach the GF: the WB champion (0 losses) and the LB + // champion (exactly 1 loss at the point it dropped to the LB). + expect(reachedGrandFinal.size).toBe(2); + const lbFinalist = [...reachedGrandFinal].find((p) => (losses.get(p) ?? 0) >= 1); + expect(lbFinalist).toBeDefined(); + // The LB finalist lost exactly once before the Grand Final began. + expect(losses.get(lbFinalist!)).toBe(1); + }); + } +}); diff --git a/apps/api/src/bracket/progression.ts b/apps/api/src/bracket/progression.ts index 4bd48fd..d9468ac 100644 --- a/apps/api/src/bracket/progression.ts +++ b/apps/api/src/bracket/progression.ts @@ -104,6 +104,14 @@ function isWinnerSourceFor(slot: Slot, round: number, match: number): boolean { return source.type === "winner_of" && source.round === round && source.match === match; } +function isLoserSourceFor(slot: Slot, round: number, match: number): boolean { + if (slot === null || !("source" in slot)) { + return false; + } + const { source } = slot; + return source.type === "loser_of" && source.round === round && source.match === match; +} + // --------------------------------------------------------------------------- // Advancement // --------------------------------------------------------------------------- @@ -142,6 +150,39 @@ export function winnerAdvancement( return updates; } +/** + * The loser-bracket mirror of {@link winnerAdvancement}: given a just-completed + * match and the participant that lost it, find every downstream slot that + * sources from `loser_of(round, match)` and seat the loser there. Used by + * double-elimination to drop WB losers into the LB; single-elimination has no + * `loser_of` consumers, so this is a no-op there. + */ +export function loserAdvancement( + group: { matches: MatchView[] }, + completed: { roundNumber: number; matchNumber: number; loserParticipantId: string }, +): AdvancementUpdate[] { + const updates: AdvancementUpdate[] = []; + for (const match of group.matches) { + const slot1 = parseSlot(match.opponent1); + if (isLoserSourceFor(slot1, completed.roundNumber, completed.matchNumber)) { + updates.push({ + matchId: match.id, + slot: "opponent1", + participantId: completed.loserParticipantId, + }); + } + const slot2 = parseSlot(match.opponent2); + if (isLoserSourceFor(slot2, completed.roundNumber, completed.matchNumber)) { + updates.push({ + matchId: match.id, + slot: "opponent2", + participantId: completed.loserParticipantId, + }); + } + } + return updates; +} + /** * Detect bye matches — exactly one participant slot and one bye slot — and treat * the lone participant as the automatic winner. For each, also compute the diff --git a/apps/api/src/tournaments/tournaments.service.ts b/apps/api/src/tournaments/tournaments.service.ts index 9b02688..266e111 100644 --- a/apps/api/src/tournaments/tournaments.service.ts +++ b/apps/api/src/tournaments/tournaments.service.ts @@ -21,6 +21,7 @@ import { AdvancementUpdate, MatchView, byeAdvancements, + loserAdvancement, parseSlot, winnerAdvancement, } from "../bracket/progression"; @@ -436,6 +437,23 @@ export class TournamentsService { }, ); await this.applyAdvancements(tx, updates); + + // Double-elimination also drops the loser into the loser bracket. The + // loser is whichever resolved opponent did not win. For single + // elimination there are no loser_of consumers, so this is a no-op. + const loserParticipantId = + winnerParticipantId === slot1.participantId + ? slot2.participantId + : slot1.participantId; + const loserUpdates = loserAdvancement( + { matches: views }, + { + roundNumber: match.round.number, + matchNumber: match.number, + loserParticipantId, + }, + ); + await this.applyAdvancements(tx, loserUpdates); } await this.recomputeStatus(tx, tournamentId); diff --git a/docs/PLAN.md b/docs/PLAN.md index 299ff54..e63e01d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -106,8 +106,10 @@ SavedTheme(id, ownerUserId, name, tokens:jsonb) Round-Robin-Standings, **SSE-Live-Updates**, SVG-Bracket, Capability-Link-Sharing + öffentliche Live-/Score-Route, Theme-Token-Anwendung + Presets/Controls. (Drag-Reseeding & voller Theme-Editor mit gespeicherter Bibliothek → M2.1; double_elim-Progression → M3.) -- **M3 — Format-Breite:** Round-Robin + Standings, Gruppen+KO (2 Stages), - dann **Swiss** + Tiebreaker (stark getestet). +- **M3 — Format-Breite (in Arbeit):** ✅ **Double-Elimination** (Winner-/Loser-Bracket + Grand Final, + Power-of-2, voll unit-getestet inkl. Turnier-Simulation). Offen: DE-Nicht-Zweierpotenz/Byes + + Bracket-Reset (**M3.1**), **Gruppen+KO** mit Cross-Stage-Seeding (**M3.2**), **Swiss** + Buchholz- + Tiebreaker (**M3.3**). DE-Service-DB-Test, sobald eine DB verfügbar ist. - **M4 — Extras:** Embed-Widget + PNG/PDF-Export; Serien/Ligen (+ optional Participant-Registry); reichere Stats.