third place corrected

This commit is contained in:
2026-06-26 13:25:39 -05:00
parent a38b3fa517
commit 1985289a37
2 changed files with 324 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import {
ThirdAssignment, slotLabel, ThirdAssignment, slotLabel,
} from "@/lib/bracket"; } from "@/lib/bracket";
import { placeIsSecure } from "@/lib/secure-places"; import { placeIsSecure } from "@/lib/secure-places";
import { thirdSlotIsSecure } from "@/lib/third-place-security";
// Ein aufgelöster Teilnehmer einer K.o.-Begegnung. // Ein aufgelöster Teilnehmer einer K.o.-Begegnung.
export interface ResolvedSide { export interface ResolvedSide {
@@ -46,15 +47,6 @@ function loserOf(m: Match | undefined): string | null {
return 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. // 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. // provisional = true, solange die zugrunde liegende Gruppe/Zuordnung nicht fix ist.
function resolveR32Slot( function resolveR32Slot(
@@ -91,9 +83,12 @@ function resolveR32Slot(
const row = thirds.find((r) => r.group === thirdGroup && r.qualifies); const row = thirds.find((r) => r.group === thirdGroup && r.qualifies);
// Fix nur, wenn ALLE drei Bedingungen erfüllt sind: // Fix nur, wenn ALLE drei Bedingungen erfüllt sind:
// 1. Annex C aufgelöst (8 Dritte zuweisbar) // 1. Annex C aufgelöst (8 Dritte zuweisbar)
// 2. Komplette Gruppenphase beendet (Dritten-Rangliste final) // 2. Der Slot ist in allen noch möglichen Konstellationen stabil
// 3. Das Team gehört gesichert zu den besten 8 // (Enumeration aller Annex-C-Kombinationen)
const fix = annexResolved && groupStageComplete(matches) && row?.qualifies === true; // 3. Das konkrete Team ist bekannt (Gruppe fertig)
const fix = annexResolved
&& thirdSlotIsSecure(winnerGroup, matches, teams)
&& row?.qualifies === true;
const provisional = !fix; const provisional = !fix;
const prefix = provisional ? "aktuell " : ""; const prefix = provisional ? "aktuell " : "";
return { return {

317
lib/third-place-security.ts Normal file
View File

@@ -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<string>;
}
// 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<string> } {
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<string>();
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<GroupId, GroupThirdState>();
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<GroupId>();
const lockedOut = new Set<GroupId>();
const contested = new Set<GroupId>();
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<number[]> {
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<GroupId, GroupThirdState>,
): 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;
}