import { getMatchProvider } from './db/db'; import { getTournamentStats } from './ingest/tournamentStats'; import { disciplineFor, fixtureEvents, goldenBootRows, nextAnytimeOdds } from './stats'; import { aliasKey, buildPlayerIndex, findBoardRow, playerMatchLine, resolvePlayer, type LineupPerfPlayer, type PlayerFixtureData, type PlayerRef, } from '../../src/lib/playerProfile'; import { nameMatch } from '../../src/lib/matchEvents'; import type { TournamentState } from './tournament'; import type { Fixture, MatchLiveEvent, PlayerMatchLine, PlayerProfile, RichShot } from '../../src/lib/types'; // Assembles /api/player + /api/players entirely from stored data: tournament // boards, the per-fixture FotMob lineups/shots and our ESPN event feeds. // Pattern: thin orchestration here, the fold logic lives in src/lib/playerProfile. interface FmPerfPlayer { name?: string; performance?: { rating?: number; substitutionEvents?: { time?: number; type?: string }[] }; } interface FmLineupTeam { starters?: FmPerfPlayer[]; subs?: FmPerfPlayer[] } interface FmDetails { homeTeamId?: number | null; awayTeamId?: number | null; shots?: { teamId?: number; playerName?: string; x?: number; y?: number; min?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string; }[]; lineup?: { homeTeam?: FmLineupTeam; awayTeam?: FmLineupTeam } | null; } const subTime = (p: FmPerfPlayer, type: string): number | null => { const e = p.performance?.substitutionEvents?.find((s) => s.type === type); return typeof e?.time === 'number' ? e.time : null; }; const perf = (p: FmPerfPlayer): LineupPerfPlayer => ({ name: p.name ?? '', rating: typeof p.performance?.rating === 'number' ? p.performance.rating : null, subIn: subTime(p, 'subIn'), subOut: subTime(p, 'subOut'), }); /** Cross-source same-player check: alias-key equality (bridges romanization) * or token-set match (bridges name order). */ const samePlayer = (a: string, b: string): boolean => aliasKey(a) === aliasKey(b) || nameMatch(a, b); const opponentOf = (f: Fixture, side: 'home' | 'away'): string => side === 'home' ? f.away.team ?? f.away.label : f.home.team ?? f.home.label; /** Everyone the stored data knows: boards (FotMob display forms first), every * lineup that was captured, then the ESPN-derived scorers dedupe onto them. */ function playerIndex(state: TournamentState): Map { const sources: PlayerRef[] = []; const boards = getTournamentStats()?.players; if (boards) { for (const cat of ['rating', 'minutes', 'goals', 'assists', 'xg']) { for (const r of boards[cat] ?? []) sources.push({ name: r.name, team: r.team }); } } for (const f of state.allFixtures()) { if (f.status === 'scheduled' || !f.home.team || !f.away.team) continue; const fm = getMatchProvider(f.num, 'fotmob'); if (!fm) continue; for (const [key, team] of [['homeTeam', f.home.team], ['awayTeam', f.away.team]] as const) { const tm = fm.data.lineup?.[key]; for (const p of [...(tm?.starters ?? []), ...(tm?.subs ?? [])]) { if (p.name) sources.push({ name: p.name, team }); } } } for (const b of goldenBootRows(state)) sources.push({ name: b.player, team: b.team }); return buildPlayerIndex(sources); } function assemble(nameParam: string, state: TournamentState): PlayerProfile | null { const ref = resolvePlayer(nameParam, playerIndex(state)); if (!ref) return null; const { name, team } = ref; const cache = new Map(); const fixtures = state .allFixtures() .filter((f) => f.home.team === team || f.away.team === team) .sort((a, b) => a.kickoff.localeCompare(b.kickoff)); const matchLog: PlayerMatchLine[] = []; const shots: RichShot[] = []; for (const f of fixtures) { if (f.status === 'scheduled') continue; const side: 'home' | 'away' = f.home.team === team ? 'home' : 'away'; const fm = getMatchProvider(f.num, 'fotmob'); const tm = fm?.data.lineup?.[side === 'home' ? 'homeTeam' : 'awayTeam']; const line = playerMatchLine(name, { num: f.num, opponent: opponentOf(f, side), side, finished: f.status === 'finished', kickoff: f.kickoff, homeScore: f.homeScore, awayScore: f.awayScore, starters: (tm?.starters ?? []).map(perf), bench: (tm?.subs ?? []).map(perf), events: fixtureEvents(f.num, f.home.team, f.away.team, cache), } satisfies PlayerFixtureData); if (line) matchLog.push(line); // His shots, forced onto the home orientation so one aggregate ShotMap // can draw every match attacking right. const teamId = side === 'home' ? fm?.data.homeTeamId : fm?.data.awayTeamId; for (const s of fm?.data.shots ?? []) { if (!s.playerName || !samePlayer(s.playerName, name)) continue; if (teamId != null && s.teamId !== teamId) continue; shots.push({ isHome: true, player: name, 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 totals: PlayerProfile['totals'] = {}; const boards = getTournamentStats()?.players; if (boards) { for (const [cat, rows] of Object.entries(boards)) { const row = findBoardRow(rows, name); if (row) totals[cat] = { value: row.value, matches: row.matches, minutes: row.minutes }; } } const bootGoals = goldenBootRows(state).find((b) => samePlayer(b.player, name))?.goals ?? 0; const discipline = disciplineFor(team, state, cache).find((p) => samePlayer(p.player, name)) ?? null; const next = state .allFixtures() .filter((f) => f.status !== 'finished' && (f.home.team === team || f.away.team === team)) .sort((a, b) => a.kickoff.localeCompare(b.kickoff))[0]; const odds = nextAnytimeOdds(name, team, state); const nextProp = next && odds != null ? { num: next.num, opponent: opponentOf(next, next.home.team === team ? 'home' : 'away'), odds } : null; return { name, team, totals, bootGoals, matchLog, shots, discipline, nextProp, updatedAt: Date.now() }; } // A 60s memo guards the 10s polling bursts of an open player page; negative // results are cached too (retired all-time scorers get linked). const TTL = 60_000; const memo = new Map(); let listMemo: { at: number; players: PlayerRef[] } | null = null; export function buildPlayerProfile(nameParam: string, state: TournamentState): PlayerProfile | null { const k = aliasKey(nameParam); const hit = memo.get(k); if (hit && Date.now() - hit.at < TTL) return hit.profile; const profile = assemble(nameParam, state); if (memo.size > 200) memo.clear(); memo.set(k, { at: Date.now(), profile }); return profile; } /** The search palette's lightweight player list. */ export function playersIndex(state: TournamentState): PlayerRef[] { if (listMemo && Date.now() - listMemo.at < TTL) return listMemo.players; const players = [...playerIndex(state).values()].sort((a, b) => a.name.localeCompare(b.name)); listMemo = { at: Date.now(), players }; return players; }