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