f5156dd16d
Two Truth-Law fixes found by auditing the product logged-out. FIX 1 — /u/[handle] claimed a "CLV-verified record" with "closing-line value included" while ZERO closing-line value renders there. Verified live: GET /api/profiles/vyndr returns beat_close_pct null (gated behind CLV_CAPTURE_RELIABLE, unset while C4 is open). Eight instances found — two of them (the OG + portrait "CLV-VERIFIED RECORD · 30D" eyebrows) only by the post-removal residual sweep; two more printed the claim in exactly the no-record branch. Copy now describes what the page shows. The gated CLV-VERIFIED badge and the BEAT CLOSE figure are removed from the public profile, OG card and portrait card. DISPLAY ONLY: beat_close_pct, clvCaptureReliable() and the whole CLV data path are untouched, and the earned directional badge stays Analyst+Desk. The claim returns when CLV genuinely renders here. Also fixes the doubled "· VYNDR · VYNDR" title (layout's '%s · VYNDR' template already supplies the suffix); verified on composed output by serving the build and reading the real HTML, not on source. FIX 2 — the player page's FORM was `70 + 4 × (count of tonight's graded props)`. Nothing on the HTTP path ever sets stats.form, so that fallback WAS the live number: Josh Bell's "74" is 70 + 4×1 prop, confirmed against his live payload. MATCHUP was gradeFromForm(that number), with a hardcoded 'B' on the no-archetype branch — both fabricated letters with no opponent input on the path. Systemic: buildIntel is the unconditional path for every player and sport. FORM and MATCHUP now render "—" (kind 'plain', so no bar width or colour is computed off a null). gradeFromForm is deleted and the prop count is no longer passed into buildIntel. computeFormScore's hardcoded 75 now returns undefined. Induced across MLB/NBA/WNBA: all render cleanly, and real values (USAGE 3.6 AB/G, REST B2B) still render. Neither form value feeds the grade — engine1 reads raw l5_avg/l20_avg against the line and never a form key; buildIntelFields decorates the already-graded object. Grade inputs are byte-identical. Held (needs a per-sport headline-stat design call): a real player-level form metric + label disambiguation. Tests 3491 passed / 289 suites, web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
355 lines
15 KiB
JavaScript
355 lines
15 KiB
JavaScript
/**
|
||
* 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');
|
||
// Wave 1 — thread the prop's game team hint so a namesake collision
|
||
// (two "James Wood") resolves to the RIGHT player, and a team that isn't
|
||
// a participant of the prop's game is dropped (never a foreign tag).
|
||
const res = await mlb.getPlayerStats(name, undefined, { teamHint: opts.teamHint });
|
||
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 ?? '—') },
|
||
];
|
||
// Session 60 (5.3) — WNBA/NBA parity: minutes-based usage on the
|
||
// profile (the basketball equivalent of AB/G). Only when the feed
|
||
// carries minutes — absent beats invented.
|
||
const mpg = Number(ci.mpg ?? ci.min ?? ci.minutes);
|
||
const extra = Number.isFinite(mpg) && mpg > 0 ? { usage: `${Math.round(mpg)} min` } : {};
|
||
if (extra.usage) season.push({ k: 'MIN', v: String(Math.round(mpg)) });
|
||
// Wave 2A — the REAL ESPN athlete id (headshot CDN) surfaces from the
|
||
// adapter. Absent → no id → monogram. Never guessed.
|
||
return { found: true, team: e.team || '', classifierInput: { ...ci, pos: e.position, ...extra }, season, last10: [], splits: [], espnId: e.espnId ?? null };
|
||
}
|
||
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.
|
||
*
|
||
* Session 65 — UN-FABRICATE (Truth Law: if it renders a number, it comes from
|
||
* real data or it does not render).
|
||
* - FORM used to fall back to `70 + 4 × (count of tonight's graded props)`,
|
||
* capped at 92. Nothing on the HTTP path ever sets `stats.form`, so that
|
||
* fallback WAS the live value: Josh Bell's "74" was 70 + 4×1 graded prop —
|
||
* a number carrying zero information about the player. Absent now renders
|
||
* absent.
|
||
* - MATCHUP used to be `gradeFromForm(form)` (i.e. the same prop count) with a
|
||
* hardcoded 'B' when no archetype resolved. There is no opponent input on
|
||
* this path at all, so BOTH branches were fabricated letters. Absent now
|
||
* renders absent.
|
||
* A real form metric is a separate, held design call (per-sport headline stat);
|
||
* it lands as `stats.form` / `stats.matchup` and lights these tiles back up.
|
||
* Tiles emit kind 'plain' when absent so the page prints "—" and draws no
|
||
* progress bar — no math, colour or bar width is computed off a null.
|
||
*/
|
||
function buildIntel(stats, arch) {
|
||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||
const form = stats.form != null && Number.isFinite(Number(stats.form))
|
||
? clamp(Math.round(Number(stats.form)), 0, 100)
|
||
: null;
|
||
// 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` : '—');
|
||
const matchup = typeof stats.matchup === 'string' && stats.matchup ? stats.matchup : null;
|
||
return [
|
||
form != null
|
||
? { label: 'FORM', kind: 'form', value: String(form), score: `${form}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' }
|
||
: { label: 'FORM', kind: 'plain', value: '—', color: '#7A7A8E' },
|
||
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
|
||
matchup
|
||
? { label: 'MATCHUP', kind: 'grade', value: matchup, color: '#00D4A0' }
|
||
: { label: 'MATCHUP', kind: 'plain', value: '—', color: '#7A7A8E' },
|
||
{ label: 'REST', kind: 'plain', value: stats.rest || '—', color: stats.rest ? '#00D4A0' : '#7A7A8E' },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
// Session 65 — prop count is deliberately NOT passed any more: it used to
|
||
// manufacture the FORM number. Intel is a function of real stats only.
|
||
intel: buildIntel(realStats, archetype),
|
||
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 },
|
||
};
|