v2 Phase 4: live match center + in-play win probability

- src/lib/model/inplay.ts: in-play win probability — remaining goals modelled as
  time-scaled Poisson processes convolved with the current score; recomputed each
  tick. 4 vitest cases (locks at FT, lead grows decisive over time, sums to 1).
- ESPN summary normalizer extended with the live event timeline (keyEvents);
  preview now carries liveStats + events. Scheduler refreshes LIVE matches'
  summaries every live tick (~20s) for fresh stats/timeline.
- /match/:num is now a live match center: when live it shows in-play win
  probability (driven by the WS score/clock), match stats bars and an event
  timeline, polling while live; preview content for scheduled games, recap when
  finished. Verified with a simulated live state (94% live vs 81% pre-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:19:22 +02:00
parent 45c0a978fc
commit adbfb5d6d1
7 changed files with 326 additions and 155 deletions
+15 -1
View File
@@ -64,6 +64,7 @@ export function normalizeScoreboard(body: { events?: EspnEvent[] }): EspnMatch[]
export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number }
export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' }
export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface EspnTimelineEvent { minute: number | null; type: string; text: string; teamId: string | null }
export interface EspnSummary {
fetchedAt: number;
venue: string | null;
@@ -74,6 +75,8 @@ export interface EspnSummary {
lineups: Record<string, EspnLineupPlayer[]>;
/** Team match stats (possession, shots, …) once the game is live/finished. */
stats: Record<string, Record<string, string>>;
/** Live/finished event timeline (goals, cards, subs). */
events: EspnTimelineEvent[];
}
export interface RawSummary {
@@ -85,6 +88,7 @@ export interface RawSummary {
teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[];
};
rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[];
keyEvents?: { clock?: { displayValue?: string }; type?: { text?: string }; text?: string; team?: { id?: string } }[];
}
export function normalizeSummary(raw: RawSummary): EspnSummary {
@@ -128,5 +132,15 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
stats[id] = Object.fromEntries((t.statistics ?? []).map((s) => [s.name, s.displayValue]));
}
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats };
const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => {
const m = e.clock?.displayValue?.match(/(\d+)/);
return {
minute: m ? Number(m[1]) : null,
type: e.type?.text ?? '',
text: e.text ?? '',
teamId: e.team?.id ?? null,
};
});
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
}
+8
View File
@@ -77,6 +77,14 @@ export function startScheduler(state: TournamentState, onChange: () => void): ()
const changed = state.mergeLive(matches, source);
if (changed.length) { console.log(`[ingest] ${changed.length} fixture(s) updated via ${source}`); onChange(); }
}
// Keep live matches' stats + event timeline fresh at the live cadence.
for (const f of state.allFixtures()) {
if (f.status !== 'live') continue;
const eid = getSourceMap(f.num, 'espn');
if (!eid) continue;
try { setMatchExt(f.num, await fetchEspnSummary(eid)); } catch { /* logged via health */ }
}
};
// ---- enrichment: ESPN summary for imminent/live fixtures ----
+10
View File
@@ -85,6 +85,16 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
home: home ? scorersData()[home] ?? [] : [],
away: away ? scorersData()[away] ?? [] : [],
},
liveStats:
eh && ea && (Object.keys(summary!.stats[eh.id] ?? {}).length || Object.keys(summary!.stats[ea.id] ?? {}).length)
? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} }
: null,
events: (summary?.events ?? []).map((e) => ({
minute: e.minute,
type: e.type,
text: e.text,
team: e.teamId === eh?.id ? 'home' : e.teamId === ea?.id ? 'away' : null,
})),
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext