From a721de5b23a2612c4daa7a4ce59b8d92b5efcd22 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Sat, 27 Jun 2026 12:15:34 -0500 Subject: [PATCH] third place marks --- app/api/matches/route.ts | 5 + app/components/ThirdPlace.tsx | 12 +- app/page.tsx | 3 +- lib/third-place-security.ts | 282 ++++++++++++---------------------- 4 files changed, 115 insertions(+), 187 deletions(-) diff --git a/app/api/matches/route.ts b/app/api/matches/route.ts index ed93337..2ddbea9 100644 --- a/app/api/matches/route.ts +++ b/app/api/matches/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { fetchMatchesAndTeams, fetchOdds, attachOdds, fetchLiveScores, applyLiveScores, assignKONumbersBySlots } from "@/lib/feeds"; import { computeGroupTables, computeThirdPlaceTable } from "@/lib/standings"; import { qualifiedThirdGroups, resolveAnnexC } from "@/lib/bracket"; +import { securelyQualifiedThirdTeams } from "@/lib/third-place-security"; // Diese Route wird vom Frontend gepollt. Sie ist der einzige Ort, der die // Upstream-Feeds berührt — gecacht, damit Rate Limits eingehalten werden. @@ -41,6 +42,9 @@ export async function GET() { const qGroups = qualifiedThirdGroups(thirdTable); const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null; + // Sichere Drittplatzierte (Team-IDs, deren Top-8-Platz mathematisch feststeht) + const secureThirdTeams = securelyQualifiedThirdTeams(matches, teams); + return NextResponse.json({ updatedAt: new Date().toISOString(), teams, @@ -48,6 +52,7 @@ export async function GET() { groupTables, groupTablesLive, thirdTable, + secureThirdTeams: [...secureThirdTeams], annexAssignment: annex, annexResolved: annex != null, }); diff --git a/app/components/ThirdPlace.tsx b/app/components/ThirdPlace.tsx index 8aec007..fcfce3a 100644 --- a/app/components/ThirdPlace.tsx +++ b/app/components/ThirdPlace.tsx @@ -3,12 +3,13 @@ import { Team, ThirdPlaceRow } from "@/lib/types"; export default function ThirdPlace({ - rows, teams, -}: { rows: ThirdPlaceRow[]; teams: Team[] }) { + rows, teams, secureTeams, +}: { rows: ThirdPlaceRow[]; teams: Team[]; secureTeams?: string[] }) { const name = (id: string) => { const t = teams.find((t) => t.id === id); return t?.localisedName ?? t?.name ?? id; }; + const secureSet = secureTeams ? new Set(secureTeams) : null; return (
@@ -35,7 +36,12 @@ export default function ThirdPlace({ > {r.overallRank} {r.group} - {name(r.teamId)} + + {name(r.teamId)} + {secureSet?.has(r.teamId) && ( + + )} + {r.played} {r.points} {r.goalDiff > 0 ? `+${r.goalDiff}` : r.goalDiff} diff --git a/app/page.tsx b/app/page.tsx index e30e4e1..4893333 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -16,6 +16,7 @@ interface ApiData { groupTables: GroupTable[]; groupTablesLive: GroupTable[]; thirdTable: ThirdPlaceRow[]; + secureThirdTeams: string[]; annexAssignment: ThirdAssignment | null; annexResolved: boolean; } @@ -128,7 +129,7 @@ export default function Home() { /> )} {data && tab === "thirds" && ( - + )} {data && tab === "bracket" && ( = [ @@ -25,7 +25,6 @@ interface GroupThirdState { 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" @@ -33,8 +32,6 @@ function openMatches(group: GroupId, matches: Match[]): Match[] { ); } -// 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 } { @@ -47,21 +44,14 @@ function enumerateThirdPossibilities( 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); - } + 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 }; - } + if (totalCombos > 500) return { minPoints: 0, maxPoints: 9, teamIds }; for (let combo = 0; combo < totalCombos; combo++) { let c = combo; @@ -74,62 +64,34 @@ function enumerateThirdPossibilities( 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); - } + 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 +function buildThirdStates(matches: Match[], teams: Team[]): Map { 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 gm = matches.filter((m) => m.group === g); + const allDone = gm.length > 0 && gm.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 (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]), - }); + 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, - }); + states.set(g, { finished: false, third, minPoints, maxPoints, possibleTeamIds: teamIds }); } } + return states; +} - // 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 +function classifyGroups(states: Map): { + lockedIn: Set; lockedOut: Set; contested: Set; +} { const lockedIn = new Set(); const lockedOut = new Set(); const contested = new Set(); @@ -138,51 +100,6 @@ export function thirdSlotIsSecure( 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; @@ -190,9 +107,8 @@ export function thirdSlotIsSecure( if (os.maxPoints > st.minPoints) countCouldBeBetter++; else if (os.maxPoints === st.minPoints && !st.finished && os.finished) countCouldBeBetter++; } - alwaysTop8 = countCouldBeBetter <= 7; + const 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; @@ -200,135 +116,135 @@ export function thirdSlotIsSecure( if (os.minPoints > st.maxPoints) countDefinitelyBetter++; else if (os.minPoints === st.maxPoints && os.finished && !st.finished) countDefinitelyBetter++; } - neverTop8 = countDefinitelyBetter >= 8; + const neverTop8 = countDefinitelyBetter >= 8; if (alwaysTop8) lockedIn.add(g); else if (neverTop8) lockedOut.add(g); else contested.add(g); } + return { lockedIn, lockedOut, contested }; +} + +// --- Public API --- + +// Liefert die Team-IDs der Dritten, die mathematisch sicher unter den Top 8 +// der gruppenübergreifenden Dritten-Tabelle sind. Prüft für jedes Team aus +// einer FERTIGEN Gruppe (played === 3), ob es in ALLEN noch möglichen +// Restspiel-Konstellationen von maximal 7 anderen Dritten überholt werden kann. +export function securelyQualifiedThirdTeams(matches: Match[], teams: Team[]): Set { + const states = buildThirdStates(matches, teams); + const secureTeams = new Set(); + + for (const g of GROUP_IDS) { + const st = states.get(g)!; + // Nur fertige Gruppen mit eindeutigem Dritten + if (!st.finished || !st.third || st.possibleTeamIds.size !== 1) continue; + + const my = st.third; + // Zähle, wie viele andere Gruppen einen Dritten stellen KÖNNEN, + // der definitiv BESSER ist als dieses Team (Worst-Case für uns: deren Best-Case vs. unser festes Ergebnis). + let couldBeBetter = 0; + for (const og of GROUP_IDS) { + if (og === g) continue; + const os = states.get(og)!; + if (os.finished && os.third) { + if (os.third.points > my.points) couldBeBetter++; + else if (os.third.points === my.points && os.third.goalDiff > my.goalDiff) couldBeBetter++; + else if (os.third.points === my.points && os.third.goalDiff === my.goalDiff && os.third.goalsFor > my.goalsFor) couldBeBetter++; + } else { + // Offene Gruppe: kann deren Dritter uns im Best-Case überholen? + if (os.maxPoints > my.points) couldBeBetter++; + else if (os.maxPoints === my.points && !os.finished) couldBeBetter++; // TD/GF offen → potentiell besser + } + } + + if (couldBeBetter <= 7) secureTeams.add(my.teamId); + } + + return secureTeams; +} + +// Liefert die Gruppen, deren Dritter mathematisch sicher unter den Top 8 ist. +// (Gruppen-Ebene — für Fix-Markierung im Baum, nicht für Team-Häkchen.) +export function securelyQualifiedThirdGroups(matches: Match[], teams: Team[]): GroupId[] { + const states = buildThirdStates(matches, teams); + const { lockedIn } = classifyGroups(states); + return [...lockedIn]; +} + +// Prüft, ob ein bestimmter Dritten-Slot (Gegner des Siegers von `winnerGroup`) +// in ALLEN noch möglichen Konstellationen identisch bleibt. +export function thirdSlotIsSecure(winnerGroup: GroupId, matches: Match[], teams: Team[]): boolean { + const states = buildThirdStates(matches, teams); + const { lockedIn, contested } = classifyGroups(states); - // 3. lockedIn >= 8: wenn genau 8 → Zuordnung EINDEUTIG (nur eine Kombination). - // Wenn > 8 → mehrdeutig (versch. 8er-Teilmengen möglich) → konservativ false. if (lockedIn.size > 8) { console.log("[3RD-SECURE] false: >8 lockedIn | wg:", winnerGroup, "| lockedIn:", [...lockedIn].join(",")); return false; } - // Genau 8 lockedIn → nur eine mögliche Kombination → Annex C ist determiniert. - // Direkt prüfen, da Enumeration entfällt. if (lockedIn.size === 8) { - const qGroups = [...lockedIn]; - const assignment = resolveAnnexC(qGroups); + const assignment = resolveAnnexC([...lockedIn]); if (!assignment) { console.log("[3RD-SECURE] false: no annex with 8 locked | wg:", winnerGroup); return false; } - const assignedGroup = assignment[winnerGroup]; - if (!assignedGroup) { console.log("[3RD-SECURE] false: no assignedGroup (8 locked) | wg:", winnerGroup); return false; } - const stableState = states.get(assignedGroup); - if (!stableState) return false; - const result = stableState.finished && stableState.third != null && stableState.possibleTeamIds.size === 1; - console.log("[3RD-SECURE] final (8 locked):", result, "| wg:", winnerGroup, "| stableGroup:", assignedGroup, "| finished:", stableState.finished, "| hasThird:", !!stableState.third, "| uniqueTeam:", stableState.possibleTeamIds.size); + const ag = assignment[winnerGroup]; + if (!ag) { console.log("[3RD-SECURE] false: no assignedGroup (8 locked) | wg:", winnerGroup); return false; } + const ss = states.get(ag); + if (!ss) return false; + const result = ss.finished && ss.third != null && ss.possibleTeamIds.size === 1; + console.log("[3RD-SECURE] final (8 locked):", result, "| wg:", winnerGroup, "| stableGroup:", ag); return result; } if (lockedIn.size + contested.size < 8) { - // Nicht genug Gruppen, um 8 Dritte zu füllen → Annex C nicht auflösbar console.log("[3RD-SECURE] false: <8 possible | wg:", winnerGroup, "| lockedIn:", lockedIn.size, "| contested:", contested.size); 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) { console.log("[3RD-SECURE] false: >200 combos | wg:", winnerGroup); return false; } let stableGroup: GroupId | null = null; - for (const indices of enumerateCombinations(contestedArr.length, need)) { - const qGroups: GroupId[] = [...lockedArr, ...indices.map((i) => contestedArr[i])]; + const qGroups: GroupId[] = [...lockedIn, ...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) { console.log("[3RD-SECURE] false: no assignedGroup | wg:", winnerGroup); return false; } - - if (stableGroup === null) { - stableGroup = assignedGroup; - } else if (stableGroup !== assignedGroup) { - console.log("[3RD-SECURE] false: unstable slot | wg:", winnerGroup, "| groups:", stableGroup, "vs", assignedGroup); - return false; - } + const ag = assignment[winnerGroup]; + if (!ag) { console.log("[3RD-SECURE] false: no assignedGroup | wg:", winnerGroup); return false; } + if (stableGroup === null) stableGroup = ag; + else if (stableGroup !== ag) { console.log("[3RD-SECURE] false: unstable slot | wg:", winnerGroup); return false; } } - // 5. Slot ist stabil → prüfe, ob die Quellgruppe selbst bereits feststeht if (stableGroup === null) { console.log("[3RD-SECURE] false: stableGroup=null | wg:", winnerGroup); return false; } - const stableState = states.get(stableGroup); - if (!stableState) return false; - const result = stableState.finished && stableState.third != null && stableState.possibleTeamIds.size === 1; - console.log("[3RD-SECURE] final:", result, "| wg:", winnerGroup, "| stableGroup:", stableGroup, "| finished:", stableState.finished, "| hasThird:", !!stableState.third, "| uniqueTeam:", stableState.possibleTeamIds.size); + const ss = states.get(stableGroup); + if (!ss) return false; + const result = ss.finished && ss.third != null && ss.possibleTeamIds.size === 1; + console.log("[3RD-SECURE] final:", result, "| wg:", winnerGroup, "| stableGroup:", stableGroup); return result; } -// Berechnet C(n, k) — die Anzahl der Kombinationen. +// --- Combinatorics helpers --- + 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); + let r = 1; + for (let i = 1; i <= k; i++) r = r * (n - i + 1) / i; + return Math.round(r); } -// 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); + const idx = Array.from({ length: k }, (_, i) => i); while (true) { - yield [...indices]; + yield [...idx]; let i = k - 1; - while (i >= 0 && indices[i] === n - k + i) i--; + while (i >= 0 && idx[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; + idx[i]++; + for (let j = i + 1; j < k; j++) idx[j] = idx[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; -}