import { GroupId, GroupTable, Match, Team, ThirdPlaceRow } from "@/lib/types"; import { R32, LATER_ROUNDS, R32Match, BracketSlot, ThirdAssignment, slotLabel, } from "@/lib/bracket"; import { placeIsSecure } from "@/lib/secure-places"; import { thirdSlotIsSecure, securelyQualifiedThirdTeams } from "@/lib/third-place-security"; import { Dictionary } from "@/lib/i18n"; // Ein aufgelöster Teilnehmer einer K.o.-Begegnung. export interface ResolvedSide { teamId: string | null; // null = noch Platzhalter label: string; // Anzeige (Teamname oder "Sieger E" etc.) code: string; score: number | null; isWinner: boolean; provisional: boolean; // true = nur auf Basis aktueller Tabelle, noch nicht fix tooltip: string | null; // Hover-Erklärung (Herkunft + ob fix) } export interface ResolvedTie { matchNumber: number; stage: string; home: ResolvedSide; away: ResolvedSide; status: string; prob?: { home: number; draw: number | null; away: number } | null; homePenalty?: number | null; awayPenalty?: number | null; } // Findet das tatsächliche Spiel (aus dem Feed) zu einer FIFA-Match-Nummer. function feedMatch(matches: Match[], num: number): Match | undefined { return matches.find((m) => m.matchNumber === num && m.group == null); } // Bestimmt den Sieger eines abgeschlossenen Spiels (Feed) als Team-ID. function winnerOf(m: Match | undefined): string | null { if (!m || m.status !== "FINISHED") return null; if (m.winnerTeamId) return m.winnerTeamId; if (m.homeScore == null || m.awayScore == null) return null; if (m.homeScore > m.awayScore) return m.homeTeamId; if (m.awayScore > m.homeScore) return m.awayTeamId; return null; } // Bestimmt im Sim-Modus die Sieger-Slot-ID. // Real gespielte Spiele: echtes winnerTeamId (inkl. Elfmeter) auf die // aufgelöste Slot-Seite mappen. Simulierte Spiele: Score-Vergleich. function simWinnerSlotId(feed: Match, homeSlotId: string, awaySlotId: string): string { if (feed.status === "FINISHED" && feed.winnerTeamId) { if (feed.winnerTeamId === homeSlotId) return homeSlotId; if (feed.winnerTeamId === awaySlotId) return awaySlotId; if (feed.homePenalty != null && feed.awayPenalty != null && feed.homePenalty !== feed.awayPenalty) { return feed.homePenalty > feed.awayPenalty ? homeSlotId : awaySlotId; } } if (feed.homeScore != null && feed.awayScore != null) { if (feed.homeScore > feed.awayScore) return homeSlotId; if (feed.awayScore > feed.homeScore) return awaySlotId; } return homeSlotId; } function loserOf(m: Match | undefined): string | null { if (!m || m.status !== "FINISHED") return null; if (m.winnerTeamId) { return m.homeTeamId === m.winnerTeamId ? m.awayTeamId : m.homeTeamId; } if (m.homeScore == null || m.awayScore == null) return null; if (m.homeScore > m.awayScore) return m.awayTeamId; if (m.awayScore > m.homeScore) return m.homeTeamId; return null; } // 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. function resolveR32Slot( slot: BracketSlot, winnerGroup: GroupId | undefined, tables: GroupTable[], assignment: ThirdAssignment | null, thirds: ThirdPlaceRow[], matches: Match[], teams: Team[], annexResolved: boolean, secureTeamIds: Set, dict?: Dictionary, ): { teamId: string | null; provisional: boolean; tooltip: string | null } { const t = dict?.tooltips; const table = (g: GroupId) => tables.find((t) => t.group === g); if (slot.type === "W") { const tab = table(slot.group!); const teamId = tab?.rows.find((r) => r.rank === 1)?.teamId ?? null; // Sieger fix, sobald Platz 1 rechnerisch gesichert ist (auch vor Gruppenende). const provisional = !placeIsSecure(slot.group!, 1, teams, matches); const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : ""; return { teamId, provisional, tooltip: `${prefix}${t?.firstOfGroup(slot.group!) ?? `1. Gruppe ${slot.group}`}` }; } if (slot.type === "R") { const tab = table(slot.group!); const teamId = tab?.rows.find((r) => r.rank === 2)?.teamId ?? null; // Zweiter fix, sobald Platz 2 rechnerisch gesichert ist. const provisional = !placeIsSecure(slot.group!, 2, teams, matches); const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : ""; return { teamId, provisional, tooltip: `${prefix}${t?.secondOfGroup(slot.group!) ?? `2. Gruppe ${slot.group}`}` }; } // 3. Platz: braucht Annex-C-Zuordnung + den Gruppensieger dieses Spiels if (slot.type === "3" && assignment && winnerGroup) { const thirdGroup = assignment[winnerGroup]; if (thirdGroup) { const row = thirds.find((r) => r.group === thirdGroup && r.qualifies); // Fix nur, wenn ALLE Bedingungen erfüllt sind: // 1. Annex C aufgelöst (8 Dritte zuweisbar) // 2. Der Slot ist in allen noch möglichen Konstellationen stabil // 3. Das konkrete Team ist sicher qualifiziert (kann nicht aus Top 8 fallen) // 4. Die Gruppe hat alle Spiele gespielt (Team bekannt) const slotStable = thirdSlotIsSecure(winnerGroup, matches, teams); const teamSecure = row?.teamId ? secureTeamIds.has(row.teamId) : false; const fix = annexResolved && slotStable && teamSecure && row?.qualifies === true; const provisional = !fix; const prefix = provisional ? (t?.provisionalPrefix ?? "aktuell") + " " : ""; return { teamId: row?.teamId ?? null, provisional, tooltip: `${prefix}${t?.thirdOfGroup(thirdGroup) ?? `3. der Gruppe ${thirdGroup}`}`, }; } } if (slot.type === "3") { const pool = slot.thirdPool?.join("") ?? "?"; return { teamId: null, provisional: true, tooltip: t?.provisionalThird(pool) ?? `aktuell 3. Gruppe ${pool}` }; } return { teamId: null, provisional: true, tooltip: null }; } function sideFrom( teamId: string | null, fallbackLabel: string, teams: Team[], feed: Match | undefined, which: "home" | "away", provisional: boolean, tooltip: string | null, ): ResolvedSide { const team = teamId ? teams.find((t) => t.id === teamId) : undefined; const score = feed ? which === "home" ? feed.homeScore : feed.awayScore : null; const w = winnerOf(feed); return { teamId, label: team?.localisedName ?? team?.name ?? fallbackLabel, code: team?.code ?? "", score, isWinner: teamId != null && w === teamId, provisional: teamId != null && provisional, tooltip, }; } // Baut die komplette aufgelöste Bracket-Struktur. // resolveWinners: wenn true, werden die Gewinner-TeamIDs aus den aufgelösten // R32-Slots abgeleitet (basierend auf den Spiel-Scores), statt aus den Team-IDs // des Feeds. Nötig für die Simulation, wo KO-Matches Scores aber keine Team-IDs // im Feed haben. export function resolveBracket( matches: Match[], teams: Team[], tables: GroupTable[], thirds: ThirdPlaceRow[], assignment: ThirdAssignment | null, annexResolved: boolean, resolveWinners = false, dict?: Dictionary, ): { r32: ResolvedTie[]; later: Record } { // Map: Match-Nummer -> Sieger-Team-ID (für Propagation in Folgerunden) const winners = new Map(); const losers = new Map(); // Map: Match-Nummer -> ist das Spiel beendet (Sieger fix)? const decided = new Map(); const secureTeamIds = securelyQualifiedThirdTeams(matches, teams); const b = dict?.bracket; const r32: ResolvedTie[] = R32.map((rm: R32Match) => { const feed = feedMatch(matches, rm.matchNumber); const homeGroup = rm.home.type === "W" ? rm.home.group : undefined; const awayGroup = rm.away.type === "W" ? rm.away.group : undefined; // Für 3.-Platz-Slots: Gruppensieger ist die andere Seite (immer W) const winnerGroup = (homeGroup ?? awayGroup) as GroupId | undefined; const h = resolveR32Slot(rm.home, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds, dict); const a = resolveR32Slot(rm.away, winnerGroup, tables, assignment, thirds, matches, teams, annexResolved, secureTeamIds, dict); const home = sideFrom(h.teamId, slotLabel(rm.home, assignment, winnerGroup, dict), teams, feed, "home", h.provisional, h.tooltip); const away = sideFrom(a.teamId, slotLabel(rm.away, assignment, winnerGroup, dict), teams, feed, "away", a.provisional, a.tooltip); const feedWinner = winnerOf(feed); if (resolveWinners && feed && h.teamId && a.teamId && feed.homeScore != null && feed.awayScore != null) { const winnerId = simWinnerSlotId(feed, h.teamId, a.teamId); const loserId = winnerId === h.teamId ? a.teamId : h.teamId; winners.set(rm.matchNumber, winnerId); losers.set(rm.matchNumber, loserId); } else { winners.set(rm.matchNumber, feedWinner); losers.set(rm.matchNumber, loserOf(feed)); } decided.set(rm.matchNumber, feed?.status === "FINISHED"); return { matchNumber: rm.matchNumber, stage: "R32", home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob, homePenalty: feed?.homePenalty, awayPenalty: feed?.awayPenalty, }; }); const later: Record = {}; for (const km of LATER_ROUNDS) { const feed = feedMatch(matches, km.matchNumber); const src = km.losers ? losers : winners; const homeId = src.get(km.fromHome) ?? null; const awayId = src.get(km.fromAway) ?? null; if (km.matchNumber === 89 || km.matchNumber === 90) { console.log("[PROPAGATE-R16]", { zielSlot: km.matchNumber, fromHome: km.fromHome, fromAway: km.fromAway, homeIdAusWinners: homeId, awayIdAusWinners: awayId, homeIdAusLosers: losers.get(km.fromHome) ?? null, awayIdAusLosers: losers.get(km.fromAway) ?? null, }); } // Ein Folgeteam ist fix, wenn das speisende Spiel beendet ist. const homeProv = !(decided.get(km.fromHome) ?? false); const awayProv = !(decided.get(km.fromAway) ?? false); const loserLabel = km.losers ? true : false; const homeLabel = loserLabel ? (b?.loser ?? "Verlierer") + " " + km.fromHome : (b?.winner ?? "Sieger") + " " + km.fromHome; const awayLabel = loserLabel ? (b?.loser ?? "Verlierer") + " " + km.fromAway : (b?.winner ?? "Sieger") + " " + km.fromAway; const homeTtip = loserLabel ? (b?.loserFromMatch(km.fromHome) ?? `Verlierer aus Spiel ${km.fromHome}`) : (b?.winnerFromMatch(km.fromHome) ?? `Sieger aus Spiel ${km.fromHome}`); const awayTtip = loserLabel ? (b?.loserFromMatch(km.fromAway) ?? `Verlierer aus Spiel ${km.fromAway}`) : (b?.winnerFromMatch(km.fromAway) ?? `Sieger aus Spiel ${km.fromAway}`); const home = sideFrom(homeId, homeLabel, teams, feed, "home", homeProv, homeTtip); const away = sideFrom(awayId, awayLabel, teams, feed, "away", awayProv, awayTtip); const feedWinner = winnerOf(feed); if (resolveWinners && feed && homeId && awayId && feed.homeScore != null && feed.awayScore != null) { const winnerId = simWinnerSlotId(feed, homeId, awayId); const loserId = winnerId === homeId ? awayId : homeId; winners.set(km.matchNumber, winnerId); losers.set(km.matchNumber, loserId); } else { winners.set(km.matchNumber, feedWinner); losers.set(km.matchNumber, loserOf(feed)); } decided.set(km.matchNumber, feed?.status === "FINISHED"); later[km.matchNumber] = { matchNumber: km.matchNumber, stage: km.stage, home, away, status: feed?.status ?? "SCHEDULED", prob: feed?.prob, homePenalty: feed?.homePenalty, awayPenalty: feed?.awayPenalty, }; } return { r32, later }; }