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 { homeCode: string; awayCode: string; homeName: string; awayName: string; pHome: number; pDraw: number; pAway: number; } // 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", 120_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 = ""; for (const mk of ev.markets ?? []) { if (mk.sportsMarketType && mk.sportsMarketType !== "moneyline") continue; if (mk.closed) continue; 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) { parsed.push({ homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway }); } } 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); } } return 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; return { ...m, prob: { home: swapped ? o.pAway : o.pHome, draw: o.pDraw, away: swapped ? o.pHome : o.pAway, }, }; } } return m; }); } // ---------------------------------------------------------------------------- // worldcup26.ir: schnelle Live-Scores (additiv, Fallback auf football-data) // ---------------------------------------------------------------------------- const WC26_BASE = "https://worldcup26.ir"; interface Wc26Game { home_team_name_en?: string; away_team_name_en?: string; group?: string; matchday?: string; home_score?: string; away_score?: string; time_elapsed?: string; } interface LiveScore { homeName: string; awayName: string; group: string; matchday: string; homeScore: number | null; awayScore: number | null; status: string; // "IN_PLAY" | "FINISHED" } // Ruft alle Spiele von worldcup26.ir ab. export async function fetchLiveScores(): Promise { const res = await fetch(`${WC26_BASE}/get/games`, { cache: "no-store", headers: { "User-Agent": "wm2026-board/1.0" }, signal: AbortSignal.timeout(8000), }); if (!res.ok) throw new Error(`worldcup26 ${res.status}`); const data = (await res.json()) as { games: Wc26Game[] }; const games = data.games ?? []; const scores: LiveScore[] = []; for (const g of games) { if (!g.home_team_name_en || !g.away_team_name_en) continue; const hScore = parseScore(g.home_score); const aScore = parseScore(g.away_score); let status = ""; switch (g.time_elapsed) { case "live": status = "IN_PLAY"; break; case "finished": status = "FINISHED"; break; default: continue; // notstarted → überspringen } // Nur anwenden, wenn mindestens ein Score vorhanden ist if (hScore == null && aScore == null) continue; scores.push({ homeName: g.home_team_name_en, awayName: g.away_team_name_en, group: g.group ?? "", matchday: String(g.matchday ?? ""), homeScore: hScore, awayScore: aScore, status, }); } console.log("[worldcup26] spiele:", scores.length); return scores; } function parseScore(s: string | undefined): number | null { if (!s || s === "null") return null; const n = Number(s); return Number.isFinite(n) ? n : null; } // Wendet worldcup26-Live-Scores auf football-data-Matches an. // Matching über normalisierte Teamnamen + group + matchday. export function applyLiveScores( matches: Match[], teams: Team[], liveScores: LiveScore[], ): Match[] { // Build lookup: normName(home)::normName(away)::group::matchday → match // Für Group-Phase ist die Paarung eindeutig (jedes Paar spielt 1x). const byPair = new Map(); for (const m of matches) { if (m.group == null) continue; // nur Gruppenphase if (!m.homeTeamId || !m.awayTeamId) continue; const ht = teams.find((t) => t.id === m.homeTeamId); const at = teams.find((t) => t.id === m.awayTeamId); if (!ht || !at) continue; const hn = normName(ht.name); const an = normName(at.name); // Beide Richtungen byPair.set(`${hn}::${an}::${m.group}`, m); byPair.set(`${an}::${hn}::${m.group}`, m); } const result = [...matches]; let applied = 0; for (const ls of liveScores) { const hn = normName(ls.homeName); const an = normName(ls.awayName); const key = `${hn}::${an}::${ls.group}`; const fdMatch = byPair.get(key); if (!fdMatch) continue; const idx = result.findIndex((m) => m.id === fdMatch.id); if (idx < 0) continue; result[idx] = { ...result[idx], homeScore: ls.homeScore ?? result[idx].homeScore, awayScore: ls.awayScore ?? result[idx].awayScore, status: ls.status as Match["status"], }; applied++; } if (applied > 0) console.log("[worldcup26] auf matches angewandt:", applied); return result; }