Files
cup26/server/src/index.ts
T
NilsBriggen b2f680906b Match-rich API: oriented shots/xG, momentum, POTM, officials, weather
GET /api/match-rich/:num projects the captured provider data for the
match center: every shot with pitch coords and xG oriented to
home/away (FotMob payloads now carry team ids, v2 + settle-versioned
refetch), team xG sums, the momentum curve, player of the match with
rating, FIFA's official attendance/stadium/referee, and kickoff-hour
weather. Verified on real data: South Korea 2.31-0.83 Czechia on 22
oriented shots, POTM Hwang 8.9, referee Amin Mohamed Omar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:30:34 +02:00

361 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from 'node:path';
import { existsSync, readFileSync } from 'node:fs';
import Fastify from 'fastify';
import type { FastifyRequest } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startScheduler } from './ingest/scheduler';
import { ModelEngine } from './model';
import { buildPreview, buildTeamProfile } from './preview';
import { buildScoreboard, snapshotCheck } from './scoreboard';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, getMatchProvider, allFixtureResults, saveFixtureResult } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
import type { EspnSummary } from './ingest/espnNormalize';
import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const MAX_WS = Number(process.env.MAX_WS) || 200;
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ trustProxy: true, bodyLimit: 256 * 1024 });
// Tolerate body-less requests that still set content-type: application/json.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const s = (body as string).trim();
if (!s) return done(null, undefined);
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
db(); // open + migrate SQLite before anything reads it
const state = new TournamentState(STATIC_DIR);
// Restore persisted results over the seed — the in-memory snapshot forgets
// them on restart once ESPN's scoreboard window has moved past a match day.
let restored = 0;
for (const r of allFixtureResults()) {
if (r.status === 'scheduled') continue;
if (state.applyScore(r.fixture_num, r.home_score, r.away_score, r.status as MatchStatus, null, 'seed')) restored++;
}
if (restored) console.log(`[state] restored ${restored} fixture result(s) from the DB`);
const model = new ModelEngine(STATIC_DIR);
model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament)
model.recompute(state.allFixtures());
const clients = new Set<WebSocket>();
const snapshotMessage = (): string =>
JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
const predictionsMessage = (): string =>
JSON.stringify({ t: 'predictions', model: model.current() } satisfies ServerMessage);
const sendAll = (msg: string): void => {
for (const ws of clients) {
try { ws.send(msg); } catch { /* closed */ }
}
};
/** Re-push the snapshot, and predictions too if the finished-result set moved. */
const notifier = new PushNotifier();
const broadcast = (): void => {
sendAll(snapshotMessage());
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
try { notifier.tick(state.allFixtures()); } catch { /* never block a broadcast */ }
};
// Record the in-play win probability per game state while matches run —
// the momentum curve on match pages. Lambdas are the frozen pre-match
// expectation (predictions only recompute when the finished set changes).
const recordInplayTick = (): void => {
const preds = new Map(model.current().matches.map((m) => [m.num, m]));
for (const f of state.allFixtures()) {
if (f.status !== 'live' || f.homeScore == null || f.awayScore == null) continue;
const pred = preds.get(f.num);
if (!pred) continue;
const minute = f.minute ?? 0;
const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore);
recordInplay(f.num, {
minute, home_score: f.homeScore, away_score: f.awayScore,
p_home: +p.home.toFixed(4), p_draw: +p.draw.toFixed(4), p_away: +p.away.toFixed(4),
});
}
};
// ---- REST: snapshot + predictions (initial load + a no-WS fallback) ----
app.get('/api/snapshot', async () => state.snapshot());
app.get('/api/predictions', async () => model.current());
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
// Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
app.get('/api/odds', async () => ({ odds: latestOddsAll() }));
// In-play win-probability history — the momentum curve on match pages.
app.get('/api/inplay/:num', async (req) => {
const num = Number((req.params as { num: string }).num);
return { points: Number.isFinite(num) ? inplayHistory(num) : [] };
});
// Web Push (feature-flagged on VAPID env): key discovery + subscriptions.
app.get('/api/push/key', async (_req, reply) =>
pushEnabled ? { key: vapidPublicKey } : reply.code(404).send({ error: 'push disabled' }),
);
app.post('/api/push/subscribe', async (req, reply) => {
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
const b = (req.body ?? {}) as { subscription?: { endpoint?: string }; team?: string };
if (!b.subscription?.endpoint || !b.team) return reply.code(400).send({ error: 'subscription + team required' });
pushSubscribe(b.subscription as { endpoint: string }, b.team);
return { ok: true };
});
app.post('/api/push/unsubscribe', async (req, reply) => {
if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' });
const b = (req.body ?? {}) as { endpoint?: string };
if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' });
pushUnsubscribe(b.endpoint);
return { ok: true };
});
// Rich match data captured from FotMob/FIFA/Open-Meteo, projected for the
// match center: oriented shots with xG, momentum, POTM, official info, weather.
app.get('/api/match-rich/:num', async (req, reply) => {
const num = Number((req.params as { num: string }).num);
if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
interface FmShot { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string }
interface FmDetails {
homeTeamId?: number | null;
shots?: FmShot[];
momentum?: { main?: { data?: { minute: number; value: number }[] } } | null;
matchFacts?: { playerOfTheMatch?: { name?: { fullName?: string }; rating?: { num?: string }; teamName?: string } } | null;
}
const fm = getMatchProvider<FmDetails>(num, 'fotmob');
const fifaInfo = getMatchProvider<{ attendance: number | null; officials: { Name?: { Description?: string }[]; TypeLocalized?: { Description?: string }[] }[]; stadium: string }>(num, 'fifa-info');
const weather = getMatchProvider<{ tempC: number | null; precipProbPct: number | null; windKmh: number | null; elevationM: number }>(num, 'weather');
const homeId = fm?.data.homeTeamId ?? null;
const shots = (fm?.data.shots ?? []).map((s) => ({
isHome: homeId != null ? s.teamId === homeId : null,
player: s.playerName ?? '',
x: s.x ?? null,
y: s.y ?? null,
min: s.min ?? null,
xg: s.expectedGoals ?? null,
xgot: s.expectedGoalsOnTarget ?? null,
type: s.eventType ?? '',
situation: s.situation ?? '',
}));
const xg = shots.reduce(
(acc, s) => {
if (s.isHome === true) acc.home += s.xg ?? 0;
else if (s.isHome === false) acc.away += s.xg ?? 0;
return acc;
},
{ home: 0, away: 0 },
);
const potmRaw = fm?.data.matchFacts?.playerOfTheMatch;
return {
shots,
xg: shots.length ? { home: +xg.home.toFixed(2), away: +xg.away.toFixed(2) } : null,
momentum: fm?.data.momentum?.main?.data ?? [],
potm: potmRaw?.name?.fullName ? { name: potmRaw.name.fullName, rating: potmRaw.rating?.num ?? null } : null,
info: fifaInfo
? {
attendance: fifaInfo.data.attendance,
stadium: fifaInfo.data.stadium,
referee: fifaInfo.data.officials?.find((o) => o.TypeLocalized?.[0]?.Description === 'Referee')?.Name?.[0]?.Description ?? null,
}
: null,
weather: weather ? weather.data : null,
};
});
// Golden Boot: per-player goals aggregated from structured ESPN scoring
// plays across every started match (own goals + shootout kicks excluded).
app.get('/api/goldenboot', async () => {
const tally = new Map<string, { player: string; team: string; goals: number; latest: string }>();
for (const f of state.allFixtures()) {
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (!ext) continue;
const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name]));
for (const e of ext.data.events) {
if (!e.scoring || e.shootout || !e.scorer) continue;
if (/own goal/i.test(e.type)) continue;
const team = (e.teamId && teamName.get(e.teamId)) ?? '';
const key = `${e.scorer}|${team}`;
const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff };
cur.goals += 1;
if (f.kickoff > cur.latest) cur.latest = f.kickoff;
tally.set(key, cur);
}
}
const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25);
return { players };
});
app.get('/api/scoreboard', async () => buildScoreboard(state));
// The data lake, quantified — counters for the /data page.
app.get('/api/data/stats', async () => {
const safe = <T,>(fn: () => T): T | null => { try { return fn(); } catch { return null; } };
return {
lake: safe(() => loadStatic('lakestats.json')),
archive: safe(() => {
const r = loadStatic<{ matchesProcessed: number; asOf: string }>('ratings.json');
const h = loadStatic<Record<string, unknown>>('h2h.json');
return { results: r.matchesProcessed, asOf: r.asOf, h2hPairings: Object.keys(h).length };
}),
fingerprints: safe(() => Object.keys(loadStatic<{ teams: Record<string, unknown> }>('fingerprints.json').teams).length),
squadValues: safe(() => Object.keys(loadStatic<{ teams: Record<string, unknown> }>('squadvalues.json').teams).length),
db: dbCounts(),
sources: healthAll(),
};
});
app.get('/api/odds/:num', async (req, reply) => {
const num = Number((req.params as { num: string }).num);
if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' });
return { history: oddsFor(num) };
});
app.get('/api/preview/:num', async (req, reply) => {
const num = Number((req.params as { num: string }).num);
const preview = Number.isFinite(num) ? buildPreview(num, state, model) : null;
return preview ?? reply.code(404).send({ error: 'no such fixture' });
});
app.get('/api/team/:name', async (req, reply) => {
const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name));
const profile = buildTeamProfile(name, state, model);
return profile ?? reply.code(404).send({ error: 'no such team' });
});
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
if (!IS_PROD) {
app.post('/api/dev/score', async (req, reply) => {
const b = (req.body ?? {}) as {
num?: number; homeScore?: number; awayScore?: number; status?: MatchStatus; minute?: number;
};
if (typeof b.num !== 'number') return reply.code(400).send({ error: 'num required' });
const status: MatchStatus = b.status ?? 'finished';
const f = state.applyScore(
b.num,
b.homeScore ?? null,
b.awayScore ?? null,
status,
b.minute ?? null,
'football-data',
);
if (!f) return reply.code(404).send({ error: 'no such fixture' });
saveFixtureResult(f.num, f.homeScore, f.awayScore, f.status);
snapshotCheck(state, model);
recordInplayTick();
broadcast();
return { ok: true, fixture: f };
});
}
// ---- WebSocket: push the snapshot on connect and on every change ----
void app.register(fastifyWebsocket);
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket: WebSocket, req: FastifyRequest) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.close(1008, 'origin');
return;
}
if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
clients.add(socket);
try { socket.send(snapshotMessage()); socket.send(predictionsMessage()); } catch { /* closed */ }
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('close', () => { clearInterval(heartbeat); clients.delete(socket); });
socket.on('error', () => { clearInterval(heartbeat); clients.delete(socket); });
});
});
// ---- ingestion scheduler (ESPN primary; football-data / SofaScore fallback) ----
const stopIngest = startScheduler(state, () => broadcast(), () => {
const taken = snapshotCheck(state, model);
if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
try { recordInplayTick(); } catch { /* non-fatal */ }
});
app.addHook('onClose', async () => stopIngest());
// ---- nightly Team-DC refit (backtest-validated): learn from this tournament ----
const refitTimer = setInterval(() => {
if (model.refitTeamDc(state.allFixtures())) broadcast();
}, 24 * 60 * 60 * 1000);
// The boot refit ran before ingestion restored finished matches — run once
// more after the boot sweep settles so a restart keeps tournament context.
const refitOnce = setTimeout(() => {
if (model.refitTeamDc(state.allFixtures())) broadcast();
}, 5 * 60_000);
app.addHook('onClose', async () => { clearInterval(refitTimer); clearTimeout(refitOnce); });
// ---- static SPA (prod) with history fallback + per-route OG meta ----
if (existsSync(STATIC_DIR)) {
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
// Shared links unfurl with the model's numbers: swap <title> and inject
// og: meta for /match/:num and /team/:name before serving the SPA shell.
const indexHtml = readFileSync(`${STATIC_DIR}/index.html`, 'utf8');
const esc = (s: string): string => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const withMeta = (title: string, desc: string): string =>
indexHtml.replace(
/<title>[^<]*<\/title>/,
`<title>${esc(title)}</title>\n <meta property="og:title" content="${esc(title)}" />\n <meta property="og:description" content="${esc(desc)}" />\n <meta name="description" content="${esc(desc)}" />`,
);
const ogFor = (url: string): string | null => {
try {
const mMatch = /^\/match\/(\d+)(?:\?|$)/.exec(url);
if (mMatch) {
const f = state.allFixtures().find((x) => x.num === Number(mMatch[1]));
if (!f) return null;
const pred = model.current().matches.find((m) => m.num === f.num);
const score = f.homeScore != null && f.status !== 'scheduled' ? ` ${f.homeScore}${f.awayScore}` : '';
const odds = pred ? ` · Model: ${Math.round(pred.probs.home * 100)}/${Math.round(pred.probs.draw * 100)}/${Math.round(pred.probs.away * 100)}` : '';
return withMeta(
`${f.home.label}${score || ' vs'} ${f.away.label} — Cup26`,
`World Cup 2026 · ${f.venue}${odds} (model probabilities, not betting advice)`,
);
}
const mTeam = /^\/team\/([^/?]+)(?:\?|$)/.exec(url);
if (mTeam) {
const team = decodeURIComponent(mTeam[1]!);
const odds = model.current().odds.find((o) => o.team === team);
if (!odds) return null;
return withMeta(
`${team} — Cup26`,
`World Cup 2026 · title odds ${(odds.champion * 100).toFixed(1)}% by the Cup26 model`,
);
}
} catch { /* fall through to the plain shell */ }
return null;
};
app.setNotFoundHandler((req, reply) => {
const url = req.raw.url ?? '/';
if (url.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
if (url.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
const html = ogFor(url);
if (html) return reply.type('text/html; charset=utf-8').send(html);
return reply.sendFile('index.html');
});
}
return app;
}
if (process.env.NODE_ENV !== 'test') {
const app = buildServer();
app.listen({ port: PORT, host: '0.0.0.0' })
.then((addr) => console.log(`cup26 server on ${addr} (static: ${existsSync(STATIC_DIR) ? STATIC_DIR : 'dev — vite serves UI'})`))
.catch((e) => { console.error(e); process.exit(1); });
}