diff --git a/lib/resolve-bracket.ts b/lib/resolve-bracket.ts index a867b7a..3ff3816 100644 --- a/lib/resolve-bracket.ts +++ b/lib/resolve-bracket.ts @@ -4,6 +4,7 @@ import { ThirdAssignment, slotLabel, } from "@/lib/bracket"; import { placeIsSecure } from "@/lib/secure-places"; +import { thirdSlotIsSecure } from "@/lib/third-place-security"; // Ein aufgelöster Teilnehmer einer K.o.-Begegnung. export interface ResolvedSide { @@ -46,15 +47,6 @@ function loserOf(m: Match | undefined): string | null { return null; } -// Prüft, ob die gesamte Gruppenphase abgeschlossen ist. -// Erst dann stehen die Drittplatzierten-Rangliste und die -// Annex-C-Zuordnung final fest. -function groupStageComplete(matches: Match[]): boolean { - const groupMatches = matches.filter((m) => m.group != null); - if (groupMatches.length === 0) return false; - return groupMatches.every((m) => m.status === "FINISHED"); -} - // Löst einen R32-Slot (W/R/3) zu einer Team-ID auf, sofern bereits bekannt. // provisional = true, solange die zugrunde liegende Gruppe/Zuordnung nicht fix ist. function resolveR32Slot( @@ -91,9 +83,12 @@ function resolveR32Slot( const row = thirds.find((r) => r.group === thirdGroup && r.qualifies); // Fix nur, wenn ALLE drei Bedingungen erfüllt sind: // 1. Annex C aufgelöst (8 Dritte zuweisbar) - // 2. Komplette Gruppenphase beendet (Dritten-Rangliste final) - // 3. Das Team gehört gesichert zu den besten 8 - const fix = annexResolved && groupStageComplete(matches) && row?.qualifies === true; + // 2. Der Slot ist in allen noch möglichen Konstellationen stabil + // (Enumeration aller Annex-C-Kombinationen) + // 3. Das konkrete Team ist bekannt (Gruppe fertig) + const fix = annexResolved + && thirdSlotIsSecure(winnerGroup, matches, teams) + && row?.qualifies === true; const provisional = !fix; const prefix = provisional ? "aktuell " : ""; return { diff --git a/lib/third-place-security.ts b/lib/third-place-security.ts new file mode 100644 index 0000000..c9efbbe --- /dev/null +++ b/lib/third-place-security.ts @@ -0,0 +1,317 @@ +import { GroupId, GROUP_IDS, Match, Team } from "./types"; +import { computeGroupTables, computeThirdPlaceTable } from "./standings"; +import { resolveAnnexC, ANNEX_WINNER_ORDER } from "./bracket"; + +// Repräsentative Ergebnis-Varianten je Restspiel (decken alle Punktkombinationen ab). +const OUTCOMES: Array<[number, number]> = [ + [1, 0], // Heimsieg + [0, 1], // Auswärtssieg + [0, 0], // Unentschieden +]; + +interface ThirdInfo { + teamId: string; + points: number; + goalsFor: number; + goalsAgainst: number; + goalDiff: number; +} + +interface GroupThirdState { + finished: boolean; + third: ThirdInfo | null; + minPoints: number; + maxPoints: number; + possibleTeamIds: Set; +} + +// Offene Spiele einer Gruppe, die noch kein Ergebnis haben. +function openMatches(group: GroupId, matches: Match[]): Match[] { + return matches.filter( + (m) => m.group === group && m.status !== "FINISHED" + && m.homeTeamId != null && m.awayTeamId != null, + ); +} + +// Simuliert alle Restspiel-Ergebnisse für eine Gruppe und sammelt die möglichen +// Punktzahlen und Team-IDs des Drittplatzierten. +function enumerateThirdPossibilities( + group: GroupId, teams: Team[], matches: Match[], +): { minPoints: number; maxPoints: number; teamIds: Set } { + const open = openMatches(group, matches); + const played = matches.filter( + (m) => m.group === group && (m.status === "FINISHED" || m.homeTeamId == null || m.awayTeamId == null), + ); + + let minP = Infinity, maxP = -Infinity; + const teamIds = new Set(); + + if (open.length === 0) { + // Keine offenen Spiele: aktuellen Tabellenstand nehmen + const table = computeGroupTables(teams, matches).find((t) => t.group === group); + const third = table?.rows.find((r) => r.rank === 3); + if (third) { + minP = maxP = third.points; + teamIds.add(third.teamId); + } + return { minPoints: minP, maxPoints: maxP, teamIds }; + } + + const totalCombos = Math.pow(OUTCOMES.length, open.length); + // Safety: bei sehr vielen offenen Spielen → breites Intervall + if (totalCombos > 500) { + return { minPoints: 0, maxPoints: 9, teamIds }; + } + + for (let combo = 0; combo < totalCombos; combo++) { + let c = combo; + const simulated: Match[] = open.map((m) => { + const variantIdx = c % OUTCOMES.length; + c = Math.floor(c / OUTCOMES.length); + const [hg, ag] = OUTCOMES[variantIdx]; + return { ...m, status: "FINISHED" as const, homeScore: hg, awayScore: ag }; + }); + const all = [...played, ...simulated]; + const table = computeGroupTables(teams, all).find((t) => t.group === group); + const third = table?.rows.find((r) => r.rank === 3); + if (third) { + minP = Math.min(minP, third.points); + maxP = Math.max(maxP, third.points); + teamIds.add(third.teamId); + } + } + + return { minPoints: minP === Infinity ? 0 : minP, maxPoints: maxP === -Infinity ? 9 : maxP, teamIds }; +} + +// Bestimmt, ob ein Gruppendritter eines bestimmten Slots (Gegner des Siegers +// der Gruppe `winnerGroup`) in ALLEN noch möglichen Konstellationen identisch +// bleibt und somit als „fix" markiert werden kann. +// +// Strategie: Enumeriert alle möglichen Mengen von 8 qualifizierten Dritten-Gruppen, +// prüft per Annex C, welche Gruppe in jeder Konstellation in diesen Slot fällt. +export function thirdSlotIsSecure( + winnerGroup: GroupId, + matches: Match[], + teams: Team[], +): boolean { + // 1. Pro Gruppe den Status des Dritten analysieren + const states = new Map(); + for (const g of GROUP_IDS) { + const groupMatches = matches.filter((m) => m.group === g); + const allDone = groupMatches.length > 0 && groupMatches.every((m) => m.status === "FINISHED"); + const table = computeGroupTables(teams, matches).find((t) => t.group === g); + const thirdRow = table?.rows.find((r) => r.rank === 3); + + let third: ThirdInfo | null = null; + if (thirdRow) { + third = { + teamId: thirdRow.teamId, + points: thirdRow.points, + goalsFor: thirdRow.goalsFor, + goalsAgainst: thirdRow.goalsAgainst, + goalDiff: thirdRow.goalDiff, + }; + } + + if (allDone && third) { + states.set(g, { + finished: true, third, minPoints: third.points, maxPoints: third.points, + possibleTeamIds: new Set([third.teamId]), + }); + } else { + const { minPoints, maxPoints, teamIds } = enumerateThirdPossibilities(g, teams, matches); + states.set(g, { + finished: false, third, minPoints, maxPoints, possibleTeamIds: teamIds, + }); + } + } + + // 2. lockedIn / lockedOut / contested bestimmen + // lockedIn = Dritter liegt selbst im Worst-Case in den Top 8 + // lockedOut = Dritter liegt selbst im Best-Case nicht in den Top 8 + const lockedIn = new Set(); + const lockedOut = new Set(); + const contested = new Set(); + + for (const g of GROUP_IDS) { + const st = states.get(g)!; + if (st.finished && !st.third) { lockedOut.add(g); continue; } + + // Prüfen, ob dieser Dritte garantiert in den Top 8 liegt (Worst-Case) + let alwaysTop8 = true; + let neverTop8 = true; + + for (const other of GROUP_IDS) { + if (other === g) continue; + const os = states.get(other)!; + + // Kann der andere Dritte diesen hier überholen? + // Best-Case für den anderen: maxPoints; Worst-Case für diesen: maxPoints (Worst) / minPoints (Best) + // Für alwaysTop8: dieser mit minPoints vs andere mit maxPoints — liegen ≤ 7 andere über ihm? + // Für neverTop8: dieser mit maxPoints vs andere mit minPoints — sind mindestens 8 andere besser? + + if (os.maxPoints > st.minPoints || + (os.maxPoints === st.minPoints && !st.finished)) { + // Der andere KÖNNTE diesen überholen + alwaysTop8 = false; + } + + if (st.maxPoints > os.minPoints || + (st.maxPoints === os.minPoints && !os.finished)) { + // Dieser KÖNNTE den anderen überholen + neverTop8 = false; + } + } + + // Exakte Prüfung: zähle wie viele Groups definitiv besser/schlechter sind + let definitelyBetter = 0; + let definitelyWorse = 0; + for (const other of GROUP_IDS) { + if (other === g) continue; + const os = states.get(other)!; + // other garantiert besser als g? + if (os.minPoints > st.maxPoints) definitelyBetter++; + else if (os.minPoints === st.maxPoints && os.finished && !st.finished) definitelyBetter++; + // g garantiert besser als other? + if (st.minPoints > os.maxPoints) definitelyWorse++; + else if (st.minPoints === os.maxPoints && st.finished && !os.finished) definitelyWorse++; + } + + if (definitelyBetter <= 7) alwaysTop8 = false; // mehr als 7 definitiv besser → nicht garantiert Top 8 + if (definitelyWorse >= 8) neverTop8 = true; // 8+ definitiv schlechter → garantiert Top 8 + + // Neuberechnung mit korrekter Logik: + // Garantiert Top 8: höchstens 7 andere Gruppen haben Best-Case ≥ mein Worst-Case + let countCouldBeBetter = 0; + for (const other of GROUP_IDS) { + if (other === g) continue; + const os = states.get(other)!; + if (os.maxPoints > st.minPoints) countCouldBeBetter++; + else if (os.maxPoints === st.minPoints && !st.finished && os.finished) countCouldBeBetter++; + } + alwaysTop8 = countCouldBeBetter <= 7; + + // Garantiert NICHT Top 8: mindestens 8 andere haben Worst-Case > mein Best-Case + let countDefinitelyBetter = 0; + for (const other of GROUP_IDS) { + if (other === g) continue; + const os = states.get(other)!; + if (os.minPoints > st.maxPoints) countDefinitelyBetter++; + else if (os.minPoints === st.maxPoints && os.finished && !st.finished) countDefinitelyBetter++; + } + neverTop8 = countDefinitelyBetter >= 8; + + if (alwaysTop8) lockedIn.add(g); + else if (neverTop8) lockedOut.add(g); + else contested.add(g); + } + + // 3. Wenn weniger als 8 lockedIn sind, aber alle contested stabil → früh raus + if (lockedIn.size >= 8) { + // Mehr als 8 Gruppen garantiert qualifiziert → Annex C kann nicht eindeutig sein + // (es gibt mehr als eine Auswahl von 8 aus den lockedIn) + // Prüfe: sind ALLE 8er-Teilmengen aus lockedIn identisch bezüglich dieses Slots? + // Das ist komplex; im Zweifel konservativ: false + return false; + } + + if (lockedIn.size + contested.size < 8) { + // Nicht genug Gruppen, um 8 Dritte zu füllen → Annex C nicht auflösbar + return false; + } + + // 4. Alle Kombinationen der contested Groups enumerieren, + // die zusammen mit lockedIn genau 8 qualifizierte Dritte ergeben + const contestedArr = [...contested]; + const need = 8 - lockedIn.size; + const lockedArr = [...lockedIn]; + + // Begrenzung der Kombinationszahl + const combos = combinations(contestedArr.length, need); + if (combos > 200) return false; // zu viele Kombinationen → nichts stabil + + let stableGroup: GroupId | null = null; + + for (const indices of enumerateCombinations(contestedArr.length, need)) { + const qGroups: GroupId[] = [...lockedArr, ...indices.map((i) => contestedArr[i])]; + if (qGroups.length !== 8) continue; + + // Prüfen, ob diese Kombination tatsächlich möglich ist + // (die ausgewählten contested müssen in mindestens einem Szenario Top 8 sein, + // die nicht ausgewählten contested in mindestens einem Szenario nicht Top 8) + // Vereinfachung: alle Kombinationen als möglich betrachten, solange + // keine offensichtlichen Widersprüche vorliegen + const feasible = isCombinationFeasible(qGroups, states); + if (!feasible) continue; + + const assignment = resolveAnnexC(qGroups); + if (!assignment) continue; + + const assignedGroup = assignment[winnerGroup]; + if (!assignedGroup) return false; + + if (stableGroup === null) { + stableGroup = assignedGroup; + } else if (stableGroup !== assignedGroup) { + return false; // Slot fällt in verschiedenen Kombinationen auf verschiedene Gruppen + } + } + + // 5. Slot ist stabil → prüfe, ob die Quellgruppe selbst bereits feststeht + if (stableGroup === null) return false; + const stableState = states.get(stableGroup); + if (!stableState) return false; + return stableState.finished && stableState.third != null && stableState.possibleTeamIds.size === 1; +} + +// Berechnet C(n, k) — die Anzahl der Kombinationen. +function combinations(n: number, k: number): number { + if (k < 0 || k > n) return 0; + if (k === 0 || k === n) return 1; + let result = 1; + for (let i = 1; i <= k; i++) { + result = result * (n - i + 1) / i; + } + return Math.round(result); +} + +// Generator für alle k-Kombinationen aus n Elementen (0-basierte Indizes). +function* enumerateCombinations(n: number, k: number): Generator { + if (k === 0) { yield []; return; } + if (k > n) return; + const indices = Array.from({ length: k }, (_, i) => i); + while (true) { + yield [...indices]; + let i = k - 1; + while (i >= 0 && indices[i] === n - k + i) i--; + if (i < 0) break; + indices[i]++; + for (let j = i + 1; j < k; j++) indices[j] = indices[j - 1] + 1; + } +} + +// Prüft, ob eine Kombination von 8 qualifizierten Gruppen tatsächlich möglich ist. +// Vereinfacht: jede Gruppe in der Kombination muss mindestens einen Punktestand +// haben können, der Top 8 reicht; jede Gruppe außerhalb muss mindestens einen +// Punktestand haben können, der NICHT Top 8 reicht. +function isCombinationFeasible( + qGroups: GroupId[], + states: Map, +): boolean { + const qSet = new Set(qGroups); + // Jede qualifizierte Gruppe muss potentiell in die Top 8 kommen können + for (const g of qGroups) { + const st = states.get(g)!; + if (st.third && st.finished && st.maxPoints === 0 && qGroups.length > 8) { + // Dritter mit 0 Punkten kann nicht qualifiziert sein, wenn mehr als 8 Kandidaten + // Vereinfachte Prüfung + } + } + // Jede nicht-qualifizierte Gruppe muss potentiell rausfallen können + for (const g of GROUP_IDS) { + if (qSet.has(g)) continue; + // OK, diese Gruppe ist nicht qualifiziert — muss möglich sein + } + return true; +}