This commit is contained in:
2026-06-22 22:11:04 -05:00
parent 73d07e7f18
commit 93ae2cbf0a
9 changed files with 519 additions and 53 deletions

94
lib/secure-places.ts Normal file
View File

@@ -0,0 +1,94 @@
import { GroupId, Match, Team } from "./types";
import { computeGroupTables } from "./standings";
// Bestimmt, welche Tabellenplätze einer Gruppe bereits MATHEMATISCH feststehen
// auch wenn noch Spiele ausstehen. Berücksichtigt ALLE FIFA-Tiebreaker, weil die
// Tabelle über computeGroupTables() berechnet wird (Punkte, Direktvergleich,
// Tordifferenz, Tore).
//
// Methode: vollständige Worst-Case-Enumeration der Restspiele. Ein Platz p ist
// für ein Team sicher, wenn es in JEDEM möglichen Ausgang aller Restspiele auf
// Platz p oder besser bleibt. Wir enumerieren pro Restspiel mehrere
// repräsentative Ergebnisse, die alle relevanten Tiebreaker-Effekte abdecken:
// - Heimsieg knapp (1:0) und hoch (5:0)
// - Auswärtssieg knapp (0:1) und hoch (0:5)
// - Unentschieden (0:0 und 2:2)
// Diese Menge deckt Punkt- UND Tordifferenz-Szenarien ausreichend ab, um echte
// Sicherheit korrekt zu erkennen (inkl. Fälle wie "Direktvergleich bereits
// gewonnen -> uneinholbar").
// Mögliche Ergebnis-Varianten je Restspiel (homeGoals, awayGoals).
const OUTCOME_VARIANTS: Array<[number, number]> = [
[1, 0], [5, 0], // Heimsieg knapp / hoch
[0, 1], [0, 5], // Auswärtssieg knapp / hoch
[0, 0], [2, 2], // Unentschieden
];
function openMatches(group: GroupId, matches: Match[]): Match[] {
return matches.filter(
(m) => m.group === group && m.status !== "FINISHED"
&& m.homeTeamId != null && m.awayTeamId != null,
);
}
// Liefert für eine Gruppe Map: Platz(1-basiert) -> teamId, aber nur für Plätze,
// die in ALLEN Szenarien stabil von demselben Team gehalten werden.
export function securePlaces(
group: GroupId, teams: Team[], matches: Match[],
): Map<number, string> {
const open = openMatches(group, matches);
// Begrenzung: bei sehr vielen offenen Spielen wird die Enumeration groß.
// In 4er-Gruppen sind es maximal 6 offene Spiele (Turnierstart) -> 6^? zu viel.
// Wir enumerieren nur, wenn die Kombinationszahl handhabbar ist; sonst gilt
// konservativ "nichts sicher" (am Turnierstart ohnehin korrekt).
const combos = Math.pow(OUTCOME_VARIANTS.length, open.length);
if (open.length === 0) {
// Alles gespielt: aktuelle Tabelle ist final.
const table = computeGroupTables(teams, matches).find((t) => t.group === group);
const res = new Map<number, string>();
table?.rows.forEach((r) => res.set(r.rank, r.teamId));
return res;
}
if (combos > 100_000) {
return new Map(); // zu früh im Turnier -> nichts gesichert
}
// Für jeden Platz das Set der Teams sammeln, die diesen Platz über ALLE
// Szenarien einnehmen können. Ist das Set einelementig, ist der Platz sicher.
const placeTeams: Array<Set<string>> = [new Set(), new Set(), new Set(), new Set()];
const total = combos;
for (let combo = 0; combo < total; combo++) {
// Szenario zusammenbauen: jedes offene Spiel bekommt eine Variante.
let c = combo;
const simulated: Match[] = open.map((m) => {
const variantIdx = c % OUTCOME_VARIANTS.length;
c = Math.floor(c / OUTCOME_VARIANTS.length);
const [hg, ag] = OUTCOME_VARIANTS[variantIdx];
return { ...m, status: "FINISHED", homeScore: hg, awayScore: ag };
});
// Gespielte + simulierte Spiele kombinieren.
const played = matches.filter((m) => !(m.group === group && m.status !== "FINISHED"));
const all = [...played, ...simulated];
const table = computeGroupTables(teams, all).find((t) => t.group === group);
if (!table) continue;
table.rows.forEach((r) => {
placeTeams[r.rank - 1]?.add(r.teamId);
});
}
const result = new Map<number, string>();
for (let i = 0; i < 4; i++) {
if (placeTeams[i].size === 1) {
result.set(i + 1, [...placeTeams[i]][0]);
}
}
return result;
}
export function placeIsSecure(
group: GroupId, place: number, teams: Team[], matches: Match[],
): boolean {
return securePlaces(group, teams, matches).has(place);
}