/** * playerIntelService — aggregates a player's intelligence payload for the * /player/:name profile page and the stats API (Session 42). * * Sources, all best-effort + graceful (the page must render even when every * upstream is cold): * - archetypeService.classify → PRIMARY/SECONDARY archetype + DNA blend * - grades:{sport} cache → this player's graded props for tonight * - caller-supplied season/last10/splits/gradeHistory (wired by the route * from the stats adapters; empty when unavailable) * * Pure-ish: I/O is only the grades-cache read, and that's injectable for tests. */ const { classify } = require('./archetypeService'); const { normalizeName, nameKey } = require('../utils/playerName'); const toNum = (v) => { const n = parseFloat(v); return Number.isNaN(n) ? 0 : n; }; const fmt3 = (v) => { const s = String(v == null ? '' : v); return s.startsWith('0.') ? s.slice(1) : s; // ".282" baseball style }; /** * Sanitize a player-name URL param (Montgomery's note). Decode, strip anything * that isn't a letter/number/space or name punctuation (. - '), collapse * whitespace, cap length. Defends the cache key + any downstream lookups. */ function sanitizePlayerName(raw) { let decoded = String(raw == null ? '' : raw); try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ } const cleaned = decoded .replace(/[^\p{L}\p{N}\s.'-]/gu, '') .replace(/\s+/g, ' ') .trim() .slice(0, 60); // Session 46 — normalize periods/suffix for display ("A.J. Ewing" → "AJ Ewing"). return normalizeName(cleaned).display; } // Session 46 — match by the normalized name key so name variants resolve to the // same player (snapshot grades, profile lookups). const normName = (n) => nameKey(n); async function loadPlayerGrades(sport, name, cacheGetFn) { const env = await cacheGetFn(`grades:${sport}`); const grades = env && Array.isArray(env.grades) ? env.grades : []; const target = normName(name); if (!target) return []; return grades.filter((g) => normName(g.player_name || g.player) === target); } // ── MLB raw-stat normalization (Session 43) ───────────────────────── // Maps the raw statsapi.mlb.com season object into (a) classifier input and // (b) the profile's display rows + last-10 log. function mapMlbHitter(s) { const pa = toNum(s.plateAppearances) || toNum(s.atBats); return { avg: toNum(s.avg), hr: toNum(s.homeRuns), rbi: toNum(s.rbi), sb: toNum(s.stolenBases), ops: toNum(s.ops), runs: toNum(s.runs), doubles: toNum(s.doubles), k_rate: pa > 0 ? (toNum(s.strikeOuts) / pa) * 100 : 0, }; } function mapMlbPitcher(s) { const ip = toNum(s.inningsPitched); const gs = toNum(s.gamesStarted); return { era: toNum(s.era), whip: toNum(s.whip), k9: toNum(s.strikeoutsPer9Inn) || (ip > 0 ? (toNum(s.strikeOuts) / ip) * 9 : 0), ip_per_start: gs > 0 ? ip / gs : 0, saves: toNum(s.saves), role: gs > 0 ? 'SP' : toNum(s.saves) > 0 ? 'CL' : 'RP', }; } function mlbSeasonRows(s, group) { if (group === 'pitching') { return [ { k: 'ERA', v: String(s.era ?? '—') }, { k: 'K', v: String(s.strikeOuts ?? '—') }, { k: 'IP', v: String(s.inningsPitched ?? '—') }, { k: 'WHIP', v: String(s.whip ?? '—') }, { k: 'GS', v: String(s.gamesStarted ?? '—') }, ]; } return [ { k: 'AVG', v: fmt3(s.avg) || '—' }, { k: 'HR', v: String(s.homeRuns ?? '—') }, { k: 'RBI', v: String(s.rbi ?? '—') }, { k: 'OPS', v: fmt3(s.ops) || '—' }, { k: 'GP', v: String(s.gamesPlayed ?? '—') }, ]; } function mlbLast10Rows(log, group) { return (log || []).slice(-10).reverse().map((g) => { const st = g.stat || {}; const summary = group === 'pitching' ? `${st.inningsPitched ?? '0'} IP · ${st.strikeOuts ?? 0} K` : `${st.hits ?? 0}-${st.atBats ?? 0} · ${st.homeRuns ?? 0} HR`; return { d: String(g.date || '').slice(5), opp: g.opponent ? String(g.opponent).slice(0, 3).toUpperCase() : '', stat: summary }; }); } /** * Resolve a player's REAL stats by sport (Session 43). Returns a normalized * bundle or { found:false }. Best-effort + never throws — the profile renders * regardless. Adapters are injectable for tests (opts.mlbAdapter/nbaClient). */ async function resolvePlayerStats(name, sport, opts = {}) { const sp = String(sport || 'nba').toLowerCase(); try { if (sp === 'mlb') { const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); const res = await mlb.getPlayerStats(name); if (!res || !res.found) return { found: false }; const classifierInput = res.group === 'pitching' ? mapMlbPitcher(res.season) : mapMlbHitter(res.season); // Session 48 — real VYNDR INTELLIGENCE for the player profile: usage (AB/G) // + rest (days off between the two most recent games; 0 = B2B). The // classify() scorer ignores these extra keys; buildIntel reads them. Object.assign(classifierInput, mlbProfileIntel(res)); return { found: true, team: res.team || '', classifierInput, season: mlbSeasonRows(res.season, res.group), last10: mlbLast10Rows(res.last10, res.group), splits: [], // Session 60 (night2/B) — the RAW flattened game log, most-recent // first, for the streaks/hot-list roster blob. Free: the adapter // already fetched it for this resolve; nothing extra is called. rawLog: (res.last10 || []) .map((r) => ({ date: r.date || null, opponent: r.opponent || null, isHome: r.isHome ?? null, ...(r.stat || {}) })) .reverse(), seasonRaw: res.season || null, group: res.group || null, playerId: res.id ?? null, }; } if (sp === 'nba' || sp === 'wnba') { // PRIMARY: the Python nba_api service (nbaStatsClient) — often offline in // prod. FALLBACK: ESPN's free public stats (espnStatsAdapter). Either way, // a miss degrades quietly to found:false (no badge, never a crash). const nba = opts.nbaClient || require('./nbaStatsClient'); let data = await nba.getSeasonAvg(name).catch(() => null); if (!data || typeof data !== 'object' || !toNum(data.ppg ?? data.points)) { const espn = opts.espnStats || require('./adapters/espnStatsAdapter'); const e = await espn.getSeasonAverages(name, sp).catch(() => ({ found: false })); if (e && e.found && e.classifierInput) { const ci = e.classifierInput; const season = [ { k: 'PPG', v: String(ci.ppg ?? '—') }, { k: 'RPG', v: String(ci.rpg ?? '—') }, { k: 'APG', v: String(ci.apg ?? '—') }, { k: 'BLK', v: String(ci.bpg ?? '—') }, ]; return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position }, season, last10: [], splits: [] }; } return { found: false }; } const classifierInput = { ppg: toNum(data.ppg ?? data.points), rpg: toNum(data.rpg ?? data.rebounds), apg: toNum(data.apg ?? data.assists), bpg: toNum(data.bpg ?? data.blocks), spg: toNum(data.spg ?? data.steals), threes: toNum(data.threes ?? data.fg3m), usg: toNum(data.usg ?? data.usage), pos: data.pos || data.position, }; const season = [ { k: 'PPG', v: String(classifierInput.ppg) }, { k: 'RPG', v: String(classifierInput.rpg) }, { k: 'APG', v: String(classifierInput.apg) }, { k: 'BLK', v: String(classifierInput.bpg) }, ]; return { found: true, team: data.team || '', classifierInput, season, last10: [], splits: [] }; } } catch (err) { console.warn('[playerIntel] resolvePlayerStats failed:', name, sp, err.message); } return { found: false }; } /** * Profile VYNDR INTELLIGENCE bits from real MLB stats (Session 48): * usage = AB/G (the MLB usage equivalent), rest = days off between the two most * recent games ("B2B"/"Xd rest"). Returns {} when unavailable. */ function mlbProfileIntel(res) { const out = {}; const ab = parseFloat(res.season && res.season.atBats); const gp = parseFloat(res.season && (res.season.gamesPlayed ?? res.season.gamesStarted)); if (Number.isFinite(ab) && Number.isFinite(gp) && gp > 0) { out.usage = `${Math.round((ab / gp) * 10) / 10} AB/G`; } const dated = Array.isArray(res.last10) ? res.last10.filter((g) => g && g.date) : []; if (dated.length >= 2) { const gap = Math.round((new Date(dated[dated.length - 1].date) - new Date(dated[dated.length - 2].date)) / 86_400_000); if (Number.isFinite(gap) && gap >= 1 && gap <= 14) { const off = gap - 1; out.rest = off === 0 ? 'B2B' : `${off}d rest`; } } return out; } /** Derive the VYNDR Intelligence metric row from whatever we have. */ function buildIntel(stats, arch, propCount) { const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n)); // Form: lean on last-10 vs season if provided, else a neutral baseline that // scales gently with the strongest graded prop's confidence proxy. const form = stats.form != null ? clamp(Math.round(stats.form), 0, 100) : 70 + clamp(propCount * 4, 0, 22); // Session 48 — prefer a preformatted usage string (e.g. "3.6 AB/G"); rest // shows the real value or "—" (no more bogus "+0%"). const usage = stats.usage || (stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—'); return [ { label: 'FORM', kind: 'form', value: String(form), score: `${clamp(form, 0, 100)}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' }, { label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' }, { label: 'MATCHUP', kind: 'grade', value: arch.primary ? gradeFromForm(form) : 'B', color: '#00D4A0' }, { label: 'REST', kind: 'plain', value: stats.rest || '—', color: '#00D4A0' }, ]; } function gradeFromForm(form) { if (form >= 90) return 'A'; if (form >= 80) return 'B+'; if (form >= 70) return 'B'; return 'C'; } /** * Build the full player intelligence payload. * opts: { cacheGet, stats, season, last10, splits, gradeHistory, injury, team } */ async function getPlayerIntel(name, sport, opts = {}) { const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet; const clean = sanitizePlayerName(name); const sp = String(sport || 'nba').toLowerCase(); // 1. Real season stats from the sport adapter (Session 43). Injectable for // tests via opts.resolveStats; opts.stats short-circuits to caller-supplied. let resolved; if (opts.stats) { resolved = { found: Object.keys(opts.stats).length > 0, classifierInput: opts.stats, season: opts.season || [], last10: opts.last10 || [], splits: opts.splits || [], team: opts.team || '' }; } else { const resolver = opts.resolveStats || resolvePlayerStats; resolved = await resolver(clean, sp, opts); } const realStats = resolved.classifierInput || {}; // 2. Archetype, now classified from REAL stats when we have them. const archetype = classify(sp, realStats); // 3. Tonight's graded props from the slate cache. let props = []; try { props = await loadPlayerGrades(sp, clean, cacheGetFn); } catch { props = []; // cold/broken cache must not 500 the page } const activeProps = props.map((p) => ({ stat: p.stat_type || p.stat, line: p.line, side: String(p.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O', grade: p.grade, confidence: p.confidence != null ? `${p.confidence}%` : null, })); const team = resolved.team || (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || ''; return { player: clean, sport: sp, team, found: !!resolved.found || props.length > 0, archetype, propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] }, education: archetype.primary ? archetype.primary.education : '', season: resolved.season || opts.season || [], last10: resolved.last10 || opts.last10 || [], splits: resolved.splits || opts.splits || [], gradeHistory: opts.gradeHistory || [], activeProps, intel: buildIntel(realStats, archetype, props.length), injury: opts.injury || null, }; } /** * Tonight's leaders for a sport — top graded props by confidence, optionally * filtered to a single stat. Reads the same grades:{sport} cache. */ async function getLeaders(sport, opts = {}) { const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet; const sp = String(sport || 'nba').toLowerCase(); const stat = opts.stat ? String(opts.stat).toLowerCase() : null; const limit = Math.max(1, Math.min(50, Number(opts.limit) || 10)); let grades = []; try { const env = await cacheGetFn(`grades:${sp}`); grades = env && Array.isArray(env.grades) ? env.grades : []; } catch { grades = []; } return grades .filter((g) => !stat || String(g.stat_type || g.stat || '').toLowerCase() === stat) .sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0)) .slice(0, limit) .map((g) => ({ player: g.player_name || g.player, team: g.team || g.team_abbr || '', stat: g.stat_type || g.stat, line: g.line, side: String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O', grade: g.grade, confidence: g.confidence, })); } module.exports = { sanitizePlayerName, getPlayerIntel, getLeaders, resolvePlayerStats, _internals: { normName, loadPlayerGrades, buildIntel, mapMlbHitter, mapMlbPitcher, mlbSeasonRows, mlbLast10Rows }, };