Compare commits

...

7 Commits

Author SHA1 Message Date
builtbykev 4c8d339987 Deploy: NBA/WNBA grade unlock (Wave 0) + headshot coverage + truth-train map
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:55:33 -04:00
builtbykev cce99709c9 Merge Wave 0: NBA/WNBA grade unlock (free ESPN gamelogs)
featureCache.gameLogFeatures falls back to espnStatsAdapter.getPlayerGameLog
(free ESPN per-game logs) when the offline Python source returns null → NBA/WNBA
produce l5/l20 → props GRADE instead of refusing. +18 tests, proof test locks
the refuse→grade transition. MLB unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:47:06 -04:00
builtbykev 287c1c047a Wave 0: NBA/WNBA grade unlock — free ESPN per-athlete gamelog source
The Python nba_api service (gameLogService) is offline in prod, so
featureCache's non-MLB branch produced no l5/l20 averages →
projectionFor returned null → the ENTIRE NBA/WNBA slate refused
(insufficient_data). Only MLB actually graded.

Fix (free, no-auth, verified live):
- espnStatsAdapter.getPlayerGameLog(name, sport) — resolves name→ESPN
  numeric athlete id via the v2 search (the v3 /search now returns
  count:0; the v2 uid carries a:<id>, defaultLeagueSlug disambiguates
  league), fetches the per-athlete gamelog, and parses per-game rows
  keyed by VYNDR stat names (points/rebounds/assists/threes/steals/
  blocks/turnovers + computed pra). Columns are indexed by the
  response's own names[] array (NBA and WNBA orders DIFFER), never
  positionally. Most-recent first, defensive (null on unrecognized
  shape, never throws), cached (espngamelog:{sport}:{id} 4h + memory).
- featureCache.gameLogFeatures — falls back to the ESPN gamelog for
  nba/wnba when the Python source returns null/empty, producing
  l5/l10/l20 + rest_days + minutes_per_game via a new local
  NBA_LOG_FIELD map + pure nbaGameLogFeatures (S11 three-map-split:
  separate from MLB_LOG_FIELD).

Grade gates already whitelist all 8 NBA/WNBA stat types in both Node
paths (analyze.js + scan.js); no gate change needed.

Tests (hermetic, no network): espnGameLog.test.js (parser/resolver/
adapter) + featureCacheNba.test.js (the UNLOCK proof — empty features
refuse, ESPN-derived features grade). 3098 tests green; next build
exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:44:57 -04:00
builtbykev 153ff21c11 Truth-train decisions locked: futures (quota-disciplined), soccer deferred, ESPN gamelog verified
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:23:25 -04:00
builtbykev 4a7046a37e Step 0 MAP: truth-everywhere + offseason hub — grading is the real first domino
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:18:02 -04:00
builtbykev 392cf3ede5 Cross-sport headshot coverage hardening
Harvest ESPN athlete id + direct headshot href from the game-summary feeds
already called (independent of the flaky Python stats path) → reliable
NBA/WNBA/NFL/NHL headshots when in-season; soccer stays honest monogram
(no free id). MLB unchanged (zero extra I/O).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:15:00 -04:00
builtbykev fceb3707b5 Wave 2B: reliable cross-sport headshots via ESPN athlete index
The NBA/WNBA espnId was captured only from espnStatsAdapter (the offline-
Python fallback), unreliable in prod. Add espnAthleteIndex — a pure,
defensive harvester that builds { nameKey -> {espnId, headshotHref} } from
the ESPN schedule->summary/boxscore/leaders/injuries/roster feeds the
pipeline already calls (free, bounded mapLimit, cached, MLB->{}).

snapshotService now fills any player the primary stats-resolve left without
an espnId from this index, and stores a DIRECT headshotHref as headshotUrl
on the enriched grade (the exact URL, never 404s on a constructed path).
Threaded headshotUrl through slateAdapter.buildPlayerStripsFromProps ->
GameCard -> StatStrip -> PlayerAvatar/getHeadshotUrl (direct href wins over
the constructed one). MLB's MLBAM path is untouched. Soccer resolves only
via a direct href; absent -> honest monogram (API_FOOTBALL_KEY remains the
reliable soccer path, unwired).

getGameSummary now also passes through ESPN `rosters` (pre-game lineups
carry id + headshot). Everything graceful: any miss -> absent -> monogram.

Tests: tests/unit/espnHeadshotIndex.test.js (11) — fixture->index, snapshot
merge fallback, direct-href-wins, soccer honest monogram, malformed/cyclic
parse never throws. Full suite 3080 green; web next build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 18:12:31 -04:00
17 changed files with 1055 additions and 11 deletions
+11
View File
@@ -121,6 +121,17 @@ Built by `slateAdapter.mapScheduleToGameCards` (`groupPropsByPlayer` +
`mapPitchers`). The card prefers `playerStrips` (name once, horizontal) over
legacy per-prop rows. Book chips use `web/src/lib/books.js` brand colors.
**Headshots (Wave 2A/2B):** each enriched grade + player strip may carry
`playerId` (MLBAM), `espnId` (ESPN athlete id), and `headshotUrl` (a RESOLVED
absolute ESPN href). `PlayerAvatar` prefers `headshotUrl` (exact URL, never
404s) → else constructs from `(sport, playerId|espnId)` → else a team-colored
monogram. `espnId`/`headshotUrl` come from `snapshotService`: primarily the
per-player stats resolve, and — when that misses (the flaky NBA/WNBA fallback)
— from `espnAthleteIndex.buildEspnAthleteIndex(sport)`, which harvests ESPN
schedule→summary/boxscore/leaders/injuries/roster feeds (free, cached, MLB→{}).
Soccer resolves ONLY via a direct `headshotUrl` (no constructed URL); absent →
honest monogram (a free `API_FOOTBALL_KEY` is the reliable soccer path, unwired).
---
## 5. Stat Strip (`web/src/components/vyndr/StatStrip.tsx`)
+55
View File
@@ -0,0 +1,55 @@
# VYNDR — TRUTH-EVERYWHERE + OFFSEASON HUB · STEP 0 MAP
### The map before the build. Governing: accountable-before-wide; never fabricate a grade/settle/market value; absent-beats-wrong; n≥20 gate; the record includes the misses. Design ref = `specs/design-reference/vyndr-system.html` (live wordmark kept).
## THE HEADLINE FINDING (this reorders the train)
The work order assumed grades are MADE everywhere but only CHECKED for MLB. **That's not the situation. Only MLB actually GRADES in prod.** NBA/WNBA/soccer silently refuse their entire slate; combat/NFL/NHL aren't in the grade loop at all. So the record isn't "MLB wearing a platform badge because only MLB settles" — it's MLB because **only MLB produces grades in the first place.**
**The first domino is GRADING, not settlement.** You cannot settle what never grades. Wave 0 (grade-first) is not a quick gate — it is the actual unlock, and it's the highest-leverage work in the product.
## PER-SPORT GRADE → SETTLE → LEARN MATRIX
| Sport | Grades in prod? | Why | Free settle path? |
|---|---|---|---|
| **MLB** | ✅ yes | `featureCache` MLB branch → statsapi.mlb.com (free, no auth) → real l5/l20 → projection | ✅ live (`outcomeService` via statsapi last10) |
| **NBA** | ❌ refuses ALL | `featureCache` non-MLB branch → `gameLogService` hits ONLY the Python nba_api (`:8000`), **usually offline in prod, no fallback** → null logs → no projection → refuse. The espnStatsAdapter "fallback" is NOT wired into the grade path (only profile/savant). | ✅ **viable** — ESPN boxscore; `liveTrackingService.parseWnbaBoxscore` + `WNBA_BOX_KEY` ALREADY parse it |
| **WNBA** | ❌ refuses ALL | same Python-only root cause (in-season now, still refuses) | ✅ same as NBA (identical ESPN shape) |
| **Soccer** | ❌ errors THEN refuses | (1) `getOdds('soccer')` throws `Unknown sport: soccer``SPORT_KEYS` has `soccer_wc/soccer_epl…` not bare `soccer` → snapshot errors pre-grade; (2) `soccerFeatureExtractor` reads prefetch Redis keys that **nothing writes** (prefetch script unarmed) → starved → refuse | ⚠️ api-football only (free 100/day, key-gated `API_FOOTBALL_KEY`), NOT ESPN cleanly |
| **Combat/MMA** | ❌ not graded | v1 is a standalone surface; no engine branch, no projection field, not in `ACTIVE_SPORTS` | ML: ✅ free (ESPN `winner` boolean, already parsed); round-total: ❌ no free method/round feed |
| **NFL/NHL** | ❌ dormant | not in `ACTIVE_SPORTS`; no `featureCache` gameLog branch either | ✅-pattern (ESPN box, same shape) when in-season |
## REVISED WAVE PLAN (grade → settle → learn, per sport)
### WAVE 0 — GRADE-FIRST (the real unlock; do FIRST)
Make NBA/WNBA/soccer actually produce grades in prod off a FREE source.
- **NBA/WNBA:** wire a free per-game-log source into `featureCache.gameLogFeatures` (mirroring the MLB branch). **CRUX/RISK to verify first:** the source must give **per-game logs (last 5/20), not just season averages**`l5_avg`/`l20_avg` can't come from a season mean. ESPN's per-athlete gamelog endpoint (`.../athletes/{id}/gamelog`) is the candidate; confirm it returns per-game rows before building. If ESPN gamelogs work, NBA (off-season) + WNBA (in-season NOW) grade for free.
- **Soccer:** (a) fix `getOdds('soccer')` to resolve the `soccer_*` league keys; (b) arm the soccer prefetch (or wire api-football live) so `soccerFeatureExtractor` isn't starved. Soccer is the hardest (double-blocked + key-gated) — consider deferring to last.
- Combat/NFL/NHL: out of scope for grading now (no engine branch; combat stays its standalone surface).
### WAVE 1 — SETTLEMENT EVERYWHERE (once a sport grades)
The plumbing is ALREADY sport-agnostic — the ONLY gate is `defaultGetPlayerStats` (MLB-only, **duplicated in `outcomeService.js` AND `ledgerService.js` — both must change**) + a per-sport box-field map.
- **NBA/WNBA:** a game-centric settle branch — sweep yesterday's `status.state==='post'` ESPN summaries, `parseWnbaBoxscore` each (reuse verbatim), match graded players by `nameKey`. Add a WNBA/NBA `BOX_KEY` local map (mirror `MLB_LOG_FIELD`; keep the 3-map split intentional per S11).
- **MMA moneyline:** settles free from the `winner` boolean already parsed. Requires combat to first WRITE grades/locks (it's not in the snapshot loop) — smaller than a box feed. Round-total = honest gap (no free source).
- **Soccer:** api-football per-fixture stats, quota-bound + key-gated → defer with grading.
- **Observability (non-negotiable):** boot announce per sport (`[settle:nba] armed`); add the sport to `opsWatch.SETTLEABLE_SPORTS` **only with a real-finals probe** (count yesterday's ESPN `state==='post'` events for the sport) so an off-day (0 games) never false-pages "settled 0". Thrown-error paging already covers all sports.
- Records need ZERO new plumbing — `accuracy:{sport}` + `getModelAggregate` `by_tier` already flow per-sport; the Wave-3 `TierRecord` lights up automatically.
### WAVE 2 — OFFSEASON / NEVER-DARK HUB
- **News/transactions/injury wire:** injuries already ingested (`injuryService`, mlb/wnba/nba). NEWS is a gap — ESPN `/news` feed is FREE, unused. Build `newsService` (mirror `injuryService`) + `/api/news/:sport` + proxy. Signings appear as prose headlines (ESPN has no typed transactions feed — honest limitation).
- **Futures/outrights:** genuine gap (zero support today). **⚠️ PAID-QUOTA DECISION (D1 below):** The Odds API carries `outrights` on the ALREADY-paid key but each futures sport-key call spends the scarce 500/mo quota; PropLine has none. Needs new `FUTURES_KEYS` + `markets=outrights` param + a new `normalizeOutrights` branch (outrights have `name`+`price`, no `point` — can't reuse `normalizeProps`) + long TTL/offseason gating.
- **Line-movement on futures:** high reuse — `computeLineDeltas`/`trackHistory`/`signedDelta` port cleanly with `line``odds` (price). News→move causal tie ("signing → win total +2.5") is NEW small join code.
- **Render:** extend `/explore` (`ExploreHub`) + the offseason-aware `outlook.js`/`emptyState.js`; **reskin the retired `TerminalTemplates` injury-wire/leader LAYOUTS with REAL data** (they're SAMPLE data today — never route as-is). New `/futures` route only if wanted.
### WAVE 3 — FUTURES HONESTY LINE
Season-long grading is a different model (projection+roster+schedule, not last-5). The hub SHOWS + TRACKS futures + news + movement honestly, and GRADES only where the model genuinely supports it — "tracked, not graded" everywhere else. The deliberate futures-grading model is a LATER train.
## DECISIONS — LOCKED (2026-07-13)
- **D1 = BUILD FUTURES, quota-disciplined.** Separate `FUTURES_KEYS` path, 612h/daily TTL, offseason-month gated, never touches the daily player-prop budget.
- **D2 = DEFER SOCCER to the end / its own follow-up.** NBA/WNBA are the free high-value wins first; soccer wired when `API_FOOTBALL_KEY` is provided.
- **D3 = ANSWERED (green).** ESPN per-athlete gamelog verified live: NBA returns 73 per-game rows + stat labels; WNBA endpoint 200. Free per-game logs exist → NBA/WNBA can grade. Endpoint: `site.web.api.espn.com/apis/common/v3/sports/basketball/{nba|wnba}/athletes/{id}/gamelog``events[]` + `labels`/`names`.
## ORIGINAL DECISION RECOMMENDATIONS (superseded by LOCKED above)
- **D1 — Futures quota.** Futures spend the already-paid odds-api 500/mo quota, competing with game-prop credits. **Rec: YES but disciplined — futures move slowly, so a 612h/daily TTL + offseason-month gating keeps the cost tiny; wire them on a separate `FUTURES_KEYS` path so they never touch the daily player-prop budget.** (Alt: skip futures, ship only the free news wire + line-movement on existing markets.)
- **D2 — Soccer.** Grading + settling soccer both need `API_FOOTBALL_KEY` (free tier, 100/day) which YOU provision (I can't create accounts/keys), plus arming the prefetch. **Rec: defer soccer to the END of the train (or its own follow-up) — it's the only double-blocked, key-gated sport; NBA/WNBA are the high-value free wins. Provide the key when convenient and I'll wire it.**
- **D3 — NBA/WNBA grade source.** Wave 0 hinges on a FREE per-game-log feed. **Rec: verify ESPN's per-athlete gamelog endpoint returns per-game rows FIRST (a spike), before building the featureCache branch — if ESPN only gives season averages, we need another free source or NBA/WNBA can't honestly grade yet.** This is the single technical risk of the whole train; I'll de-risk it in Step 0.5.
## SEQUENCE
Wave 0 (NBA/WNBA grade via ESPN gamelogs — verify the feed first) → Wave 1 (NBA/WNBA settle via existing box parser + observability; MMA moneyline) → Wave 2 (news wire free + futures per D1) → Wave 3 (futures honesty). Soccer deferred per D2. Each wave: green → merge → deploy → fingerprint → report.
+181 -1
View File
@@ -15,10 +15,16 @@
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { nameKey } = require('../../utils/playerName');
const SEARCH = 'https://site.web.api.espn.com/apis/common/v3/search';
// Wave 0 — the v3 /search endpoint above now returns count:0 for every query
// (verified live 2026-07-13); the v2 search below is the reliable resolver and
// carries the numeric athlete id inside its `uid` ("s:40~l:59~a:3149391").
const SEARCH_V2 = 'https://site.api.espn.com/apis/search/v2';
const SPORT_PATH = { nba: 'basketball/nba', wnba: 'basketball/wnba' };
const TTL = 6 * 3600;
const GAMELOG_TTL = 4 * 3600; // per-game logs refresh once per night
const TIMEOUT = 10_000;
// ESPN stat label → our classifier-input key. Lowercased, punctuation-stripped.
@@ -118,4 +124,178 @@ async function getSeasonAverages(name, sport, opts = {}) {
}
}
module.exports = { getSeasonAverages, parseAthleteStats, __internals: { STAT_MAP, keyify, SPORT_PATH } };
// ─────────────────────────────────────────────────────────────────────────
// Wave 0 — FREE per-game logs for NBA/WNBA (the grade unlock).
//
// The Python nba_api service (gameLogService) is offline in prod, so
// featureCache's non-MLB branch produced no l5/l20 → projectionFor returned
// null → the ENTIRE NBA/WNBA slate refused. ESPN's per-athlete gamelog is free,
// no-auth, and returns per-game rows (VERIFIED LIVE: NBA id 1966 → 73 rows,
// WNBA id 3149391 → 23 rows). This mirrors mlbStatsAdapter.getPlayerGameLog's
// output contract ({ found, id, last10:[{ date, opponent, isHome, stat:{…} }] })
// so it drops straight into the existing feature pipeline.
//
// The per-game `stats[]` array is aligned to the response's OWN `names[]`
// array — and that order DIFFERS between NBA and WNBA — so columns are indexed
// by name token, never by a hardcoded position.
// ─────────────────────────────────────────────────────────────────────────
// ESPN `names[]` token → our per-game stat field. Compound cells ("9-23" =
// made-attempted) take the MADE portion. pra is COMPUTED (pts+reb+ast), never
// a raw column.
function toNum(v) {
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
function madeOf(v) {
if (v == null) return null;
const s = String(v);
const dash = s.indexOf('-');
const n = parseInt(dash >= 0 ? s.slice(0, dash) : s, 10);
return Number.isFinite(n) ? n : null;
}
function buildGameStat(statArr, idxOf) {
if (!Array.isArray(statArr)) return null;
const at = (token, fn = toNum) => {
const i = idxOf[token];
return i == null ? null : fn(statArr[i]);
};
const out = {};
const points = at('points');
const rebounds = at('totalRebounds');
const assists = at('assists');
const threes = at('threePointFieldGoalsMade-threePointFieldGoalsAttempted', madeOf);
const steals = at('steals');
const blocks = at('blocks');
const turnovers = at('turnovers');
const minutes = at('minutes');
// Absent beats zero — only attach a field ESPN actually reported.
if (points != null) out.points = points;
if (rebounds != null) out.rebounds = rebounds;
if (assists != null) out.assists = assists;
if (threes != null) out.threes = threes;
if (steals != null) out.steals = steals;
if (blocks != null) out.blocks = blocks;
if (turnovers != null) out.turnovers = turnovers;
if (minutes != null) out.minutes = minutes;
if (points != null && rebounds != null && assists != null) out.pra = points + rebounds + assists;
return Object.keys(out).length ? out : null;
}
/**
* PURE: parse an ESPN athlete-gamelog payload into per-game rows, most-recent
* first. Returns an array or null (unrecognized shape). NEVER throws.
*/
function parseGameLog(payload) {
if (!payload || typeof payload !== 'object') return null;
const names = Array.isArray(payload.names) ? payload.names : null;
if (!names || names.length === 0) return null;
const idxOf = {};
names.forEach((n, i) => { if (idxOf[String(n)] == null) idxOf[String(n)] = i; });
const evMap = (payload.events && typeof payload.events === 'object') ? payload.events : {};
const seen = new Set();
const rows = [];
for (const st of Array.isArray(payload.seasonTypes) ? payload.seasonTypes : []) {
for (const cat of (st && Array.isArray(st.categories)) ? st.categories : []) {
for (const ev of (cat && Array.isArray(cat.events)) ? cat.events : []) {
const eid = ev && ev.eventId;
if (!eid || seen.has(eid)) continue;
const stat = buildGameStat(ev.stats, idxOf);
if (!stat) continue;
seen.add(eid);
const meta = evMap[eid] || {};
const isHome = meta.atVs === 'vs' ? true : (meta.atVs === '@' ? false : null);
rows.push({
date: meta.gameDate || null,
opponent: (meta.opponent && (meta.opponent.abbreviation || meta.opponent.displayName)) || null,
isHome,
stat,
});
}
}
}
if (rows.length === 0) return null;
// Most-recent first. Undated rows sink to the end without reordering.
rows.sort((a, b) => {
const ta = a.date ? Date.parse(a.date) : NaN;
const tb = b.date ? Date.parse(b.date) : NaN;
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return tb - ta;
});
return rows.slice(0, 20);
}
async function fetchJsonG(url, opts = {}) {
if (typeof opts.fetchImpl === 'function') return opts.fetchImpl(url);
const client = opts.http || axios;
const res = await client.get(url, { timeout: TIMEOUT });
return res && res.data;
}
/**
* Resolve name → ESPN numeric athlete id for a basketball league via the v2
* search. Disambiguates by `defaultLeagueSlug` (nba/wnba — an NCAA namesake is
* NOT returned for a WNBA query), then prefers an exact canonical-name match.
* Returns the numeric id string or null (a missing id beats the wrong player).
*/
async function resolveAthleteId(name, sport, opts = {}) {
const data = await fetchJsonG(`${SEARCH_V2}?query=${encodeURIComponent(name)}&limit=10`, opts);
const section = (data && Array.isArray(data.results) ? data.results : []).find((r) => r && r.type === 'player');
const players = (section && Array.isArray(section.contents)) ? section.contents : [];
if (players.length === 0) return null;
const inLeague = players.filter((p) => String(p.defaultLeagueSlug || '').toLowerCase() === sport);
const pool = inLeague.length ? inLeague : players;
const target = nameKey(name);
const exact = pool.find((p) => nameKey(p.displayName || p.name || '') === target);
const chosen = exact || pool[0];
if (!chosen) return null;
const m = /a:(\d+)/.exec(String(chosen.uid || ''));
if (m) return m[1];
return /^\d+$/.test(String(chosen.id)) ? String(chosen.id) : null;
}
const gameLogMem = new Map();
/**
* NBA/WNBA per-game logs from ESPN. Returns { found, id, last10 } (most-recent
* first) or { found:false }. Never throws. Cached (Redis `espngamelog:{sport}:
* {id}` 4h + in-memory mirror). opts.fetchImpl/opts.http injectable for tests.
*/
async function getPlayerGameLog(name, sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
const path = SPORT_PATH[sp];
if (!path || !name) return { found: false };
try {
const id = await resolveAthleteId(name, sp, opts);
if (!id) return { found: false };
const ck = `espngamelog:${sp}:${id}`;
if (gameLogMem.has(ck)) return gameLogMem.get(ck);
try {
const cached = await cacheGet(ck);
if (cached) { gameLogMem.set(ck, cached); return cached; }
} catch { /* ignore cache read */ }
const payload = await fetchJsonG(`https://site.web.api.espn.com/apis/common/v3/sports/${path}/athletes/${id}/gamelog`, opts);
const last10 = parseGameLog(payload);
if (!last10) return { found: false, id };
const result = { found: true, id, last10 };
gameLogMem.set(ck, result);
try { await cacheSet(ck, result, GAMELOG_TTL); } catch { /* ignore cache write */ }
return result;
} catch (err) {
console.warn('[espnStats] game log failed:', name, sp, err.message);
return { found: false };
}
}
module.exports = {
getSeasonAverages,
parseAthleteStats,
getPlayerGameLog,
parseGameLog,
resolveAthleteId,
__internals: { STAT_MAP, keyify, SPORT_PATH, buildGameStat, madeOf, toNum, gameLogMem },
};
+180
View File
@@ -0,0 +1,180 @@
"use strict";
/**
* espnAthleteIndex — Wave 2B: reliable ESPN athlete-id + DIRECT headshot capture
* from feeds VYNDR already calls for a slate.
*
* THE GAP it closes: NBA/WNBA `espnId` was captured ONLY from
* `espnStatsAdapter.getSeasonAverages` (the offline-Python fallback), which is
* unreliable in prod. ESPN's own summary / boxscore / leaders / injuries / roster
* payloads carry, per athlete, `athlete.id` AND often a direct
* `athlete.headshot.href` (the exact image URL). This harvests both into a
* { nameKey -> { espnId, headshotHref } }
* index — reusing `scheduleService.getSchedule` + `getGameSummary` (no new
* endpoint), bounded (mapLimit), cached per sport+date, and NEVER throwing.
*
* Doctrine (unchanged): a REAL photo where ESPN gives an id/href; a
* team-colored monogram where it can't. Nothing here fabricates a face. MLB's
* MLBAM path is untouched (this returns {} for MLB — MLB owns its own id).
*
* A direct `headshotHref` is PREFERRED over a constructed `(sport,id)` URL: it
* is the exact URL ESPN serves, so it never 404s on a league whose CDN path we
* would otherwise guess. This is the ONLY honest route for soccer (ESPN soccer
* headshots are inconsistent → we trust a direct href only, never a constructed
* soccer URL). All parsing is pure + defensive so a malformed shape yields {}.
*/
const { nameKey } = require('../utils/playerName');
const INDEX_TTL = 3600; // 1h — an athlete's id/href is stable within a day
const GAME_CONCURRENCY = 4; // bounded per-game summary fan-out
const MAX_DEPTH = 8; // recursion guard on the ESPN payload walk
// Sports whose headshots ESPN hosts by athlete id (a.espncdn CDN). MLB is
// deliberately excluded (its MLBAM path owns headshots). Soccer is included
// best-effort: ESPN soccer headshots are inconsistent, so only a DIRECT href is
// trusted downstream — snapshotService never constructs a soccer URL from an id.
const ESPN_INDEX_SPORTS = new Set(['nba', 'wnba', 'nfl', 'nhl', 'soccer']);
// Athlete-specific markers that distinguish an athlete object from a TEAM object
// (teams also carry displayName + numeric id, but never these). Guards the
// recursive harvest from tagging a team name as a player.
const ATHLETE_MARKERS = ['headshot', 'position', 'jersey', 'guid'];
function isNumericId(v) {
return v != null && /^\d+$/.test(String(v));
}
/** Pull a direct absolute headshot URL from an athlete node, else null. */
function extractHeadshotHref(node) {
const h = node && node.headshot;
if (!h) return null;
if (typeof h === 'string') return /^https?:\/\//i.test(h) ? h : null;
if (typeof h === 'object' && typeof h.href === 'string' && /^https?:\/\//i.test(h.href)) return h.href;
return null;
}
function looksLikeAthlete(o) {
if (!o || typeof o !== 'object') return false;
if (!(o.displayName || o.fullName)) return false;
return ATHLETE_MARKERS.some((m) => o[m] != null);
}
/** Record one athlete into the index, preferring the richest data on collision. */
function recordAthlete(out, athlete) {
if (!athlete || typeof athlete !== 'object') return;
const name = athlete.displayName || athlete.fullName || athlete.name;
if (!name || typeof name !== 'string') return;
const espnId = isNumericId(athlete.id) ? String(athlete.id) : null;
const headshotHref = extractHeadshotHref(athlete);
if (!espnId && !headshotHref) return; // nothing useful — absent beats noise
const key = nameKey(name);
if (!key) return;
const existing = out[key];
if (!existing) {
out[key] = { espnId: espnId || null, headshotHref: headshotHref || null };
return;
}
// A direct href is the reliable asset — fill it if a later source has one.
if (headshotHref && !existing.headshotHref) existing.headshotHref = headshotHref;
if (espnId && !existing.espnId) existing.espnId = espnId;
}
/**
* PURE: walk any ESPN payload (summary, boxscore, leaders, injuries, roster)
* and harvest every athlete-like object into { nameKey -> { espnId, headshotHref } }.
* Records ONLY explicit `.athlete` wrappers or objects carrying an athlete
* marker (never bare team objects). Defensive: a malformed shape yields {} and
* NEVER throws.
*/
function harvestAthletes(payload, out, depth, seen) {
out = out || {};
depth = depth || 0;
seen = seen || new Set();
if (payload == null || depth > MAX_DEPTH) return out;
if (Array.isArray(payload)) {
for (const item of payload) harvestAthletes(item, out, depth + 1, seen);
return out;
}
if (typeof payload !== 'object') return out;
if (seen.has(payload)) return out;
seen.add(payload);
// Canonical ESPN wrapper: { athlete: {...}, stats|value|... }.
if (payload.athlete && typeof payload.athlete === 'object') recordAthlete(out, payload.athlete);
// A bare athlete object surfaced directly (roster/injury shapes vary).
else if (looksLikeAthlete(payload)) recordAthlete(out, payload);
for (const k of Object.keys(payload)) {
const v = payload[k];
if (v && typeof v === 'object') harvestAthletes(v, out, depth + 1, seen);
}
return out;
}
async function mapLimit(items, concurrency, fn) {
let i = 0;
async function worker() {
while (i < items.length) {
const idx = i++;
// eslint-disable-next-line no-await-in-loop
await fn(items[idx], idx);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length || 1) }, worker));
}
/** Look up an athlete's { espnId, headshotHref } by (possibly raw) name. */
function lookup(index, name) {
if (!index || typeof index !== 'object') return null;
const k = nameKey(name || '');
return (k && index[k]) || null;
}
/**
* Build the per-sport { nameKey -> { espnId, headshotHref } } index from the
* ESPN feeds the pipeline already fetches. Cached (`espnindex:{sport}:{date}`,
* 1h). Returns {} for MLB and for any error (never throws). All deps injectable
* → unit tests hit no network.
*/
async function buildEspnAthleteIndex(sport, opts = {}) {
const sp = String(sport || '').toLowerCase();
if (!ESPN_INDEX_SPORTS.has(sp)) return {};
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
const cacheSet = opts.cacheSet || require('../utils/redis').cacheSet;
const sched = require('./scheduleService');
const getSchedule = opts.getSchedule || sched.getSchedule;
const getGameSummary = opts.getGameSummary || sched.getGameSummary;
const date = opts.date || sched.todayET();
const key = `espnindex:${sp}:${date}`;
try {
const cached = await cacheGet(key);
if (cached && typeof cached === 'object') return cached;
} catch { /* ignore cache read */ }
const out = {};
try {
const games = await getSchedule(sp, date).catch(() => null);
const ids = Array.isArray(games) ? games.map((g) => g && g.id).filter(Boolean) : [];
await mapLimit(ids, GAME_CONCURRENCY, async (id) => {
try {
const summary = await getGameSummary(sp, id);
harvestAthletes(summary, out);
} catch { /* one game failing must not sink the index */ }
});
try { await cacheSet(key, out, INDEX_TTL); } catch { /* ignore cache write */ }
} catch {
return {}; // total failure → empty → every player falls to a monogram
}
return out;
}
module.exports = {
buildEspnAthleteIndex,
harvestAthletes,
lookup,
extractHeadshotHref,
__internals: { recordAthlete, looksLikeAthlete, isNumericId, ESPN_INDEX_SPORTS, INDEX_TTL },
};
+77
View File
@@ -93,6 +93,22 @@ function mlbStatValue(statObj, statType) {
return Number.isFinite(n) ? n : null;
}
// Wave 0 — NBA/WNBA stat_type → the per-game field on an espnStatsAdapter
// gamelog row's `stat` object. A SEPARATE local map from MLB_LOG_FIELD (the
// S11 three-map-split rule — never merge). Combo stats route to statFromGameLog
// (which sums points/rebounds/assists at read time); `pra`/`threes` are already
// normalized fields on the row. A stat_type absent here does NOT grade via this
// path (absent beats a fabricated projection). Add a new NBA/WNBA stat here to
// unlock it.
const NBA_LOG_FIELD = {
points: 'points', rebounds: 'rebounds', assists: 'assists',
threes: 'threes', steals: 'steals', blocks: 'blocks', turnovers: 'turnovers',
pra: 'pra',
// combos (statFromGameLog sums the raw components)
pts_reb_ast: 'pts_reb_ast', pts_reb: 'pts_reb', pts_ast: 'pts_ast',
reb_ast: 'reb_ast', stl_blk: 'stl_blk',
};
/**
* Session 46 — derive recent/season averages from a real MLB game log
* (mlbStatsAdapter.getPlayerStats result). PURE so it's unit-testable without
@@ -140,6 +156,49 @@ function mlbGameLogFeatures(res, statType) {
return out;
}
/**
* Wave 0 — derive recent/season averages from an ESPN NBA/WNBA gamelog
* (espnStatsAdapter.getPlayerGameLog result). PURE + unit-testable. The result's
* last10 is MOST-RECENT-FIRST, so l5 = first 5, l20 = all available (the season
* per-game reference projectionFor needs). Emits the SAME fields the Python
* path did, plus rest_days + minutes_per_game (usage), so the grade card lights
* up. Returns {} when the log is missing or the stat_type isn't mapped.
*/
function nbaGameLogFeatures(res, statType) {
if (!res || !res.found) return {};
const field = NBA_LOG_FIELD[statType];
if (!field) return {};
const logs = Array.isArray(res.last10) ? res.last10 : [];
const vals = logs.map((g) => statFromGameLog(g && g.stat, field)).filter((v) => v != null);
const out = {};
if (vals.length) {
const m5 = avg(vals.slice(0, 5)); // most-recent first
const m10 = avg(vals.slice(0, 10));
const m20 = avg(vals.slice(0, 20));
const s10 = stddev(vals.slice(0, 10));
if (m5 != null) out.l5_avg = m5;
if (m10 != null) out.l10_avg = m10;
if (m20 != null) out.l20_avg = m20;
if (s10 != null) out.l10_stddev = s10;
}
// rest_days from the two most-recent dated games (0 = back-to-back), mirroring
// the MLB branch + the NBA convention buildIntelFields reads.
const dated = logs.filter((g) => g && g.date);
if (dated.length >= 2) {
const last = new Date(dated[0].date).getTime(); // most recent
const prev = new Date(dated[1].date).getTime();
const gap = Math.round((last - prev) / 86_400_000);
if (Number.isFinite(gap) && gap >= 1 && gap <= 14) out.rest_days = gap - 1;
}
// minutes_per_game — the NBA "usage" equivalent buildIntelFields surfaces.
const mins = logs.map((g) => g && g.stat && Number(g.stat.minutes)).filter((v) => Number.isFinite(v));
const mpg = avg(mins);
if (mpg != null) out.minutes_per_game = mpg;
return out;
}
async function gameLogFeatures(playerName, sport, statType) {
// MLB game logs come from the FREE statsapi.mlb.com (Session 46) — the Python
// gameLogService only covers NBA/WNBA, so MLB props had no recent/season
@@ -156,6 +215,22 @@ async function gameLogFeatures(playerName, sport, statType) {
}
const logs = await gameLogs.getGameLogs(playerName, sport, 20);
// Wave 0 — NBA/WNBA grade unlock. The Python nba_api service (gameLogService)
// is offline in prod, so `logs` is null and this branch used to return {} →
// no l5/l20 → projectionFor null → the ENTIRE slate refused. Fall back to the
// FREE ESPN per-athlete gamelog so NBA (off-season) + WNBA (in-season) grade.
if ((!logs || logs.length === 0) && (sport === 'nba' || sport === 'wnba')) {
try {
const espnStats = require('../adapters/espnStatsAdapter');
const res = await espnStats.getPlayerGameLog(playerName, sport);
return nbaGameLogFeatures(res, statType);
} catch (e) {
console.warn('[featureCache] ESPN game-log fallback failed:', e.message);
return {};
}
}
if (!logs || logs.length === 0) return {};
const valuesAll = logs.map((row) => statFromGameLog(row, statType)).filter((v) => v != null);
@@ -341,6 +416,8 @@ module.exports = {
statFromGameLog,
mlbGameLogFeatures,
mlbStatValue,
nbaGameLogFeatures,
NBA_LOG_FIELD,
avg,
stddev,
daysBetween,
+5 -1
View File
@@ -227,7 +227,7 @@ const SUMMARY_TTL = 10 * 60; // 10 min
*/
async function getGameSummary(sport, eventId) {
const path = ESPN_SPORT_PATHS[String(sport || '').toLowerCase()];
const empty = { injuries: [], odds: [], ats: null, leaders: [], boxscore: null };
const empty = { injuries: [], odds: [], ats: null, leaders: [], boxscore: null, rosters: [] };
if (!path || !eventId) return empty;
const key = `espn:summary:${sport}:${eventId}`;
@@ -244,6 +244,10 @@ async function getGameSummary(sport, eventId) {
ats: data.againstTheSpread || null,
leaders: Array.isArray(data.leaders) ? data.leaders : [],
boxscore: data.boxscore || null,
// Wave 2B — pre-game lineups carry athlete id + direct headshot href, the
// reliable NBA/WNBA headshot source (espnAthleteIndex harvests it). Passed
// through defensively; absent → [] (no fabricated roster).
rosters: Array.isArray(data.rosters) ? data.rosters : [],
};
await cacheSet(key, out, SUMMARY_TTL);
return out;
+28
View File
@@ -221,6 +221,10 @@ async function runSnapshot(sport, opts = {}) {
// Session 58 — Phase 1 truth infrastructure. ledger no-ops without
// SUPABASE env, so tests / local dev never touch a database.
ledger: opts.ledger || require('./ledgerService'),
// Wave 2B — reliable ESPN athlete id + DIRECT headshot href from feeds the
// pipeline already calls (schedule + summary). Fills the NBA/WNBA espnId gap
// when the stats-resolve fallback misses. Returns {} for MLB / errors.
buildEspnIndex: opts.buildEspnIndex || require('./espnAthleteIndex').buildEspnAthleteIndex,
};
const start = deps.nowMs();
const ts = deps.now();
@@ -351,6 +355,27 @@ async function runSnapshot(sport, opts = {}) {
});
await mergeRosterLogs(sp, logEntries, deps);
// Wave 2B — the RELIABLE espnId/headshot source. The stats-resolve espnId
// above comes only from espnStatsAdapter (the offline-Python fallback), which
// is flaky in prod. ESPN's own schedule→summary feeds (already free, already
// called elsewhere) carry each athlete's id AND often a DIRECT headshot href.
// Build the index once per snapshot (MLB → {} so its MLBAM path is untouched)
// and fill any player the primary resolve left without an id. A direct href is
// preferred — it's the exact URL, so it never 404s on a constructed path.
let espnIndex = {};
try {
espnIndex = (await deps.buildEspnIndex(sp, { cacheGet: deps.cacheGet, cacheSet: deps.cacheSet })) || {};
} catch { espnIndex = {}; /* graceful — every player falls to a monogram */ }
const headshotUrlByPlayer = {};
for (const player of players) {
const entry = espnIndex[nameKey(player)];
if (!entry) continue;
if (espnIdByPlayer[player] == null && entry.espnId != null) espnIdByPlayer[player] = entry.espnId;
// A direct ESPN href wins over any constructed URL (most reliable; the only
// honest route for soccer, where we never construct an id-based URL).
if (entry.headshotHref) headshotUrlByPlayer[player] = entry.headshotHref;
}
const enriched = graded.map((g) => {
const pn = g.player || g.player_name;
return {
@@ -362,6 +387,9 @@ async function runSnapshot(sport, opts = {}) {
// from the stats resolve above. Absent → PlayerAvatar renders a monogram.
playerId: playerIdByPlayer[pn] ?? g.playerId ?? null,
espnId: espnIdByPlayer[pn] ?? g.espnId ?? null,
// Wave 2B — a RESOLVED absolute headshot URL from ESPN (preferred over the
// constructed (sport,id) URL). Absent → the id/monogram path stands.
headshotUrl: headshotUrlByPlayer[pn] ?? g.headshotUrl ?? null,
};
});
+179
View File
@@ -0,0 +1,179 @@
// Wave 0 — ESPN per-athlete gamelog parser + resolver (the NBA/WNBA grade
// unlock's free source). Hermetic: fetch is injected, no network.
// Redis is a no-op here so the cache read/write paths don't touch a live server.
jest.mock('../../src/utils/redis', () => ({
cacheGet: async () => null,
cacheSet: async () => {},
cacheDel: async () => {},
}));
const espn = require('../../src/services/adapters/espnStatsAdapter');
// Real ESPN NBA gamelog shape (verified live 2026-07-13): a per-response
// `names[]` array + seasonTypes[].categories[].events[{eventId, stats[]}], with
// dates/opponents in a top-level `events` map keyed by eventId. Column order is
// indexed by name token — NBA and WNBA differ, so this is not positional.
const NBA_NAMES = [
'minutes', 'fieldGoalsMade-fieldGoalsAttempted', 'fieldGoalPct',
'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'threePointPct',
'freeThrowsMade-freeThrowsAttempted', 'freeThrowPct', 'totalRebounds',
'assists', 'blocks', 'steals', 'fouls', 'turnovers', 'points',
];
// min FG FG% 3PT 3P% FT FT% REB AST BLK STL PF TO PTS
const G_OLD = ['32', '9-19', '47.4', '1-4', '25.0', '4-4', '100', '8', '5', '0', '2', '3', '2', '23'];
const G_MID = ['36', '10-20', '50.0', '3-7', '42.9', '5-6', '83.3', '10', '7', '1', '1', '2', '4', '28'];
const G_NEW = ['40', '8-18', '44.4', '2-6', '33.3', '6-8', '75.0', '12', '3', '1', '0', '1', '4', '24'];
const NBA_FIXTURE = {
names: NBA_NAMES,
labels: ['MIN', 'FG', 'FG%', '3PT', '3P%', 'FT', 'FT%', 'REB', 'AST', 'BLK', 'STL', 'PF', 'TO', 'PTS'],
seasonTypes: [
{
displayName: '2025-26 Regular Season',
categories: [
{
displayName: 'January',
events: [
{ eventId: 'E_OLD', stats: G_OLD },
{ eventId: 'E_MID', stats: G_MID },
{ eventId: 'E_NEW', stats: G_NEW },
],
},
],
},
],
events: {
E_OLD: { id: 'E_OLD', gameDate: '2026-01-01T00:30:00.000+00:00', atVs: '@', opponent: { abbreviation: 'BOS' } },
E_MID: { id: 'E_MID', gameDate: '2026-01-03T00:30:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'GSW' } },
E_NEW: { id: 'E_NEW', gameDate: '2026-01-05T00:30:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'HOU' } },
},
};
const V2_SEARCH = {
results: [
{
type: 'player',
contents: [
{ uid: 's:40~l:46~a:1966', id: 'guid-abc', displayName: 'LeBron James', sport: 'basketball', defaultLeagueSlug: 'nba' },
],
},
],
};
function fetchImplFor(searchPayload, gamelogPayload) {
return async (url) => {
if (url.includes('/search/v2')) return searchPayload;
if (url.includes('/gamelog')) return gamelogPayload;
return null;
};
}
beforeEach(() => espn.__internals.gameLogMem.clear());
describe('parseGameLog (pure)', () => {
it('normalizes per-game rows keyed by VYNDR stat names, most-recent first', () => {
const rows = espn.parseGameLog(NBA_FIXTURE);
expect(Array.isArray(rows)).toBe(true);
expect(rows).toHaveLength(3);
// most-recent first → E_NEW leads
expect(rows[0].date).toBe('2026-01-05T00:30:00.000+00:00');
expect(rows[0].opponent).toBe('HOU');
expect(rows[0].isHome).toBe(true);
expect(rows[0].stat).toMatchObject({
points: 24, rebounds: 12, assists: 3, threes: 2, steals: 0, blocks: 1, turnovers: 4, minutes: 40,
});
// pra computed, not a raw column
expect(rows[0].stat.pra).toBe(24 + 12 + 3);
// oldest last, away game
expect(rows[2].date).toBe('2026-01-01T00:30:00.000+00:00');
expect(rows[2].isHome).toBe(false);
});
it('indexes columns by the response names[] (WNBA order differs from NBA)', () => {
const wnbaNames = [
'minutes', 'points', 'totalRebounds', 'assists', 'steals', 'blocks', 'turnovers',
'fieldGoalsMade-fieldGoalsAttempted', 'fieldGoalPct',
'threePointFieldGoalsMade-threePointFieldGoalsAttempted', 'threePointPct',
'freeThrowsMade-freeThrowsAttempted', 'freeThrowPct', 'fouls',
];
// min PTS REB AST STL BLK TO FG FG% 3PT 3P% FT FT% PF
const row = ['31', '20', '12', '2', '1', '2', '3', '9-23', '39.1', '2-5', '40.0', '2-2', '100.0', '2'];
const wnbaFixture = {
names: wnbaNames,
seasonTypes: [{ categories: [{ events: [{ eventId: 'W1', stats: row }] }] }],
events: { W1: { gameDate: '2026-07-13T01:00:00.000+00:00', atVs: 'vs', opponent: { abbreviation: 'IND' } } },
};
const rows = espn.parseGameLog(wnbaFixture);
expect(rows).toHaveLength(1);
expect(rows[0].stat).toMatchObject({ points: 20, rebounds: 12, assists: 2, steals: 1, blocks: 2, turnovers: 3, threes: 2 });
});
it('omits a stat ESPN did not report (absent, never 0)', () => {
// Drop the points column entirely.
const noPtsNames = NBA_NAMES.slice(0, -1); // remove 'points'
const fixture = {
names: noPtsNames,
seasonTypes: [{ categories: [{ events: [{ eventId: 'X', stats: G_NEW.slice(0, -1) }] }] }],
events: { X: { gameDate: '2026-01-05T00:30:00.000+00:00' } },
};
const rows = espn.parseGameLog(fixture);
expect(rows[0].stat).not.toHaveProperty('points');
expect(rows[0].stat).not.toHaveProperty('pra'); // pra needs all three → absent
expect(rows[0].stat.rebounds).toBe(12);
});
it('returns null on an unrecognized shape and never throws', () => {
expect(espn.parseGameLog(null)).toBeNull();
expect(espn.parseGameLog({})).toBeNull();
expect(espn.parseGameLog({ names: [], seasonTypes: [] })).toBeNull();
expect(espn.parseGameLog({ names: NBA_NAMES, seasonTypes: [] })).toBeNull();
expect(() => espn.parseGameLog({ names: NBA_NAMES, seasonTypes: 'bad', events: 5 })).not.toThrow();
});
});
describe('resolveAthleteId', () => {
it('extracts the numeric athlete id from the v2 uid', async () => {
const id = await espn.resolveAthleteId('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, NBA_FIXTURE) });
expect(id).toBe('1966');
});
it('disambiguates by league (a WNBA query never returns the NCAA namesake)', async () => {
const dual = {
results: [{
type: 'player',
contents: [
{ uid: 's:40~l:59~a:3149391', displayName: "A'ja Wilson", defaultLeagueSlug: 'wnba' },
{ uid: 's:40~l:54~a:4412077', displayName: "A'Ja Wilson", defaultLeagueSlug: 'womens-college-basketball' },
],
}],
};
const id = await espn.resolveAthleteId("A'ja Wilson", 'wnba', { fetchImpl: fetchImplFor(dual, NBA_FIXTURE) });
expect(id).toBe('3149391');
});
it('returns null when no player section matches', async () => {
const id = await espn.resolveAthleteId('Nobody', 'nba', { fetchImpl: fetchImplFor({ results: [] }, NBA_FIXTURE) });
expect(id).toBeNull();
});
});
describe('getPlayerGameLog', () => {
it('resolves → fetches → normalizes into { found, id, last10 }', async () => {
const res = await espn.getPlayerGameLog('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, NBA_FIXTURE) });
expect(res.found).toBe(true);
expect(res.id).toBe('1966');
expect(res.last10).toHaveLength(3);
expect(res.last10[0].stat.points).toBe(24);
});
it('returns { found:false } for an unknown sport or empty name (no throw)', async () => {
await expect(espn.getPlayerGameLog('X', 'nfl')).resolves.toEqual({ found: false });
await expect(espn.getPlayerGameLog('', 'nba')).resolves.toEqual({ found: false });
});
it('returns { found:false } when the gamelog shape is unrecognized', async () => {
const res = await espn.getPlayerGameLog('LeBron James', 'nba', { fetchImpl: fetchImplFor(V2_SEARCH, { garbage: true }) });
expect(res.found).toBe(false);
});
});
+204
View File
@@ -0,0 +1,204 @@
// Wave 2B (WIRING & DATA TRAIN, Step 2 follow-up) — the ESPN athlete index.
//
// THE GAP: NBA/WNBA `espnId` was captured ONLY from espnStatsAdapter (the
// offline-Python fallback), unreliable in prod. ESPN's own summary/boxscore/
// leaders/injuries/roster payloads — already free, already called for a slate —
// carry each athlete's id AND often a DIRECT headshot href. This suite locks:
// (a) an ESPN summary fixture → { nameKey → { espnId, headshotHref } } index
// (b) a grade with NO stats-espnId gets the id/href from the index by nameKey
// (c) a direct headshotHref WINS over the constructed (sport,id) URL
// (d) soccer with no reliable href → absent → monogram (honest)
// (e) a malformed ESPN shape → empty index, never a throw
// NO network — every dep is injected.
const { nameKey } = require('../../src/utils/playerName');
const idx = require('../../src/services/espnAthleteIndex');
const svc = require('../../src/services/snapshotService');
const adapter = require('../../web/src/lib/slateAdapter');
const { getHeadshotUrl } = require('../../web/src/lib/playerHeadshotUrl');
function memCache() {
const store = {};
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
}
// A realistic ESPN summary: athletes surface via injuries/leaders/boxscore/
// roster wrappers; a bare TEAM object must NOT be harvested as a player.
const HREF = (id) => `https://a.espncdn.com/i/headshots/wnba/players/full/${id}.png`;
const summaryFixture = {
injuries: [
{ team: { displayName: 'Indiana Fever' }, injuries: [
{ status: 'OUT', athlete: { id: '4433403', displayName: 'Caitlin Clark', headshot: { href: HREF('4433403') } } },
] },
],
leaders: [
{ leaders: [ { leaders: [
{ athlete: { id: '2529140', displayName: 'Kelsey Mitchell', headshot: { href: HREF('2529140') } }, value: 21 },
] } ] },
],
boxscore: { players: [
{ statistics: [ { athletes: [
{ athlete: { id: '3906972', displayName: 'Aliyah Boston', headshot: { href: HREF('3906972') } }, stats: ['12'] },
] } ] },
] },
rosters: [
// headshot as a bare string (ESPN varies the shape) + no numeric id-only team.
{ roster: [ { athlete: { id: '4281190', displayName: 'Sophie Cunningham', headshot: HREF('4281190') } } ] },
],
// A team object (numeric id + displayName, NO athlete marker) — must be ignored.
teams: [ { team: { id: '5', displayName: 'Indiana Fever' } } ],
};
describe('(a) harvestAthletes — ESPN summary → { nameKey → {espnId, headshotHref} }', () => {
it('pulls athletes from injuries, leaders, boxscore, and roster wrappers', () => {
const index = idx.harvestAthletes(summaryFixture);
expect(index[nameKey('Caitlin Clark')]).toEqual({ espnId: '4433403', headshotHref: HREF('4433403') });
expect(index[nameKey('Kelsey Mitchell')].espnId).toBe('2529140');
expect(index[nameKey('Aliyah Boston')].headshotHref).toContain('3906972');
// headshot given as a bare string still resolves.
expect(index[nameKey('Sophie Cunningham')].headshotHref).toBe(HREF('4281190'));
});
it('does NOT harvest a bare team object as an athlete', () => {
const index = idx.harvestAthletes(summaryFixture);
expect(index[nameKey('Indiana Fever')]).toBeUndefined();
});
it('buildEspnAthleteIndex fans out per game (injected schedule + summary, no network)', async () => {
const cache = memCache();
const built = await idx.buildEspnAthleteIndex('wnba', {
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
getSchedule: async () => [{ id: '401' }, { id: '402' }],
getGameSummary: async () => summaryFixture,
date: '2026-07-10',
});
expect(built[nameKey('Caitlin Clark')].espnId).toBe('4433403');
expect(built[nameKey('Aliyah Boston')].headshotHref).toContain('3906972');
// Cached under the sport+date key.
expect(cache.store['espnindex:wnba:2026-07-10']).toBeTruthy();
});
it('MLB → {} (MLBAM path is untouched); returns {} on any error, never throws', async () => {
const cache = memCache();
expect(await idx.buildEspnAthleteIndex('mlb', { cacheGet: cache.cacheGet, cacheSet: cache.cacheSet })).toEqual({});
// A schedule that throws degrades to {} (graceful).
const built = await idx.buildEspnAthleteIndex('nba', {
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
getSchedule: async () => { throw new Error('espn down'); },
getGameSummary: async () => ({}),
date: '2026-07-10',
});
expect(built).toEqual({});
});
});
// A generic grade-capture stub (mirrors gradeAndCacheSlate's envelope contract).
function fakeGrade(grades) {
return async (_sport, _props, opts) => { await opts.cacheSet('grades:x', { grades, updated_at: opts.now(), source: 'test' }); };
}
describe('(b) snapshot merge — no stats-espnId → index fills espnId + headshotUrl', () => {
it('a WNBA grade with no resolved id inherits the ESPN index id + direct href', async () => {
const cache = memCache();
await svc.runSnapshot('wnba', {
getOdds: async () => ({ sport: 'wnba', props: [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, over_odds: -115, under_odds: -105, book: 'dk' }], provider: 'test' }),
gradeAndCacheSlate: fakeGrade([{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A', confidence: 80 }]),
resolveStats: async () => ({ found: true, classifierInput: {} }), // NO espnId from the flaky fallback
classify: () => ({ primary: null }),
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
now: () => '2026-07-10T20:00:00Z', nowMs: () => 1000,
buildEspnIndex: async () => ({ [nameKey('Caitlin Clark')]: { espnId: '4433403', headshotHref: HREF('4433403') } }),
});
const snap = cache.store['snapshot:wnba:latest'];
const cc = snap.grades.find((g) => nameKey(g.player) === nameKey('Caitlin Clark'));
expect(cc.espnId).toBe('4433403');
expect(cc.headshotUrl).toBe(HREF('4433403'));
// grades:{sport} inherits it too (GameCard/Explore read from here).
const g = cache.store['grades:wnba'].grades.find((x) => nameKey(x.player) === nameKey('Caitlin Clark'));
expect(g.headshotUrl).toBe(HREF('4433403'));
});
it('the stats-resolve espnId still wins when present (index is only a fallback)', async () => {
const cache = memCache();
await svc.runSnapshot('wnba', {
getOdds: async () => ({ sport: 'wnba', props: [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, book: 'dk' }], provider: 'test' }),
gradeAndCacheSlate: fakeGrade([{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A', confidence: 80 }]),
resolveStats: async () => ({ found: true, classifierInput: {}, espnId: '999999' }),
classify: () => ({ primary: null }),
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
now: () => '2026-07-10T20:00:00Z', nowMs: () => 1000,
buildEspnIndex: async () => ({ [nameKey('Caitlin Clark')]: { espnId: '4433403', headshotHref: HREF('4433403') } }),
});
const cc = cache.store['snapshot:wnba:latest'].grades[0];
expect(cc.espnId).toBe('999999'); // primary resolve wins for the id
expect(cc.headshotUrl).toBe(HREF('4433403')); // but the direct href still applies
});
});
describe('(c) a direct headshotHref WINS over the constructed (sport,id) URL — in the strip', () => {
it('buildPlayerStripsFromProps threads headshotUrl; getHeadshotUrl prefers it', () => {
const props = [{ player: 'Caitlin Clark', stat_type: 'points', line: 22.5, home_team: 'IND', away_team: 'CHI' }];
const gradeIndex = adapter.indexGrades([
{
player: 'Caitlin Clark', stat_type: 'points', line: 22.5, direction: 'over', grade: 'A',
espnId: '4433403', headshotUrl: HREF('4433403'), team: 'IND',
gradedAt: { line: 22.5, timestamp: '2026-07-10T02:00:00Z' },
},
]);
const strips = adapter.buildPlayerStripsFromProps(props, gradeIndex, {});
expect(strips).toHaveLength(1);
expect(strips[0].headshotUrl).toBe(HREF('4433403'));
// The direct href resolves verbatim, never the constructed a.espncdn combiner URL.
const resolved = getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId, headshotUrl: strips[0].headshotUrl });
expect(resolved).toBe(HREF('4433403'));
expect(resolved).not.toContain('combiner');
// Without the href, it falls back to the constructed URL (still honest).
const constructed = getHeadshotUrl({ sport: 'wnba', espnId: strips[0].espnId });
expect(constructed).toContain('/headshots/wnba/players/full/4433403.png');
});
});
describe('(d) soccer with no reliable href → absent → monogram (honest)', () => {
it('a soccer index that yields no href leaves the resolver at the silhouette sentinel', async () => {
const cache = memCache();
// ESPN soccer summaries carry no athlete id/headshot via getGameSummary (no
// soccer path) → the index is empty → nothing to thread.
const built = await idx.buildEspnAthleteIndex('soccer', {
cacheGet: cache.cacheGet, cacheSet: cache.cacheSet,
getSchedule: async () => [{ id: '9' }],
getGameSummary: async () => ({ injuries: [], leaders: [], boxscore: null, rosters: [] }),
date: '2026-07-10',
});
expect(idx.lookup(built, 'Lionel Messi')).toBeNull();
// No id + no href → the silhouette sentinel (PlayerAvatar swaps to a monogram).
expect(getHeadshotUrl({ sport: 'soccer', headshotUrl: null })).toBe('/images/player-silhouette.svg');
// We NEVER construct a soccer URL from an id (would 404).
expect(getHeadshotUrl({ sport: 'soccer', espnId: '12345' })).toBe('/images/player-silhouette.svg');
});
it('BUT a soccer athlete WITH a direct href is honored (best-effort)', () => {
const href = 'https://a.espncdn.com/i/headshots/soccer/players/full/45843.png';
const index = idx.harvestAthletes({ rosters: [{ roster: [{ athlete: { id: '45843', displayName: 'Lionel Messi', headshot: { href } } }] }] });
expect(index[nameKey('Lionel Messi')].headshotHref).toBe(href);
expect(getHeadshotUrl({ sport: 'soccer', headshotUrl: href })).toBe(href);
});
});
describe('(e) defensive parse — malformed ESPN shapes never throw', () => {
it('null / non-object / garbage → empty index', () => {
expect(idx.harvestAthletes(null)).toEqual({});
expect(idx.harvestAthletes(undefined)).toEqual({});
expect(idx.harvestAthletes(42)).toEqual({});
expect(idx.harvestAthletes('nope')).toEqual({});
expect(idx.harvestAthletes({ nonsense: true, boxscore: { players: 'not-an-array' } })).toEqual({});
// An athlete with no id AND no headshot contributes nothing (absent beats noise).
expect(idx.harvestAthletes({ leaders: [{ athlete: { displayName: 'No Id Here' } }] })).toEqual({});
});
it('a cyclic object does not hang the walker', () => {
const a = { athlete: { id: '1', displayName: 'Loop Player', headshot: { href: 'https://x/1.png' } } };
a.self = a; // cycle
const index = idx.harvestAthletes(a);
expect(index[nameKey('Loop Player')].espnId).toBe('1');
});
});
+1 -1
View File
@@ -39,7 +39,7 @@ describe('getGameSummary', () => {
test('missing sections → empty defaults (no crash)', async () => {
mockAxiosGet.mockResolvedValue({ data: {} });
const out = await getGameSummary('nba', '999');
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null });
expect(out).toEqual({ injuries: [], odds: [], ats: null, leaders: [], boxscore: null, rosters: [] });
});
test('invalid sport → empty defaults without axios', async () => {
+103
View File
@@ -0,0 +1,103 @@
// Wave 0 — THE PROOF: an NBA/WNBA prop now GRADES instead of refusing.
//
// Root cause (pre-fix): featureCache's non-MLB branch called gameLogService
// (Python nba_api, offline in prod) → null logs → no l5/l20 → projectionFor
// null → insufficientDataResult → the whole slate refused. This test injects
// the Python source as NULL (offline) and the ESPN gamelog as the free fallback,
// and asserts real l5_avg/l20_avg emerge → projectionFor returns a projection.
// Python game-log service is OFFLINE (returns null) — the prod failure mode.
jest.mock('../../src/services/intelligence/gameLogService', () => ({
getGameLogs: async () => null,
getCareerPlayoffGames: async () => null,
getWithWithoutStats: async () => null,
}));
// ESPN gamelog fallback — a normalized result (as espnStatsAdapter would return).
const espnLog = { res: null };
jest.mock('../../src/services/adapters/espnStatsAdapter', () => ({
getPlayerGameLog: async () => espnLog.res,
}));
const { __internals: fc } = require('../../src/services/intelligence/featureCache');
const { __internals: eng } = require('../../src/services/intelligence/analyzeViaEngine1');
// Most-recent first, matching getPlayerGameLog's contract.
const lebronLog = {
found: true,
id: '1966',
last10: [
{ date: '2026-01-05T00:30:00Z', stat: { points: 24, rebounds: 12, assists: 3, threes: 2, minutes: 40, pra: 39 } },
{ date: '2026-01-03T00:30:00Z', stat: { points: 28, rebounds: 10, assists: 7, threes: 3, minutes: 36, pra: 45 } },
{ date: '2026-01-01T00:30:00Z', stat: { points: 23, rebounds: 8, assists: 5, threes: 1, minutes: 32, pra: 36 } },
{ date: '2025-12-29T00:30:00Z', stat: { points: 30, rebounds: 9, assists: 6, threes: 4, minutes: 38, pra: 45 } },
{ date: '2025-12-27T00:30:00Z', stat: { points: 21, rebounds: 11, assists: 8, threes: 2, minutes: 34, pra: 40 } },
{ date: '2025-12-25T00:30:00Z', stat: { points: 26, rebounds: 7, assists: 9, threes: 3, minutes: 37, pra: 42 } },
],
};
describe('nbaGameLogFeatures (pure)', () => {
it('derives l5/l10/l20 + rest + usage for an NBA points prop', () => {
const f = fc.nbaGameLogFeatures(lebronLog, 'points');
// l5 = mean of the 5 most-recent points (24,28,23,30,21) = 25.2
expect(f.l5_avg).toBeCloseTo((24 + 28 + 23 + 30 + 21) / 5, 5);
expect(f.l20_avg).toBeGreaterThan(0);
expect(f.minutes_per_game).toBeGreaterThan(0);
// consecutive games (2d apart) → 1d gap → 0 rest? dates are 2d apart → gap 2 → rest 1
expect(f.rest_days).toBe(1);
});
it('handles combo (pra) + threes via the log-field map', () => {
expect(fc.nbaGameLogFeatures(lebronLog, 'pra').l5_avg).toBeGreaterThan(0);
expect(fc.nbaGameLogFeatures(lebronLog, 'threes').l5_avg).toBeGreaterThan(0);
// pts_reb_ast combo sums components → equals the precomputed pra
expect(fc.nbaGameLogFeatures(lebronLog, 'pts_reb_ast').l5_avg)
.toBeCloseTo(fc.nbaGameLogFeatures(lebronLog, 'pra').l5_avg, 5);
});
it('returns {} for an unfound player or unmapped stat', () => {
expect(fc.nbaGameLogFeatures({ found: false }, 'points')).toEqual({});
expect(fc.nbaGameLogFeatures(lebronLog, 'not_a_stat')).toEqual({});
});
});
describe('gameLogFeatures — ESPN fallback when Python is offline', () => {
it('emits non-null l5_avg/l20_avg for nba (Python null → ESPN fallback)', async () => {
espnLog.res = lebronLog;
const f = await fc.gameLogFeatures('LeBron James', 'nba', 'points');
expect(f.l5_avg).not.toBeNull();
expect(f.l5_avg).toBeGreaterThan(0);
expect(f.l20_avg).toBeGreaterThan(0);
});
it('works for wnba too', async () => {
espnLog.res = lebronLog; // shape-identical
const f = await fc.gameLogFeatures("A'ja Wilson", 'wnba', 'rebounds');
expect(f.l5_avg).toBeGreaterThan(0);
});
it('degrades to {} when ESPN also has nothing (still no fabrication)', async () => {
espnLog.res = { found: false };
const f = await fc.gameLogFeatures('Ghost Player', 'nba', 'points');
expect(f).toEqual({});
});
});
describe('THE UNLOCK — projectionFor now returns a grade, not insufficient_data', () => {
it('empty features (the old prod state) → projection null → REFUSE', () => {
expect(eng.projectionFor({}, { stat_type: 'points', line: 20 })).toBeNull();
});
it('ESPN-derived features → finite projection → the prop GRADES', async () => {
espnLog.res = lebronLog;
const features = await fc.gameLogFeatures('LeBron James', 'nba', 'points');
const projection = eng.projectionFor(features, { stat_type: 'points', line: 24.5 });
expect(projection).not.toBeNull();
expect(Number.isFinite(projection)).toBe(true);
// and the intel card lights up off the same vector
const intel = eng.buildIntelFields(features);
expect(intel.season_avg).toBeDefined();
expect(intel.last10_avg).toBeDefined();
expect(intel.form).toBeDefined();
});
});
+3
View File
@@ -40,6 +40,8 @@ export interface PlayerStrip {
// Wave 2A — real headshot ids threaded from the snapshot grade.
playerId?: string | number | null;
espnId?: string | number | null;
// Wave 2B — a resolved absolute ESPN headshot URL (wins over a constructed one).
headshotUrl?: string | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — prop viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -371,6 +373,7 @@ export default function GameCard({ game: g, onAddParlay, onOpen, preferredBooks
sport={g.sport}
playerId={ps.playerId}
espnId={ps.espnId}
headshotUrl={ps.headshotUrl}
archetype={ps.archetype}
lineup={ps.lineup}
injury={ps.injury}
+9 -5
View File
@@ -22,6 +22,7 @@ export default function PlayerAvatar({
sport = 'mlb',
playerId,
espnId,
headshotUrl,
team,
size = 36,
}: {
@@ -31,15 +32,18 @@ export default function PlayerAvatar({
playerId?: string | number | null;
/** ESPN athlete id — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
/** Wave 2B — a resolved absolute ESPN headshot URL; wins over a constructed one. */
headshotUrl?: string | null;
team?: string | null;
size?: number;
}) {
const [broken, setBroken] = useState(false);
// Wave 2A — resolve from whichever real id we have. getHeadshotUrl routes by
// sport: MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. No id at
// all → null → team-colored monogram (never a gray silhouette).
const url = (playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId })
// Wave 2A/2B — resolve from whichever real asset we have. A direct
// headshotUrl (ESPN's exact href) wins; else getHeadshotUrl routes by sport:
// MLB→mlbstatic via playerId; NBA/WNBA→a.espncdn via espnId. Nothing → null →
// team-colored monogram (never a gray silhouette).
const url = (headshotUrl || playerId != null || espnId != null)
? getHeadshotUrl({ sport, playerId, espnId, headshotUrl })
: null;
const accent = (team && accentColor(team, String(sport))) || '#4A9EFF';
const showImg = url && url !== '/images/player-silhouette.svg' && !broken;
+4 -1
View File
@@ -199,6 +199,8 @@ interface StatStripProps {
// playerId; NBA/WNBA → ESPN espnId. Absent → PlayerAvatar monogram.
playerId?: string | number | null;
espnId?: string | number | null;
// Wave 2B — a resolved absolute ESPN headshot URL (wins over a constructed one).
headshotUrl?: string | null;
archetype?: StripArchetype;
// Session 64 (A1-S5) — viability (lineup confirmation + injury wire).
lineup?: { status: string; slot?: number } | null;
@@ -231,6 +233,7 @@ export default function StatStrip({
sport,
playerId,
espnId,
headshotUrl,
archetype,
lineup,
injury,
@@ -384,7 +387,7 @@ export default function StatStrip({
<div style={{ display: 'flex', alignItems: 'center', gap: 9, flexWrap: 'wrap' }}>
{/* DS0 — player identity block: real headshot / team-colored monogram
(never the gray silhouette). Team drives the accent color. */}
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} team={team} size={30} />
<PlayerAvatar name={player} sport={sport} playerId={playerId} espnId={espnId} headshotUrl={headshotUrl} team={team} size={30} />
<PlayerName style={{ fontWeight: 700, fontSize: 14, color: '#fff', ...nameStyle }}>{player}</PlayerName>
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{team}</span>
{archetype && <ArchetypeBadge archetype={archetype.primary} size="sm" variant="full" />}
+2
View File
@@ -33,6 +33,8 @@ export interface HeadshotInput {
playerId?: string | number | null;
/** ESPN athlete ID — the NBA/WNBA (and dormant NFL/NHL) headshot source. */
espnId?: string | number | null;
/** Wave 2B — a RESOLVED absolute headshot URL (ESPN's exact href); wins over a constructed one. */
headshotUrl?: string | null;
/** Pre-cached photo URL (used by soccer where each league has no central CDN). */
cachedPhotoUrl?: string | null;
}
+9 -2
View File
@@ -7,8 +7,9 @@
* genuinely unit-testable (jest can't transform the `.ts`).
*
* Each league hosts its own CDN. Fallback chain inside the resolver:
* 1. `cachedPhotoUrl` — a stored URL (soccer, where API-Football returns the
* photo). We DO NOT construct soccer URLs (no central CDN).
* 1. `headshotUrl` / `cachedPhotoUrl` — a RESOLVED absolute URL (Wave 2B: the
* exact href ESPN served; or a soccer photo from API-Football). Preferred —
* it never 404s on a constructed path. We DO NOT construct soccer URLs.
* 2. League CDN with `playerId` — official source.
* 3. ESPN CDN with `espnId` — NBA/WNBA (and dormant NFL/NHL) headshots.
* 4. `/images/player-silhouette.svg` — the sentinel the component swaps for a
@@ -34,8 +35,14 @@ function getHeadshotUrl(input) {
const sport = String(input.sport || '').toLowerCase();
const playerId = input.playerId != null ? String(input.playerId) : '';
const espnId = input.espnId != null ? String(input.espnId) : '';
// Wave 2B — a resolved absolute URL (ESPN's exact href) wins over any
// constructed one. Guarded to an http(s) URL so a stray relative/garbage
// value degrades to the id/monogram path rather than a broken image.
const headshotUrl = input.headshotUrl && /^https?:\/\//i.test(String(input.headshotUrl))
? String(input.headshotUrl) : '';
const cached = input.cachedPhotoUrl ? String(input.cachedPhotoUrl) : '';
if (headshotUrl) return headshotUrl;
if (cached) return cached;
if (playerId) {
+4
View File
@@ -352,6 +352,9 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
// (MLBAM playerId / ESPN espnId). Absent → PlayerAvatar monogram.
playerId: (rec && rec.playerId != null ? rec.playerId : undefined),
espnId: (rec && rec.espnId != null ? rec.espnId : undefined),
// Wave 2B — a RESOLVED absolute ESPN headshot URL (preferred over the
// constructed (sport,id) URL). Absent → the id/monogram path stands.
headshotUrl: (rec && rec.headshotUrl ? rec.headshotUrl : undefined),
lineup: lineupStatusFor(pk, knownTeam),
injury: injuryFor(pk),
stats: [],
@@ -366,6 +369,7 @@ function buildPlayerStripsFromProps(gameProps, gradeIndex, deltaIndex, now = Dat
// Fill an id from a later graded row if the first row lacked one.
if (byPlayer[pk].playerId == null && rec && rec.playerId != null) byPlayer[pk].playerId = rec.playerId;
if (byPlayer[pk].espnId == null && rec && rec.espnId != null) byPlayer[pk].espnId = rec.espnId;
if (byPlayer[pk].headshotUrl == null && rec && rec.headshotUrl) byPlayer[pk].headshotUrl = rec.headshotUrl;
}
if (rec) {
const side = sideCh(rec.direction);