Files
cup26/server/src/player.ts
T
NilsBriggen 0fd13d4c24 Players become first-class: profile pages, unified names, deeper match data
- /player/:name pages assembled entirely from stored data: tournament
  totals, per-match log (rating, minutes, G/A, cards), aggregated shot
  map, suspension status, next-match scorer line. Either source's
  spelling resolves to the same page (token-set + romanization fold).
- Player names are now links everywhere: stats boards, golden boot,
  discipline, injuries, all-time scorers, lineups, the pitch dots,
  timeline events and assists, and the search palette (diacritic-folded).
- Golden Boot displays the FotMob form of each name — no more
  "Hwang In-Beom" and "In-Beom Hwang" on the same page.
- Match pages: detailed full-time stats card (passes, duels, xGOT… from
  the stored FotMob report), O/U + spread closing line, "Who scores?"
  pre-match props board, and a FIFA-confirmed-lineups badge.
- Stats hub: new boards — xA, chances created, save percentage, saves
  per 90.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 00:54:58 +02:00

168 lines
7.1 KiB
TypeScript

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<string, PlayerRef> {
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<FmDetails>(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<number, MatchLiveEvent[]>();
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<FmDetails>(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<string, { at: number; profile: PlayerProfile | null }>();
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;
}