830 lines
31 KiB
TypeScript
830 lines
31 KiB
TypeScript
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
|
|
import { TEAM_LOCALIZATION } from "./team-mappings";
|
|
import { R32, LATER_ROUNDS, resolveAnnexC, qualifiedThirdGroups } from "./bracket";
|
|
import { computeGroupTables, computeThirdPlaceTable } from "./standings";
|
|
import { FIFA_GROUP_MAP, FIFA_STAGE_MAP } from "./fifa-constants";
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Caching: In-Memory-Cache mit TTL + stale-while-revalidate.
|
|
// Bei abgelaufenem Cache wird der alte Wert sofort zurückgegeben und die
|
|
// Erneuerung im Hintergrund angestoßen (keine Wartezeit für den Nutzer).
|
|
// ----------------------------------------------------------------------------
|
|
interface CacheEntry<T> { value: T; expires: number; refreshing?: boolean; }
|
|
const cache = new Map<string, CacheEntry<unknown>>();
|
|
|
|
// Subscription-System: Benachrichtigt Listener (z.B. API-Routen) bei
|
|
// erfolgreichem Hintergrund-Refresh, sodass das Frontend nach dem
|
|
// nächsten Poll die aktuellen Daten erhält.
|
|
type CacheListener = (value: unknown) => void;
|
|
const cacheSubscriptions = new Map<string, Set<CacheListener>>();
|
|
|
|
function notifyCacheListeners(key: string, value: unknown): void {
|
|
const subs = cacheSubscriptions.get(key);
|
|
if (!subs) return;
|
|
for (const cb of subs) {
|
|
try { cb(value); } catch { /* silent */ }
|
|
}
|
|
}
|
|
|
|
export function onCacheRefresh<T>(key: string, cb: (value: T) => void): () => void {
|
|
if (!cacheSubscriptions.has(key)) cacheSubscriptions.set(key, new Set());
|
|
const set = cacheSubscriptions.get(key)!;
|
|
const wrapped = cb as CacheListener;
|
|
set.add(wrapped);
|
|
return () => { set.delete(wrapped); if (set.size === 0) cacheSubscriptions.delete(key); };
|
|
}
|
|
|
|
export async function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
|
|
const hit = cache.get(key) as CacheEntry<T> | undefined;
|
|
const now = Date.now();
|
|
|
|
// Frischer Cache → direkt zurück
|
|
if (hit && hit.expires > now) return hit.value;
|
|
|
|
// Abgelaufen, aber vorhanden → sofort alten Wert liefern, im Hintergrund erneuern
|
|
if (hit) {
|
|
if (!hit.refreshing) {
|
|
hit.refreshing = true;
|
|
fn()
|
|
.then((value) => {
|
|
cache.set(key, { value, expires: Date.now() + ttlMs });
|
|
notifyCacheListeners(key, value);
|
|
})
|
|
.catch((err) => { console.error(`[cache] Hintergrund-Refresh fehlgeschlagen (${key}):`, err); })
|
|
.finally(() => { const e = cache.get(key) as CacheEntry<T> | undefined; if (e) e.refreshing = false; });
|
|
}
|
|
return hit.value;
|
|
}
|
|
|
|
// Kaltstart: kein Cache → synchron laden
|
|
const value = await fn();
|
|
cache.set(key, { value, expires: now + ttlMs });
|
|
return value;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// 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<string, string> = {
|
|
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<T>(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<ParsedOdds[]> {
|
|
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<string[]>(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<string, string>();
|
|
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<ParsedOdds, { homeId: string; awayId: string } | 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) {
|
|
oddsMatch.set(o, { homeId, awayId });
|
|
} else {
|
|
oddsMatch.set(o, null);
|
|
}
|
|
}
|
|
|
|
const attachedOdds = new Set<ParsedOdds>();
|
|
|
|
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<number, { homeTeamId: string; awayTeamId: string }> {
|
|
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<number, { homeTeamId: string; awayTeamId: string }>();
|
|
|
|
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<string, string>();
|
|
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<string, string> = {
|
|
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;
|
|
IdMatch?: string | null;
|
|
IdStage?: string | null;
|
|
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;
|
|
idMatch: string | null;
|
|
idStage: string | null;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// FIFA-API Calendar-Endpoint: Primärquelle für Spiele + Teams
|
|
// ----------------------------------------------------------------------------
|
|
|
|
interface FifaCalendarTeamBlock {
|
|
IdTeam: string;
|
|
Abbreviation: string;
|
|
TeamName: Array<{ Locale: string; Description: string }>;
|
|
}
|
|
|
|
interface FifaCalendarMatch {
|
|
IdMatch: string;
|
|
IdStage: string;
|
|
IdGroup: string | null;
|
|
MatchNumber: number;
|
|
Date: string;
|
|
Home: FifaCalendarTeamBlock | null;
|
|
Away: FifaCalendarTeamBlock | null;
|
|
HomeTeamScore: number | null;
|
|
AwayTeamScore: number | null;
|
|
HomeTeamPenaltyScore: number | null;
|
|
AwayTeamPenaltyScore: number | null;
|
|
MatchStatus: number;
|
|
MatchTime: string | null;
|
|
ResultType: number | null;
|
|
Winner?: string | null;
|
|
Attendance: string | null;
|
|
Stadium?: {
|
|
Name: Array<{ Locale: string; Description: string }>;
|
|
CityName: Array<{ Locale: string; Description: string }>;
|
|
} | null;
|
|
}
|
|
|
|
// Holt alle Spiele + Teams von der FIFA-API (parallele Quelle, noch nicht aktiv).
|
|
export async function fetchMatchesAndTeamsFifa(locale: string = "de"): Promise<{ matches: Match[]; teams: Team[] }> {
|
|
return cached(`fifa:matches:${locale}`, 60_000, async () => {
|
|
const lang = locale === "en" ? "en" : "de";
|
|
const url = `${FIFA_BASE}/calendar/matches?language=${lang}&count=500&idSeason=${FIFA_SEASON}`;
|
|
const res = await fetch(url, {
|
|
cache: "no-store",
|
|
headers: { "User-Agent": "wm2026-board/1.0" },
|
|
});
|
|
if (!res.ok) throw new Error(`fifa-calendar ${res.status}`);
|
|
const data = (await res.json()) as { Results: FifaCalendarMatch[] };
|
|
console.log("[fifa-fetch] Kalender geladen, Result-Array Länge:", (data.Results ?? []).length);
|
|
|
|
const teamMap = new Map<string, Team>();
|
|
|
|
function teamFromBlock(tb: FifaCalendarTeamBlock, idGroup: string | null): Team | null {
|
|
const id = tb.IdTeam;
|
|
if (teamMap.has(id)) return teamMap.get(id)!;
|
|
const code = fifaCodeToAppCode(tb.Abbreviation);
|
|
const group = idGroup ? FIFA_GROUP_MAP[idGroup] : null;
|
|
if (!group) return null;
|
|
const loc = TEAM_LOCALIZATION[code.toUpperCase()];
|
|
const name = tb.TeamName?.[0]?.Description ?? "";
|
|
const t: Team = {
|
|
id,
|
|
name,
|
|
code,
|
|
group,
|
|
localisedName: loc ? loc[lang === "en" ? "en" : "de"] : name,
|
|
localisedNames: loc ? { de: loc.de, en: loc.en } : { de: name, en: name },
|
|
};
|
|
teamMap.set(id, t);
|
|
return t;
|
|
}
|
|
|
|
function mapFifaStatus(ms: number): MatchStatus {
|
|
switch (ms) {
|
|
case 0: return "FINISHED";
|
|
case 1: return "SCHEDULED";
|
|
case 10: return "POSTPONED";
|
|
default: return "LIVE";
|
|
}
|
|
}
|
|
|
|
const matches: Match[] = (data.Results ?? []).map((fm) => {
|
|
const group = fm.IdGroup ? FIFA_GROUP_MAP[fm.IdGroup] ?? null : null;
|
|
let homeTeam: Team | null = null;
|
|
let awayTeam: Team | null = null;
|
|
if (fm.Home) homeTeam = teamFromBlock(fm.Home, fm.IdGroup);
|
|
if (fm.Away) awayTeam = teamFromBlock(fm.Away, fm.IdGroup);
|
|
const attendance = fm.Attendance ? parseInt(fm.Attendance, 10) || null : null;
|
|
const minute = fm.MatchTime ? parseInt(fm.MatchTime, 10) || null : null;
|
|
const pref = lang === "en" ? "en-GB" : "de-DE";
|
|
const stadiumName = fm.Stadium?.Name?.find(n => n.Locale === pref)?.Description
|
|
?? fm.Stadium?.Name?.[0]?.Description ?? null;
|
|
const stadiumCity = fm.Stadium?.CityName?.find(n => n.Locale === pref)?.Description
|
|
?? fm.Stadium?.CityName?.[0]?.Description ?? null;
|
|
return {
|
|
id: fm.IdMatch,
|
|
group,
|
|
stage: FIFA_STAGE_MAP[fm.IdStage] ?? "GROUP",
|
|
matchNumber: fm.MatchNumber,
|
|
utcDate: fm.Date,
|
|
status: mapFifaStatus(fm.MatchStatus),
|
|
minute,
|
|
homeTeamId: fm.Home?.IdTeam ?? null,
|
|
awayTeamId: fm.Away?.IdTeam ?? null,
|
|
homeScore: fm.HomeTeamScore,
|
|
awayScore: fm.AwayTeamScore,
|
|
homePenalty: fm.HomeTeamPenaltyScore,
|
|
awayPenalty: fm.AwayTeamPenaltyScore,
|
|
winnerTeamId: fm.Winner ?? null,
|
|
venue: null,
|
|
stadiumName,
|
|
stadiumCity,
|
|
attendance,
|
|
};
|
|
});
|
|
|
|
const teams = [...teamMap.values()];
|
|
console.log("[fifa-fetch] Teams:", teams.length, "Matches:", matches.length);
|
|
return { matches, teams };
|
|
});
|
|
}
|
|
|
|
// Holt Live-Scores von der FIFA-API. Gibt Map MatchNumber → Scores + FIFA-ID→App-Code-Map zurück.
|
|
export async function fetchFifaScores(locale: string = "de"): Promise<{
|
|
scores: Map<number, FifaScores>;
|
|
fifaIdToAppCode: Map<string, string>;
|
|
}> {
|
|
return cached(`fifa:scores:${locale}`, 45_000, async () => {
|
|
const lang = locale === "en" ? "en" : "de";
|
|
const url = `${FIFA_BASE}/calendar/matches?language=${lang}&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[] };
|
|
console.log("[fifa-fetch] Scores geladen, Result-Array Länge:", (data.Results ?? []).length);
|
|
const scores = new Map<number, FifaScores>();
|
|
const fifaIdToAppCode = new Map<string, string>();
|
|
|
|
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;
|
|
case 10: status = "POSTPONED"; 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,
|
|
idMatch: fm.IdMatch ?? null,
|
|
idStage: fm.IdStage ?? null,
|
|
});
|
|
}
|
|
console.log("[fifa-fetch] Score-Einträge:", scores.size, "Team-Mappings:", fifaIdToAppCode.size);
|
|
return { scores, fifaIdToAppCode };
|
|
});
|
|
}
|
|
|
|
// Wendet FIFA-Live-Scores auf Matches an (MatchNumber als exakter Schlüssel).
|
|
// Team-IDs sind jetzt FIFA-IDs → winnerTeamId kann direkt gesetzt werden.
|
|
export function applyFifaScores(
|
|
matches: Match[], teams: Team[],
|
|
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
|
): Match[] {
|
|
const { scores: fifaMap } = fifaData;
|
|
if (fifaMap.size === 0) return matches;
|
|
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;
|
|
if (fs.winnerTeamId) r.winnerTeamId = fs.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;
|
|
}
|
|
|
|
// Holt Tor-Details pro Spiel vom FIFA-Detail-Endpoint.
|
|
interface FifaGoalRaw { scorer: string; minute: string; team: "home" | "away"; type: number | null; }
|
|
|
|
function locName(arr: Array<{ Locale: string; Description: string }> | undefined): string {
|
|
if (!arr || arr.length === 0) return "";
|
|
return (arr.find(x => x.Locale === "de-DE")
|
|
?? arr.find(x => x.Locale === "en-GB")
|
|
?? arr[0])?.Description ?? "";
|
|
}
|
|
|
|
function normMinuteStr(min: string | null | undefined): string {
|
|
return (min ?? "").replace(/'/g, "").replace(/\s+/g, "").trim();
|
|
}
|
|
|
|
const RECENT_FINISHED_MS = 60 * 60 * 1000; // 60 Minuten
|
|
const MATCH_DURATION_ESTIMATE = 2.5 * 60 * 60 * 1000; // 2.5h Puffer für Spiel + Pause + Nachspielzeit
|
|
|
|
function isRecentlyFinished(m: Match): boolean {
|
|
if (m.status !== "FINISHED") return false;
|
|
const kickoff = new Date(m.utcDate).getTime();
|
|
if (isNaN(kickoff)) return false;
|
|
return kickoff + MATCH_DURATION_ESTIMATE > Date.now() - RECENT_FINISHED_MS;
|
|
}
|
|
|
|
async function fetchFifaGoals(idStage: string, idMatch: string, locale: string = "de"): Promise<FifaGoalRaw[]> {
|
|
return cached(`fifa:detail:${locale}:${idMatch}`, 60_000, async () => {
|
|
const lang = locale === "en" ? "en" : "de";
|
|
const url = `${FIFA_BASE}/live/football/17/${FIFA_SEASON}/${idStage}/${idMatch}?language=${lang}`;
|
|
const res = await fetch(url, {
|
|
cache: "no-store",
|
|
headers: { "User-Agent": "wm2026-board/1.0" },
|
|
signal: AbortSignal.timeout(5000),
|
|
});
|
|
if (!res.ok) { console.warn("[fifa-detail] status", res.status, idMatch); return []; }
|
|
const dj: any = await res.json();
|
|
|
|
const buildPlayerMap = (teamBlock: any): Map<string, string> => {
|
|
const map = new Map<string, string>();
|
|
for (const p of teamBlock?.Players ?? []) {
|
|
map.set(String(p.IdPlayer), locName(p.PlayerName) || locName(p.ShortName));
|
|
}
|
|
return map;
|
|
};
|
|
const homeBlock = dj.HomeTeam, awayBlock = dj.AwayTeam;
|
|
const homePlayers = buildPlayerMap(homeBlock);
|
|
const awayPlayers = buildPlayerMap(awayBlock);
|
|
|
|
const goals: FifaGoalRaw[] = [];
|
|
for (const [block, players, side] of [
|
|
[homeBlock, homePlayers, "home"] as const,
|
|
[awayBlock, awayPlayers, "away"] as const,
|
|
]) {
|
|
for (const g of block?.Goals ?? []) {
|
|
const min = normMinuteStr(g.Minute);
|
|
if (min === "" || isNaN(parseInt(min, 10))) continue;
|
|
goals.push({
|
|
scorer: players.get(String(g.IdPlayer)) || "?",
|
|
minute: normMinuteStr(g.Minute),
|
|
team: side,
|
|
type: g.Type ?? null,
|
|
});
|
|
}
|
|
}
|
|
goals.sort((a, b) => parseInt(a.minute, 10) - parseInt(b.minute, 10));
|
|
return goals;
|
|
});
|
|
}
|
|
|
|
// Lädt Tor-Details für laufende oder kürzlich beendete Spiele mit Toren.
|
|
// FINISHED-Matches werden nur innerhalb eines 60-Minuten-Fensters berücksichtigt,
|
|
// um die Request-Zahl bei 79+ Spielen im Turnierverlauf drastisch zu reduzieren.
|
|
export async function attachFifaGoals(
|
|
matches: Match[],
|
|
fifaData: { scores: Map<number, FifaScores>; fifaIdToAppCode: Map<string, string> },
|
|
locale: string = "de",
|
|
): Promise<Match[]> {
|
|
const targets = matches.filter(m =>
|
|
((m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED") || isRecentlyFinished(m)) &&
|
|
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
|
|
);
|
|
if (targets.length === 0) return matches;
|
|
|
|
const goalsByMatchId = new Map<string, GoalEvent[]>();
|
|
const results = await Promise.allSettled(targets.map(async (m) => {
|
|
const fs = fifaData.scores.get(m.matchNumber);
|
|
if (!fs?.idMatch || !fs?.idStage) return;
|
|
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
|
|
if (goals.length) goalsByMatchId.set(m.id, goals);
|
|
}));
|
|
|
|
const fetched = results.filter(r => r.status === "fulfilled").length;
|
|
const failed = results.filter(r => r.status === "rejected").length;
|
|
if (goalsByMatchId.size > 0 || failed > 0) {
|
|
console.log("[fifa-fetch] Goals: geladen für", goalsByMatchId.size, "Matches,", fetched, "OK,", failed, "Fehler");
|
|
}
|
|
if (goalsByMatchId.size === 0) return matches;
|
|
|
|
return matches.map(m => {
|
|
const g = goalsByMatchId.get(m.id);
|
|
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// FIFA Live-Daten Pipeline: kombiniert Scores + Goals in gestufter Abfolge.
|
|
// Step 1: Scores von der FIFA-API holen (enthält idMatch/idStage-Mappings).
|
|
// Step 2: Scores auf die Matches anwenden (MatchNumber als Schlüssel).
|
|
// Step 3: Goal-Details NUR für live/beendete Matches mit Toren nachladen.
|
|
//
|
|
// Die Schritte werden sequentiell ausgeführt (kein Promise.all wie zuvor),
|
|
// damit fehlende ID-Mappings nicht zu sinnlosen Requests führen.
|
|
// Jeder Schritt loggt die Array-/Map-Länge, sodass auf der Vercel-Konsole
|
|
// sofort ersichtlich ist, ob der Upstream leer ist oder der Fehler im Mapping liegt.
|
|
// ----------------------------------------------------------------------------
|
|
export async function attachFifaLiveData(
|
|
matches: Match[],
|
|
teams: Team[],
|
|
locale: string = "de",
|
|
): Promise<Match[]> {
|
|
// --- Step 1: Scores von FIFA holen ---
|
|
let fifaData: Awaited<ReturnType<typeof fetchFifaScores>>;
|
|
try {
|
|
fifaData = await fetchFifaScores(locale);
|
|
} catch (err) {
|
|
console.warn("[fifa-fetch] Scores fehlgeschlagen:", err instanceof Error ? err.message : err);
|
|
return matches;
|
|
}
|
|
|
|
// --- Step 2: Scores auf Matches anwenden ---
|
|
const updated = applyFifaScores(matches, teams, fifaData);
|
|
|
|
// --- Step 3: Goal-Details nur für live + kürzlich beendete Matches mit Toren ---
|
|
const targets = updated.filter(m => {
|
|
const fs = fifaData.scores.get(m.matchNumber);
|
|
return (
|
|
((m.status === "LIVE" || m.status === "IN_PLAY" || m.status === "PAUSED") || isRecentlyFinished(m)) &&
|
|
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0 &&
|
|
fs?.idMatch != null &&
|
|
fs?.idStage != null
|
|
);
|
|
});
|
|
|
|
if (targets.length === 0) {
|
|
console.log("[fifa-fetch] Pipeline: Scores aktualisiert, keine Goal-Requests nötig");
|
|
return updated;
|
|
}
|
|
|
|
const goalsByMatchId = new Map<string, GoalEvent[]>();
|
|
let fetched = 0;
|
|
let failed = 0;
|
|
|
|
for (const m of targets) {
|
|
const fs = fifaData.scores.get(m.matchNumber)!;
|
|
if (!fs.idMatch || !fs.idStage) continue;
|
|
try {
|
|
const goals = await fetchFifaGoals(fs.idStage, fs.idMatch, locale);
|
|
fetched++;
|
|
if (goals.length) goalsByMatchId.set(m.id, goals);
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
|
|
console.log("[fifa-fetch] Pipeline: Goals für", goalsByMatchId.size, "Matches geladen (", fetched, "OK,", failed, "Fehler )");
|
|
|
|
if (goalsByMatchId.size === 0) return updated;
|
|
|
|
return updated.map(m => {
|
|
const g = goalsByMatchId.get(m.id);
|
|
return g ? { ...m, goals: g.map(({ scorer, minute, team }) => ({ scorer, minute, team })) } : m;
|
|
});
|
|
} |