Files
wm-projekt/lib/third-place-security.ts

272 lines
10 KiB
TypeScript

import { GroupId, GROUP_IDS, Match, Team } from "./types";
import { computeGroupTables } from "./standings";
import { resolveAnnexC } 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>;
}
function openMatches(group: GroupId, matches: Match[]): Match[] {
return matches.filter(
(m) => m.group === group && m.status !== "FINISHED"
&& m.homeTeamId != null && m.awayTeamId != null,
);
}
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) {
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);
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 };
}
function buildThirdStates(matches: Match[], teams: Team[]): Map<GroupId, GroupThirdState> {
const states = new Map<GroupId, GroupThirdState>();
for (const g of GROUP_IDS) {
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 (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 });
}
}
return states;
}
function classifyGroups(states: Map<GroupId, GroupThirdState>): {
lockedIn: Set<GroupId>; lockedOut: Set<GroupId>; contested: Set<GroupId>;
} {
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; }
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++;
}
const alwaysTop8 = countCouldBeBetter <= 7;
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++;
}
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<string> {
console.log("[3RD-DISPLAY] ThirdPlace ✓-haekchen = securelyQualifiedThirdTeams (team-level, zaehlt GD)",
"| ThirdPlace GRUENER name = CSS .team-complete (played===3, KEIN Sicherheitsindikator)",
"| Bracket fix = thirdSlotIsSecure -> classifyGroups -> lockedIn (group-level, NUR Punkte, KEIN GD)");
console.log("[3RD-SOURCE] securelyQualifiedThirdTeams aufgerufen (teams)");
const states = buildThirdStates(matches, teams);
const secureTeams = new Set<string>();
const { lockedIn } = classifyGroups(states);
for (const g of GROUP_IDS) {
const st = states.get(g)!;
if (!st.finished || !st.third || st.possibleTeamIds.size !== 1) continue;
const my = st.third;
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 {
if (os.maxPoints > my.points) couldBeBetter++;
else if (os.maxPoints === my.points && !os.finished) couldBeBetter++;
}
}
const team = teams.find(t => t.id === my.teamId);
const isSecure = couldBeBetter <= 7;
if (isSecure) secureTeams.add(my.teamId);
// Diagnose-Log für Iran und Grenzfälle
const targetTeams = new Set(["IRN", "KOR", "ALG", "CRO"]); // Team-Codes
if (team && targetTeams.has(team.code)) {
console.log("[3RD-TEAM-SECURE]",
"team:", team.name, "| group:", g,
"| punkte:", my.points, "| gd:", my.goalDiff,
"| couldBeBetter:", couldBeBetter,
"| gruppe lockedIn:", lockedIn.has(g),
"| als sicher markiert:", isSecure);
}
}
const iranTeam = teams.find(t => t.code === "IRN");
if (iranTeam) {
console.log("[3RD-IRAN-DISPLAY]",
"secureTeams enthaelt Iran:", secureTeams.has(iranTeam.id),
"| secureGroups (lockedIn) enthaelt G:", lockedIn.has("G"),
"| haekchen gezeigt:", secureTeams.has(iranTeam.id) ? "ja" : "nein",
"| name gruen (CSS team-complete, played===3):", "immer bei finished gruppe",
"| im baum fix:", lockedIn.has("G") ? "ja (thirdSlotIsSecure via classifyGroups/POINTS)" : "nein");
}
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[] {
console.log("[3RD-SOURCE] securelyQualifiedThirdGroups aufgerufen (groups)");
const states = buildThirdStates(matches, teams);
const { lockedIn, lockedOut, contested } = classifyGroups(states);
console.log("[3RD-CLASSIFY] lockedIn:", [...lockedIn].join(","),
"| lockedOut:", [...lockedOut].join(","),
"| contested:", [...contested].join(","));
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 {
console.log("[3RD-SOURCE] thirdSlotIsSecure aufgerufen (slot-fix via classifyGroups)");
const states = buildThirdStates(matches, teams);
const { lockedIn, contested } = classifyGroups(states);
if (lockedIn.size > 8) return false;
if (lockedIn.size === 8) {
const assignment = resolveAnnexC([...lockedIn]);
if (!assignment) return false;
const ag = assignment[winnerGroup];
if (!ag) return false;
const ss = states.get(ag);
if (!ss) return false;
const result = ss.finished && ss.third != null && ss.possibleTeamIds.size === 1;
return result;
}
if (lockedIn.size + contested.size < 8) return false;
const contestedArr = [...contested];
const need = 8 - lockedIn.size;
const combos = combinations(contestedArr.length, need);
if (combos > 200) return false;
let stableGroup: GroupId | null = null;
for (const indices of enumerateCombinations(contestedArr.length, need)) {
const qGroups: GroupId[] = [...lockedIn, ...indices.map((i) => contestedArr[i])];
if (qGroups.length !== 8) continue;
const assignment = resolveAnnexC(qGroups);
if (!assignment) continue;
const ag = assignment[winnerGroup];
if (!ag) return false;
if (stableGroup === null) stableGroup = ag;
else if (stableGroup !== ag) return false;
}
if (stableGroup === null) return false;
const ss = states.get(stableGroup);
if (!ss) return false;
const result = ss.finished && ss.third != null && ss.possibleTeamIds.size === 1;
return result;
}
// --- Combinatorics helpers ---
function combinations(n: number, k: number): number {
if (k < 0 || k > n) return 0;
if (k === 0 || k === n) return 1;
let r = 1;
for (let i = 1; i <= k; i++) r = r * (n - i + 1) / i;
return Math.round(r);
}
function* enumerateCombinations(n: number, k: number): Generator<number[]> {
if (k === 0) { yield []; return; }
if (k > n) return;
const idx = Array.from({ length: k }, (_, i) => i);
while (true) {
yield [...idx];
let i = k - 1;
while (i >= 0 && idx[i] === n - k + i) i--;
if (i < 0) break;
idx[i]++;
for (let j = i + 1; j < k; j++) idx[j] = idx[j - 1] + 1;
}
}