889 lines
33 KiB
TypeScript
889 lines
33 KiB
TypeScript
import { GroupId, GoalEvent, Match, MatchStatus, Team } from "./types";
|
||
import { venueFor } from "./venues";
|
||
import { localisedTeamName, 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: einfacher In-Memory-Cache mit TTL. Verhindert, dass jede Browser-
|
||
// Anfrage einen Upstream-Call auslöst (football-data: 10 req/min Limit).
|
||
// ----------------------------------------------------------------------------
|
||
interface CacheEntry<T> { value: T; expires: number; }
|
||
const cache = new Map<string, CacheEntry<unknown>>();
|
||
|
||
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();
|
||
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<string, string> {
|
||
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<Match["stage"], number> = {
|
||
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<string, Match>();
|
||
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<string, Team>();
|
||
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 ?? ""),
|
||
localisedNames: {
|
||
de: localisedTeamName(side.tla ?? "", side.name ?? "", "de"),
|
||
en: localisedTeamName(side.tla ?? "", side.name ?? "", "en"),
|
||
},
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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<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: Spiele + Teams parallel zur football-data-Quelle
|
||
// ----------------------------------------------------------------------------
|
||
|
||
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[] };
|
||
|
||
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()];
|
||
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[] };
|
||
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,
|
||
});
|
||
}
|
||
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();
|
||
}
|
||
|
||
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 beendete/laufende Spiele mit Toren und hängt sie an die Matches an.
|
||
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 === "FINISHED" || m.status === "LIVE" || m.status === "IN_PLAY") &&
|
||
((m.homeScore ?? 0) + (m.awayScore ?? 0)) > 0,
|
||
);
|
||
const goalsByMatchId = new Map<string, GoalEvent[]>();
|
||
await Promise.all(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);
|
||
}));
|
||
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;
|
||
});
|
||
} |