781 lines
30 KiB
TypeScript
781 lines
30 KiB
TypeScript
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<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 ?? ""),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
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", 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 = "";
|
||
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 rawStartDate = (ev as any).startDate ?? null;
|
||
const rawEndDate = (ev as any).endDate ?? null;
|
||
const rawGameStartTime = (ev as any).gameStartTime ?? null;
|
||
const startTime = marketGameStartTime ?? rawGameStartTime ?? rawEndDate ?? rawStartDate;
|
||
console.log(" [PM-RAW-ML]", ev.slug,
|
||
"| active:", (ev as any).active, "| closed:", (ev as any).closed,
|
||
"| startDate:", rawStartDate,
|
||
"| gameStartTime:", rawGameStartTime,
|
||
"| mkGameStartTime:", marketGameStartTime,
|
||
"| endDate:", rawEndDate);
|
||
parsed.push({ slug: ev.slug, homeCode, awayCode, homeName, awayName, pHome, pDraw, pAway, startTime });
|
||
}
|
||
}
|
||
|
||
console.log("[PM-RAW]", new Date().toISOString(), "| events gesamt:", allEvents.length);
|
||
console.log("[PM-LOAD]", new Date().toISOString(), "| moneyline-events:", parsed.length, "| events gesamt:", allEvents.length);
|
||
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 feedMatchesWithTheseTeams = matches.filter(m =>
|
||
m.homeTeamId && m.awayTeamId &&
|
||
((m.homeTeamId === homeId && m.awayTeamId === awayId) ||
|
||
(m.homeTeamId === awayId && m.awayTeamId === homeId)),
|
||
);
|
||
console.log("[PM-NOMATCH]", o.homeName, "vs", o.awayName, "| slug:", o.slug);
|
||
console.log("[PM-NOMATCH-WHY]", o.homeName, "vs", o.awayName,
|
||
"| homeId gefunden:", !!homeId,
|
||
"| awayId gefunden:", !!awayId,
|
||
"| homeCode:", o.homeCode, "| awayCode:", o.awayCode,
|
||
"| feed-matches mit diesen teams:", feedMatchesWithTheseTeams.length);
|
||
}
|
||
}
|
||
|
||
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);
|
||
console.log("[PM-ATTACH]", o.homeName, "vs", o.awayName,
|
||
"| match gefunden:", true,
|
||
"| match.status:", m.status,
|
||
"| prob gesetzt:", true);
|
||
return {
|
||
...m,
|
||
prob: {
|
||
home: swapped ? o.pAway : o.pHome,
|
||
draw: o.pDraw,
|
||
away: swapped ? o.pHome : o.pAway,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
return m;
|
||
});
|
||
|
||
for (const [o, ids] of oddsMatch) {
|
||
if (ids && !attachedOdds.has(o)) {
|
||
const matchingFeedMatch = matches.find(m =>
|
||
m.homeTeamId && m.awayTeamId &&
|
||
((m.homeTeamId === ids.homeId && m.awayTeamId === ids.awayId) ||
|
||
(m.homeTeamId === ids.awayId && m.awayTeamId === ids.homeId)),
|
||
);
|
||
console.log("[PM-NOMATCH]", o.homeName, "vs", o.awayName, "| slug:", o.slug);
|
||
console.log("[PM-NOMATCH-WHY]", o.homeName, "vs", o.awayName,
|
||
"| team-ids gematcht:", ids.homeId, "vs", ids.awayId,
|
||
"| feed-match mit diesen teams existiert:", !!matchingFeedMatch,
|
||
"| feed-match status:", matchingFeedMatch?.status ?? "—",
|
||
"| prob bereits gesetzt:", matchingFeedMatch?.prob ? "ja" : "nein");
|
||
}
|
||
}
|
||
|
||
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 });
|
||
}
|
||
}
|
||
|
||
console.log("[R32-PAIRS] aufgelöste Paarungen:", pairings.size);
|
||
for (const [num, p] of pairings) {
|
||
const h = teams.find(t=>t.id===p.homeTeamId)?.name ?? p.homeTeamId;
|
||
const a = teams.find(t=>t.id===p.awayTeamId)?.name ?? p.awayTeamId;
|
||
console.log(" [R32-PAIR]", "matchNumber:", num, "|", h, "vs", a);
|
||
}
|
||
|
||
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 koOhneProb = matches.filter(m => m.group == null && m.prob == null).length;
|
||
console.log("[KO-ODDS] start | odds-events:", odds?.length ?? "undefined",
|
||
"| ko-matches ohne prob:", koOhneProb);
|
||
|
||
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]));
|
||
|
||
console.log("[KO-LOOP] start | pairings:", pairings.size, "| odds-events:", odds?.length ?? "undef");
|
||
|
||
for (const [matchNum, pairing] of pairings) {
|
||
if (!pairing.homeTeamId || !pairing.awayTeamId) continue;
|
||
|
||
const hName = teams.find(t => t.id === pairing.homeTeamId)?.name ?? "?";
|
||
const aName = teams.find(t => t.id === pairing.awayTeamId)?.name ?? "?";
|
||
|
||
// 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) {
|
||
console.log("[KO-TRY]", "matchNumber:", matchNum,
|
||
"| teams:", `${hName} vs ${aName}`,
|
||
"| polymarket-event gefunden:", false,
|
||
"| ziel-feedmatch gefunden:", false,
|
||
"| stufe:", "keine");
|
||
continue;
|
||
}
|
||
|
||
// Ziel-Feed-Match identifizieren
|
||
let targetMatch: Match | undefined;
|
||
let stufe: string | undefined;
|
||
|
||
// Stufe 1: bereits nummeriertes R32-Match (via assignKONumbersBySlots Pass 1)
|
||
targetMatch = matches.find(m =>
|
||
m.stage === "R32" && m.group == null && m.matchNumber === matchNum,
|
||
);
|
||
if (targetMatch) stufe = "1 (nummeriert)";
|
||
|
||
// 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;
|
||
stufe = "2 (datum)";
|
||
} 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;
|
||
stufe = "2 (datum+tie)";
|
||
}
|
||
}
|
||
console.log("[KO-TIME]", "slot:", matchNum,
|
||
"| teams:", `${hName} vs ${aName}`,
|
||
"| pm-gameStartTime(UTC):", matchedEvent.startTime,
|
||
"| feed-kandidaten:", candidates.map(c =>
|
||
`${c.id}(${c.utcDate})`,
|
||
).join(", "),
|
||
"| gewählt:", targetMatch?.id ?? "keins");
|
||
if (!targetMatch) {
|
||
console.log("[KO-TIE]", "matchNumber:", matchNum,
|
||
"| slug:", matchedEvent.slug,
|
||
"| slugDate:", slugDate,
|
||
"| startTime:", matchedEvent.startTime,
|
||
"| candidates:", candidates.map(c =>
|
||
`${c.id}(${c.utcDate})`,
|
||
).join(", "));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!targetMatch) {
|
||
console.log("[KO-TRY]", "matchNumber:", matchNum,
|
||
"| teams:", `${hName} vs ${aName}`,
|
||
"| polymarket-event gefunden:", true,
|
||
"| ziel-feedmatch gefunden:", false,
|
||
"| stufe:", stufe ?? "keine");
|
||
continue;
|
||
}
|
||
|
||
// prob nicht überschreiben, falls schon gesetzt
|
||
if (targetMatch.prob) {
|
||
console.log("[KO-TRY]", "matchNumber:", matchNum,
|
||
"| teams:", `${hName} vs ${aName}`,
|
||
"| polymarket-event gefunden:", true,
|
||
"| ziel-feedmatch gefunden:", true,
|
||
"| stufe:", stufe,
|
||
"| prob bereits gesetzt:", true);
|
||
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,
|
||
};
|
||
|
||
console.log("[PM-ATTACH]", matchedEvent.homeName, "vs", matchedEvent.awayName,
|
||
"| match gefunden via R32:", true,
|
||
"| match.status:", targetMatch.status,
|
||
"| matchNumber:", targetMatch.matchNumber,
|
||
"| prob gesetzt:", true);
|
||
console.log("[KO-TRY]", "matchNumber:", matchNum,
|
||
"| teams:", `${hName} vs ${aName}`,
|
||
"| polymarket-event gefunden:", !!matchedEvent,
|
||
"| ziel-feedmatch gefunden:", !!targetMatch,
|
||
"| stufe:", stufe);
|
||
}
|
||
|
||
return matches;
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// 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<LiveScore[]> {
|
||
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<string, Match>();
|
||
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;
|
||
} |