b2f680906b
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>
361 lines
17 KiB
TypeScript
361 lines
17 KiB
TypeScript
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||
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); });
|
||
}
|