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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user