import { GroupId, Match, MatchStatus, Team } from "./types"; import { venueFor } from "./venues"; import { localisedTeamName } from "./team-mappings"; import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket"; import { computeGroupTables, computeThirdPlaceTable } from "./standings"; // ---------------------------------------------------------------------------- // Caching: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser- // Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit). // ---------------------------------------------------------------------------- interface CacheEntry { value: T; expires: number; } const cache = new Map>(); export async function cached(key: string, ttlMs: number, fn: () => Promise): Promise { const hit = cache.get(key) as CacheEntry | undefined; const now = Date.now(); if (hit && hit.expires > now) return hit.value; try { const value = await fn(); cache.set(key, { value, expires: now + ttlMs }); return value; } catch (err) { // Bei Upstream-Fehler abgelaufenen Cache weiterverwenden, statt hart zu failen. if (hit) return hit.value; throw err; } } // ---------------------------------------------------------------------------- // football-data.org: Teams, Spiele, Status // ---------------------------------------------------------------------------- const FD_BASE = "https://api.football-data.org/v4"; const FD_COMP = "WC"; // FIFA World Cup function fdHeaders(): Record { const token = process.env.FOOTBALL_DATA_TOKEN; return token ? { "X-Auth-Token": token } : {}; } function mapStatus(s: string): MatchStatus { switch (s) { case "LIVE": return "LIVE"; case "IN_PLAY": return "IN_PLAY"; case "PAUSED": return "PAUSED"; case "FINISHED": return "FINISHED"; default: return "SCHEDULED"; } } // Wandelt einen football-data-Gruppennamen ("GROUP_A") in unsere GroupId. function parseGroup(g: string | null | undefined): GroupId | null { if (!g) return null; const m = /GROUP_([A-L])/.exec(g); return m ? (m[1] as GroupId) : null; } interface FdMatchesResponse { matches: Array<{ id: number; utcDate: string; status: string; minute?: number | null; matchday?: number | null; stage: string; group?: string | null; venue?: string | null; attendance?: number | null; homeTeam: { id: number | null; name: string | null; tla?: string | null; crest?: string | null }; awayTeam: { id: number | null; name: string | null; tla?: string | null; crest?: string | null }; score: { fullTime: { home: number | null; away: number | null } }; }>; } function stageFor(stage: string, group: GroupId | null): Match["stage"] { if (group) return "GROUP"; switch (stage) { case "LAST_32": return "R32"; case "LAST_16": return "R16"; case "QUARTER_FINALS": return "QF"; case "SEMI_FINALS": return "SF"; case "THIRD_PLACE": return "3RD"; case "FINAL": return "FINAL"; default: return "GROUP"; } } // Phasen-Reihenfolge für die K.o.-Nummerierung. const STAGE_ORDER: Record = { GROUP: 0, R32: 1, R16: 2, QF: 3, SF: 4, "3RD": 5, FINAL: 6, }; // Setzt Spielnummern und Stadien. // K.o.-Spiele: Nummerierung über Slot-Auflösung (assignKONumbersBySlots), // NICHT mehr chronologisch — die FIFA-Nummern folgen der Bracket-Topologie. // Venue: Gruppenspiele über die Teampaarung, K.o.-Spiele über die Spielnummer. function assignNumbersAndVenues(matches: Match[], teams: Team[]): void { // Stadien setzen (Gruppenphase per Paarung, K.o. per Nummer). for (const m of matches) { m.venue = venueFor(m, teams); } } // Vergibt K.o.-Match-Nummern über Slot-Auflösung (FIFA-Topologie) // statt chronologisch. Für R32-Matches mit Team-IDs wird der Slot gesucht, // dessen aufgelöste Teams dem Feed-Paar entsprechen. export function assignKONumbersBySlots(matches: Match[], teams: Team[]): void { const tables = computeGroupTables(teams, matches); const thirds = computeThirdPlaceTable(tables); const qGroups = qualifiedThirdGroups(thirds); const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null; const byPair = new Map(); for (const m of matches) { if (m.group != null || !m.homeTeamId || !m.awayTeamId) continue; byPair.set(`${m.homeTeamId}::${m.awayTeamId}`, m); byPair.set(`${m.awayTeamId}::${m.homeTeamId}`, m); } for (const slot of R32) { const homeGroup = slot.home.type === "W" ? slot.home.group : undefined; const awayGroup = slot.away.type === "W" ? slot.away.group : undefined; const wg = (homeGroup ?? awayGroup) as GroupId | undefined; let hid: string | null = null; if (slot.home.type === "W" && slot.home.group) { hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 1)?.teamId ?? null; } else if (slot.home.type === "R" && slot.home.group) { hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 2)?.teamId ?? null; } else if (slot.home.type === "3" && annex && wg) { const tg = annex[wg]; if (tg) hid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null; } let aid: string | null = null; if (slot.away.type === "W" && slot.away.group) { aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 1)?.teamId ?? null; } else if (slot.away.type === "R" && slot.away.group) { aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 2)?.teamId ?? null; } else if (slot.away.type === "3" && annex && wg) { const tg = annex[wg]; if (tg) aid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null; } if (hid && aid) { const fm = byPair.get(`${hid}::${aid}`); if (fm) fm.matchNumber = slot.matchNumber; } } // R16–Finale: per Stage + Datum den LATER_ROUNDS-Slots zuordnen for (const stage of ["R16", "QF", "SF", "3RD", "FINAL"] as const) { const sm = matches.filter(m => m.stage === stage && m.group == null && m.matchNumber === 0) .sort((a, b) => +new Date(a.utcDate) - +new Date(b.utcDate)); const ls = LATER_ROUNDS.filter(k => k.stage === (stage === "3RD" ? "3RD" : stage)); for (let i = 0; i < sm.length && i < ls.length; i++) { sm[i].matchNumber = ls[i].matchNumber; } } } // Holt alle Spiele + leitet die Teamliste daraus ab (spart einen Extra-Call). export async function fetchMatchesAndTeams(): Promise<{ matches: Match[]; teams: Team[] }> { return cached("fd:matches", 60_000, async () => { const res = await fetch(`${FD_BASE}/competitions/${FD_COMP}/matches`, { headers: fdHeaders(), // Next.js: kein eigenes Caching, wir cachen selbst cache: "no-store", }); if (!res.ok) throw new Error(`football-data ${res.status}`); const data = (await res.json()) as FdMatchesResponse; const teamMap = new Map(); const matches: Match[] = data.matches.map((m) => { const group = parseGroup(m.group); // Teams registrieren (nur wenn Gruppenspiel und ID vorhanden) for (const side of [m.homeTeam, m.awayTeam]) { if (side.id != null && side.name && group) { const id = String(side.id); if (!teamMap.has(id)) { teamMap.set(id, { id, name: side.name, code: side.tla ?? "", group, crest: `/crests/${id}.svg`, localisedName: localisedTeamName(side.tla ?? "", side.name ?? ""), }); } } } return { id: String(m.id), group, stage: stageFor(m.stage, group), // Vorläufig 0 — die echte FIFA-Spielnummer wird unten gesetzt. matchNumber: 0, utcDate: m.utcDate, status: mapStatus(m.status), minute: m.minute ?? null, homeTeamId: m.homeTeam.id != null ? String(m.homeTeam.id) : null, awayTeamId: m.awayTeam.id != null ? String(m.awayTeam.id) : null, homeScore: m.score.fullTime.home, awayScore: m.score.fullTime.away, venue: null, // wird unten aus der Map gesetzt attendance: m.attendance ?? null, }; }); const teams = [...teamMap.values()]; assignNumbersAndVenues(matches, teams); return { matches, teams }; }); } // ---------------------------------------------------------------------------- // Polymarket Gamma API: Wahrscheinlichkeiten pro Spiel (Series soccer-fifwc) // ---------------------------------------------------------------------------- const PM_BASE = "https://gamma-api.polymarket.com"; const PM_SERIES = "11433"; // WM 2026 Serie interface PmEvent { id: string; slug: string; title: string; markets?: PmMarket[]; } interface PmMarket { slug: string; groupItemTitle: string; outcomes: string; // JSON-String ["Yes","No"] outcomePrices: string; // JSON-String ["0.385","0.615"] sportsMarketType?: string; closed: boolean; } export interface ParsedOdds { slug: string; homeCode: string; awayCode: string; homeName: string; awayName: string; pHome: number; pDraw: number; pAway: number; startTime: string | null; } // Normalisiert Teamcodes zwischen Polymarket-Slug und Feed (3-Buchstaben). // Bekannte Abweichungen: kr/kor (Südkorea), nld/ned (Niederlande), cdr/cod (DR Kongo). const CODE_NORM: Record = { kor: "kr", nld: "ned", cdr: "cod", cvi: "cpv", rsa: "rsa", // ok swe: "swe", // ok }; function normCode(c: string): string { return (CODE_NORM[c] ?? c).toLowerCase(); } function safeParse(s: string, fallback: T): T { try { return JSON.parse(s) as T; } catch { return fallback; } } // Holt alle WM-Spiele über den Series-Endpoint mit Pagination. // Parst pro Event die drei Moneyline-Märkte (Heim/Draw/Auswärts). export async function fetchOdds(): Promise { return cached("pm:odds", 300_000, async () => { const allEvents: PmEvent[] = []; for (let offset = 0; ; offset += 100) { const url = `${PM_BASE}/events?series_id=${PM_SERIES}&active=true&closed=false&limit=100&offset=${offset}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, }); if (!res.ok) throw new Error(`polymarket ${res.status}`); const page = (await res.json()) as PmEvent[]; allEvents.push(...page); if (page.length < 100) break; } const parsed: ParsedOdds[] = []; for (const ev of allEvents) { // Slug: fifwc-{home}-{away}-{yyyy}-{mm}-{dd} const slugParts = ev.slug.replace("fifwc-", "").split("-"); if (slugParts.length < 5) continue; const homeCode = normCode(slugParts[0]); const awayCode = normCode(slugParts[1]); let pHome = 0, pDraw = 0, pAway = 0; let homeName = "", awayName = ""; let marketGameStartTime: string | null = null; for (const mk of ev.markets ?? []) { if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue; if (mk.closed) continue; if (!marketGameStartTime) marketGameStartTime = (mk as any).gameStartTime ?? null; const prices = safeParse(mk.outcomePrices, []).map(Number); if (prices.length < 2) continue; const yes = prices[0]; // erster Preis = "Yes"-Wahrscheinlichkeit const gti = mk.groupItemTitle.toLowerCase(); if (mk.slug.endsWith("-draw") || gti.startsWith("draw")) { pDraw = yes; } else { // Heuristik: match gegen groupItemTitle (Teamname) // Prüfe ob der Titel den Home-Code oder Away-Code enthält // Besser: erster Markt = Home, dritter = Away; Draw in der Mitte. // Da die Reihenfolge nicht garantiert ist: über slug-Suffix matchen. if (mk.slug.endsWith("-" + slugParts[0].toLowerCase())) { pHome = yes; homeName = mk.groupItemTitle; } else if (mk.slug.endsWith("-" + slugParts[1].toLowerCase())) { pAway = yes; awayName = mk.groupItemTitle; } } } if (pHome > 0 || pDraw > 0 || pAway > 0) { const startTime = marketGameStartTime ?? (ev as any).gameStartTime ?? (ev as any).endDate ?? (ev as any).startDate; parsed.push({ slug: ev.slug, homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway, startTime }); } } console.log("[polymarket] spiele:", parsed.length); return parsed; }); } // Normalisiert Teamnamen für Matching (lowercase, diakritische Zeichen, aliase). function normName(n: string): string { return n .toLowerCase() .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // diakritische Zeichen entfernen .replace(/[^a-z0-9 ]/g, "") .replace(/\s+/g, " ") .trim(); } // Verknüpft Polymarket-Odds mit Feed-Matches über Teamnamen (primär) oder Codes. export function attachOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] { // Map: Team-ID → normierter Name const nameById = new Map(teams.map((t) => [t.id, normName(t.name)])); // Map: normierter Name → Team-ID (für Lookup) const idByName = new Map(); for (const t of teams) { const nn = normName(t.name); if (!idByName.has(nn)) idByName.set(nn, t.id); } // Map: normierter Code → Team-ID const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id])); // Map: Polymarket ParsedOdds → { homeTeamId, awayTeamId } const oddsMatch = new Map(); for (const o of odds) { const hn = normName(o.homeName); const an = normName(o.awayName); const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode); const awayId = idByName.get(an) ?? idByCode.get(o.awayCode); if (homeId && awayId) { oddsMatch.set(o, { homeId, awayId }); } else { oddsMatch.set(o, null); } } const attachedOdds = new Set(); const result = matches.map((m) => { if (!m.homeTeamId || !m.awayTeamId) return m; // Suche in oddsMatch nach einer Kombination die beide Team-IDs matcht for (const [o, ids] of oddsMatch) { if (!ids) continue; // Beide Richtungen prüfen if ((ids.homeId === m.homeTeamId && ids.awayId === m.awayTeamId) || (ids.homeId === m.awayTeamId && ids.awayId === m.homeTeamId)) { const swapped = ids.homeId === m.awayTeamId; attachedOdds.add(o); return { ...m, prob: { home: swapped ? o.pAway : o.pHome, draw: o.pDraw, away: swapped ? o.pHome : o.pAway, }, }; } } return m; }); return result; } // ---------------------------------------------------------------------------- // KO-Odds-Fallback: löst R32-Paarungen auf und verknüpft Polymarket-Events // mit K.o.-Feed-Matches, die noch keine Team-IDs im Feed haben. // ---------------------------------------------------------------------------- // Löst für jeden R32-Slot (73-88) die aktuellen Team-Paarungen auf Basis der // Gruppentabellen und Annex-C-Zuordnung. Gibt eine Map matchNumber → Paarung // zurück, NUR wenn beide Team-IDs eindeutig bekannt sind (kein Raten). function resolveR32Pairings(matches: Match[], teams: Team[]): Map { const tables = computeGroupTables(teams, matches); const thirds = computeThirdPlaceTable(tables); const qGroups = qualifiedThirdGroups(thirds); const annex = qGroups.length === 8 ? resolveAnnexC(qGroups) : null; const pairings = new Map(); for (const slot of R32) { const homeGroup = slot.home.type === "W" ? slot.home.group : undefined; const awayGroup = slot.away.type === "W" ? slot.away.group : undefined; const wg = (homeGroup ?? awayGroup) as GroupId | undefined; let hid: string | null = null; if (slot.home.type === "W" && slot.home.group) { hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 1)?.teamId ?? null; } else if (slot.home.type === "R" && slot.home.group) { hid = tables.find(t => t.group === slot.home.group)?.rows.find(r => r.rank === 2)?.teamId ?? null; } else if (slot.home.type === "3" && annex && wg) { const tg = annex[wg]; if (tg) hid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null; } let aid: string | null = null; if (slot.away.type === "W" && slot.away.group) { aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 1)?.teamId ?? null; } else if (slot.away.type === "R" && slot.away.group) { aid = tables.find(t => t.group === slot.away.group)?.rows.find(r => r.rank === 2)?.teamId ?? null; } else if (slot.away.type === "3" && annex && wg) { const tg = annex[wg]; if (tg) aid = thirds.find(r => r.group === tg && r.qualifies)?.teamId ?? null; } if (hid && aid) { pairings.set(slot.matchNumber, { homeTeamId: hid, awayTeamId: aid }); } } return pairings; } // Extrahiert das Datum (YYYY-MM-DD) aus einem Polymarket-Slug. // Slug-Format: fifwc-{home}-{away}-{yyyy}-{mm}-{dd} function slugToDate(slug: string): string | null { const parts = slug.split("-"); if (parts.length < 3) return null; const y = parts[parts.length - 3]; const m = parts[parts.length - 2]; const d = parts[parts.length - 1]; if (/^\d{4}$/.test(y) && /^\d{2}$/.test(m) && /^\d{2}$/.test(d)) { return `${y}-${m}-${d}`; } return null; } // Zweiter Durchlauf für attachOdds: ordnet Polymarket-Events K.o.-Feed-Matches // zu, die (noch) keine Team-IDs im Feed haben. Arbeitet direkt auf den // aufgelösten R32-Paarungen (resolveR32Pairings). Identifiziert das Ziel- // Feed-Match über die Slot-matchNumber (Stufe 1) oder Datums-Lookup aus dem // Slug (Stufe 2) — KEINE chronologische Nummernvergabe. // Mutiert matches in-place, setzt nur bei Matches OHNE bestehende prob. export function attachKOOdds(matches: Match[], teams: Team[], odds: ParsedOdds[]): Match[] { const pairings = resolveR32Pairings(matches, teams); if (pairings.size === 0) return matches; const idByName = new Map(); for (const t of teams) { const nn = normName(t.name); if (!idByName.has(nn)) idByName.set(nn, t.id); } const idByCode = new Map(teams.map((t) => [t.code.toLowerCase(), t.id])); for (const [matchNum, pairing] of pairings) { if (!pairing.homeTeamId || !pairing.awayTeamId) continue; // Finde Polymarket-Event für dieses Team-Paar let matchedEvent: ParsedOdds | null = null; for (const o of odds) { const hn = normName(o.homeName); const an = normName(o.awayName); const homeId = idByName.get(hn) ?? idByCode.get(o.homeCode); const awayId = idByName.get(an) ?? idByCode.get(o.awayCode); if (!homeId || !awayId) continue; if ((homeId === pairing.homeTeamId && awayId === pairing.awayTeamId) || (homeId === pairing.awayTeamId && awayId === pairing.homeTeamId)) { matchedEvent = o; break; } } if (!matchedEvent) continue; // Ziel-Feed-Match identifizieren let targetMatch: Match | undefined; // Stufe 1: bereits nummeriertes R32-Match (via assignKONumbersBySlots Pass 1) targetMatch = matches.find(m => m.stage === "R32" && m.group == null && m.matchNumber === matchNum, ); // Stufe 2: Datums-Lookup aus Polymarket-Slug für unnummerierte Matches if (!targetMatch) { const slugDate = slugToDate(matchedEvent.slug); if (slugDate) { const candidates = matches.filter(m => m.stage === "R32" && m.group == null && m.matchNumber === 0 && m.utcDate.slice(0, 10) === slugDate, ); if (candidates.length === 1) { targetMatch = candidates[0]; targetMatch.matchNumber = matchNum; } else if (candidates.length > 1) { // Tie-breaker: falls Polymarket-Event eine Startzeit hat, nächstgelegenes Feed-Match if (matchedEvent.startTime) { const eventTime = new Date(matchedEvent.startTime).getTime(); let best: Match | undefined; let bestDiff = Infinity; for (const c of candidates) { const diff = Math.abs(new Date(c.utcDate).getTime() - eventTime); if (diff < bestDiff) { bestDiff = diff; best = c; } } // Nur zuordnen, wenn Zeitdifferenz plausibel (<4h) if (best && bestDiff < 4 * 60 * 60 * 1000) { targetMatch = best; targetMatch.matchNumber = matchNum; } } } } } if (!targetMatch) continue; // prob nicht überschreiben, falls schon gesetzt if (targetMatch.prob) continue; const homeIdFromEvent = idByName.get(normName(matchedEvent.homeName)) ?? idByCode.get(matchedEvent.homeCode); const swapped = homeIdFromEvent === pairing.awayTeamId; targetMatch.prob = { home: swapped ? matchedEvent.pAway : matchedEvent.pHome, draw: matchedEvent.pDraw, away: swapped ? matchedEvent.pHome : matchedEvent.pAway, }; } return matches; } // ---------------------------------------------------------------------------- // FIFA-API: Live-Scores, Elfmeterschießen, Spielminute // ---------------------------------------------------------------------------- const FIFA_BASE = "https://api.fifa.com/api/v3"; const FIFA_SEASON = "285023"; // FIFA-Code → App-Code (nur Abweichungen; sonst identisch) const FIFA_CODE_OVERRIDE: Record = { CRO: "HRV", // Kroatien POR: "PRT", // Portugal SUI: "CHE", // Schweiz }; function fifaCodeToAppCode(fifaCode: string): string { return FIFA_CODE_OVERRIDE[fifaCode] ?? fifaCode; } interface FifaTeamBlock { IdTeam: string; // FIFA-Team-ID Abbreviation: string; // FIFA-Code (z.B. PAR, CRO) } interface FifaMatch { MatchNumber: number; Home: FifaTeamBlock | null; Away: FifaTeamBlock | null; HomeTeamScore: number | null; AwayTeamScore: number | null; HomeTeamPenaltyScore: number | null; AwayTeamPenaltyScore: number | null; MatchStatus: number; // 0=finished, 1=scheduled, else=live MatchTime: string | null; // z.B. "132'" ResultType: number | null; // 1=regular, 2=penalties Winner?: string | null; // Team-ID des Siegers } interface FifaScores { homeScore: number | null; awayScore: number | null; homePenalty: number | null; awayPenalty: number | null; resultType: number | null; status: MatchStatus; matchTime: string | null; winnerTeamId: string | null; } // Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück. export async function fetchFifaScores(): Promise<{ scores: Map; fifaIdToAppCode: Map; }> { return cached("fifa:scores", 45_000, async () => { const url = `${FIFA_BASE}/calendar/matches?language=de&count=500&idSeason=${FIFA_SEASON}`; const res = await fetch(url, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, signal: AbortSignal.timeout(5000), }); if (!res.ok) throw new Error(`fifa ${res.status}`); const data = (await res.json()) as { Results: FifaMatch[] }; const scores = new Map(); const fifaIdToAppCode = new Map(); for (const fm of data.Results ?? []) { // Team-Mapping sammeln for (const tb of [fm.Home, fm.Away]) { if (tb && !fifaIdToAppCode.has(tb.IdTeam)) { fifaIdToAppCode.set(tb.IdTeam, fifaCodeToAppCode(tb.Abbreviation)); } } let status: MatchStatus; switch (fm.MatchStatus) { case 0: status = "FINISHED"; break; case 1: status = "SCHEDULED"; break; default: status = "LIVE"; break; } scores.set(fm.MatchNumber, { homeScore: fm.HomeTeamScore, awayScore: fm.AwayTeamScore, homePenalty: fm.HomeTeamPenaltyScore, awayPenalty: fm.AwayTeamPenaltyScore, resultType: fm.ResultType, status, matchTime: fm.MatchTime, winnerTeamId: fm.Winner ?? null, }); } return { scores, fifaIdToAppCode }; }); } // Wendet FIFA-Live-Scores auf Matches an (MatchNumber als exakter Schlüssel). export function applyFifaScores( matches: Match[], teams: Team[], fifaData: { scores: Map; fifaIdToAppCode: Map }, ): Match[] { const { scores: fifaMap, fifaIdToAppCode } = fifaData; if (fifaMap.size === 0) return matches; // Baue App-Code → App-Team-ID Map const appCodeToId = new Map(); for (const t of teams) { if (t.code) appCodeToId.set(t.code.toLowerCase(), t.id); } let applied = 0; const result = matches.map((m) => { const fs = fifaMap.get(m.matchNumber); if (!fs) return m; const r = { ...m }; if (fs.homeScore != null) r.homeScore = fs.homeScore; if (fs.awayScore != null) r.awayScore = fs.awayScore; if (fs.homePenalty != null) r.homePenalty = fs.homePenalty; if (fs.awayPenalty != null) r.awayPenalty = fs.awayPenalty; // FIFA-ID → App-Team-ID auflösen if (fs.winnerTeamId) { const appCode = fifaIdToAppCode.get(fs.winnerTeamId); if (appCode) { const appTeamId = appCodeToId.get(appCode.toLowerCase()); if (appTeamId) { r.winnerTeamId = appTeamId; } else { console.warn("[fifa] Winner-Team-Code nicht in App-Teams:", appCode, "| matchNumber:", m.matchNumber); } } else { console.warn("[fifa] FIFA-Winner-ID nicht in Team-Map:", fs.winnerTeamId, "| matchNumber:", m.matchNumber); } } console.log("[FIFA-WINNER]", "matchNumber:", m.matchNumber, "| fifa.Winner:", fs.winnerTeamId, "| status:", r.status, "| homeScore:", r.homeScore, "| awayScore:", r.awayScore, "| homePenalty:", r.homePenalty, "| awayPenalty:", r.awayPenalty, "| -> m.winnerTeamId gesetzt:", r.winnerTeamId); if (fs.matchTime) { const min = parseInt(fs.matchTime, 10); if (!isNaN(min)) r.minute = min; } r.status = fs.status; applied++; return r; }); if (applied > 0) console.log("[fifa] scores angewandt:", applied); return result; }