b5d3fd14bb
MLB statsapi + WNBA ESPN live boxscores -> per-player current values
(live:{sport}:{date} TTL 90s, /api/live/:sport + Next proxy). Pure
propState math (HIT / ON PACE / NEEDS N / HOLDS / LINE PASSED — never
red in-progress), attachLiveProgress strip join on nameKey+statType,
proximity-to-hit slate float, StatStrip LiveTracker in the ROW-GRAMMAR
outcome slot (spec amended + lock test updated). Grades never change
in-game — tracking, labeled as such. 2698 -> 2757 tests, web build 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
369 lines
15 KiB
JavaScript
369 lines
15 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* liveTrackingService — LIVE TRACKING (A1 board, Session 11).
|
||
*
|
||
* The read is locked pre-game; the game is watched. This service produces the
|
||
* per-player CURRENT box-line values for in-progress games so the slate can
|
||
* render live progress marks — proto-outcomes in the OUTCOME slot region
|
||
* (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6). GRADES NEVER
|
||
* CHANGE IN-GAME.
|
||
*
|
||
* Sources (both FREE, zero out-of-pocket):
|
||
* MLB — statsapi.mlb.com: schedule?hydrate=linescore identifies Live games
|
||
* AND carries inning progress in ONE call; then
|
||
* /api/v1/game/{gamePk}/boxscore per live game.
|
||
* WNBA — ESPN scoreboard (state 'in' + period) identifies live events; then
|
||
* the ESPN summary?event= boxscore per live event.
|
||
*
|
||
* POLLING RULE / quota math: getLiveTracking caches `live:{sport}:{date}` for
|
||
* LIVE_TTL (90s). The cost during live windows is 1 schedule call + N boxscore
|
||
* calls per 90s ACROSS ALL USERS (shared cache); when nothing is live the
|
||
* cached { hasLive: false } envelope means one schedule check per 90s while
|
||
* anyone polls, and ZERO boxscore calls. The frontend only polls while live
|
||
* games are on screen.
|
||
*
|
||
* DATA SEMANTICS: only real box-line values. A player who hasn't appeared has
|
||
* EMPTY stats objects in the MLB boxscore (or didNotPlay / an empty stats row
|
||
* on ESPN) → he is ABSENT from the output, never 0.
|
||
*
|
||
* Everything is injectable (fetchJson, cacheGet, cacheSet) → parsers and the
|
||
* whole refresh cycle are unit-tested on REAL captured feed shapes with zero
|
||
* network (fixtures captured live 2026-07-11).
|
||
*/
|
||
|
||
const { nameKey } = require('../utils/playerName');
|
||
|
||
const LIVE_TTL = 90; // seconds — the shared polling window
|
||
const HTTP_TIMEOUT_MS = 10_000;
|
||
const MLB_BASE = 'https://statsapi.mlb.com/api/v1';
|
||
const ESPN_WNBA_BASE = 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MLB stat map — VYNDR stat_type → { group, field } in a LIVE boxscore player.
|
||
//
|
||
// This is a LOCAL map on purpose (same rule as outcomeService.MLB_LOG_FIELD):
|
||
// settlement reads the post-game GAME LOG, where statsapi has already picked
|
||
// ONE stat group for the row by position. The LIVE boxscore instead carries
|
||
// BOTH `stats.batting` and `stats.pitching` per player, so each stat must name
|
||
// its group explicitly — and innings_pitched must be parsed in thirds
|
||
// ('5.2' = 5⅔ ≈ 5.667) for pace math, which parseFloat gets wrong. Coupling
|
||
// the two maps would let a settlement-side change silently break live pace
|
||
// (or vice versa). If you add an MLB stat_type, wire it HERE for tracking and
|
||
// in outcomeService.MLB_LOG_FIELD for settlement.
|
||
// ---------------------------------------------------------------------------
|
||
const LIVE_BOX_FIELD = {
|
||
hits: { group: 'batting', field: 'hits' },
|
||
total_bases: { group: 'batting', field: 'totalBases' },
|
||
home_runs: { group: 'batting', field: 'homeRuns' },
|
||
rbi: { group: 'batting', field: 'rbi' },
|
||
runs: { group: 'batting', field: 'runs' },
|
||
stolen_bases: { group: 'batting', field: 'stolenBases' },
|
||
doubles: { group: 'batting', field: 'doubles' },
|
||
triples: { group: 'batting', field: 'triples' },
|
||
walks: { group: 'batting', field: 'baseOnBalls' },
|
||
// Pitcher props. `strikeouts` is the PITCHER prop in the VYNDR vocabulary
|
||
// (pitcher_strikeouts market) — resolved from stats.pitching.
|
||
strikeouts: { group: 'pitching', field: 'strikeOuts' },
|
||
earned_runs: { group: 'pitching', field: 'earnedRuns' },
|
||
hits_allowed: { group: 'pitching', field: 'hits' },
|
||
outs: { group: 'pitching', field: 'outs' },
|
||
innings_pitched: { group: 'pitching', field: 'inningsPitched', parse: ipToDecimal },
|
||
};
|
||
|
||
/** MLB innings string in thirds → decimal ('5.2' = 5 + 2/3). Null-strict. */
|
||
function ipToDecimal(v) {
|
||
if (v == null || v === '') return null;
|
||
const [whole, partial] = String(v).split('.');
|
||
const w = Number(whole);
|
||
if (!Number.isFinite(w)) return null;
|
||
const p = Number(partial) || 0;
|
||
return w + p / 3;
|
||
}
|
||
|
||
/** Strict numeric read — null when absent/unparseable, NEVER a fabricated 0. */
|
||
function numOrNull(v) {
|
||
if (v == null || v === '') return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MLB parsers (pure)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Pure: statsapi schedule JSON (hydrate=linescore) → live games only:
|
||
* [{ gamePk, home, away, progress }]. progress = { label, fraction, inning,
|
||
* half, scheduledInnings }. Games without a parseable linescore still list
|
||
* (progress null) — the boxscore values are real either way.
|
||
*/
|
||
function parseMlbLiveSchedule(scheduleJson) {
|
||
const games = ((scheduleJson || {}).dates || []).flatMap((d) => d.games || []);
|
||
return games
|
||
.filter((g) => g && g.status && g.status.abstractGameState === 'Live')
|
||
.map((g) => ({
|
||
gamePk: g.gamePk ?? null,
|
||
home: g.teams?.home?.team?.name ?? null,
|
||
away: g.teams?.away?.team?.name ?? null,
|
||
progress: mlbProgress(g.linescore),
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Pure: an MLB linescore → { label, fraction, inning, half, scheduledInnings }.
|
||
* Fraction = (inning − (top ? 1 : 0.5)) / scheduled — mid-inning estimate for
|
||
* pace math (Bottom 8th of 9 → 7.5/9 ≈ 0.833). Label uses ▲ (top) / ▼ (bottom):
|
||
* '▲6th' / '▼8th'. Null when the feed carries no inning yet.
|
||
*/
|
||
function mlbProgress(linescore) {
|
||
const ls = linescore || {};
|
||
const inning = numOrNull(ls.currentInning);
|
||
if (inning == null) return null;
|
||
const scheduled = numOrNull(ls.scheduledInnings) || 9;
|
||
// inningState: Top | Middle | Bottom | End. Middle/End sit between halves —
|
||
// count them as the completed half (top done → same fraction as bottom start).
|
||
const state = String(ls.inningState || (ls.isTopInning ? 'Top' : 'Bottom')).toLowerCase();
|
||
const top = state === 'top';
|
||
const fraction = Math.min(1, Math.max(0, (inning - (top ? 1 : 0.5)) / scheduled));
|
||
const ord = ordinal(inning);
|
||
return {
|
||
label: `${top ? '▲' : '▼'}${ord}`,
|
||
fraction,
|
||
inning,
|
||
half: top ? 'top' : 'bottom',
|
||
scheduledInnings: scheduled,
|
||
};
|
||
}
|
||
|
||
function ordinal(n) {
|
||
const s = ['th', 'st', 'nd', 'rd'];
|
||
const v = n % 100;
|
||
return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
|
||
}
|
||
|
||
/**
|
||
* Pure: an MLB live boxscore → { [nameKey]: { name, team, values } } where
|
||
* `values` maps VYNDR stat_type → current number for every wired stat the
|
||
* player has REAL box data for. A player with empty batting AND pitching
|
||
* objects has not appeared → OMITTED entirely (absent, never 0).
|
||
*/
|
||
function parseMlbBoxscore(boxJson) {
|
||
const out = {};
|
||
const teams = (boxJson || {}).teams || {};
|
||
for (const side of ['home', 'away']) {
|
||
const t = teams[side] || {};
|
||
const teamName = t.team?.name ?? null;
|
||
const players = t.players || {};
|
||
for (const key of Object.keys(players)) {
|
||
const p = players[key];
|
||
const name = p?.person?.fullName;
|
||
if (!name) continue;
|
||
const batting = p.stats?.batting;
|
||
const pitching = p.stats?.pitching;
|
||
const hasBatting = batting && Object.keys(batting).length > 0;
|
||
const hasPitching = pitching && Object.keys(pitching).length > 0;
|
||
if (!hasBatting && !hasPitching) continue; // not in the game yet — absent
|
||
const values = {};
|
||
for (const [statType, m] of Object.entries(LIVE_BOX_FIELD)) {
|
||
const groupObj = m.group === 'batting' ? (hasBatting ? batting : null) : (hasPitching ? pitching : null);
|
||
if (!groupObj) continue;
|
||
const raw = groupObj[m.field];
|
||
const val = m.parse ? m.parse(raw) : numOrNull(raw);
|
||
if (val != null) values[statType] = val;
|
||
}
|
||
if (Object.keys(values).length === 0) continue;
|
||
out[nameKey(name)] = { name, team: teamName, values };
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// WNBA parsers (pure) — ESPN site API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// VYNDR stat_type → the ESPN boxscore `keys` entry. `threes` is the made count
|
||
// parsed from the "M-A" pair; `pra` is computed from components (never read).
|
||
const WNBA_BOX_KEY = {
|
||
points: 'points',
|
||
rebounds: 'rebounds',
|
||
assists: 'assists',
|
||
steals: 'steals',
|
||
blocks: 'blocks',
|
||
turnovers: 'turnovers',
|
||
threes: 'threePointFieldGoalsMade-threePointFieldGoalsAttempted',
|
||
};
|
||
|
||
/**
|
||
* Pure: ESPN scoreboard JSON → live events only:
|
||
* [{ id, home, away, progress }]. progress fraction = (period − 0.5) / 4,
|
||
* clamped (OT caps at 1) — mid-period estimate, label 'Q{n}' / 'OT'.
|
||
*/
|
||
function parseWnbaLiveScoreboard(scoreboardJson) {
|
||
const events = (scoreboardJson || {}).events || [];
|
||
return events
|
||
.filter((e) => e?.status?.type?.state === 'in')
|
||
.map((e) => {
|
||
const comp = e.competitions?.[0] || {};
|
||
const competitors = comp.competitors || [];
|
||
const home = competitors.find((c) => c.homeAway === 'home');
|
||
const away = competitors.find((c) => c.homeAway === 'away');
|
||
const period = numOrNull(e.status?.period);
|
||
const progress = period == null ? null : {
|
||
label: period > 4 ? (period === 5 ? 'OT' : `${period - 4}OT`) : `Q${period}`,
|
||
fraction: Math.min(1, Math.max(0, (period - 0.5) / 4)),
|
||
period,
|
||
};
|
||
return {
|
||
id: String(e.id),
|
||
home: home?.team?.displayName ?? null,
|
||
away: away?.team?.displayName ?? null,
|
||
progress,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Pure: an ESPN summary boxscore → { [nameKey]: { name, team, values } }.
|
||
* ESPN ships per-team `statistics[0]` with a parallel `keys` array and
|
||
* per-athlete `stats` string arrays. didNotPlay or an empty stats row →
|
||
* the player has not appeared → OMITTED (absent, never 0). `pra` is computed
|
||
* only when points, rebounds AND assists are all present.
|
||
*/
|
||
function parseWnbaBoxscore(summaryJson) {
|
||
const out = {};
|
||
const teams = (summaryJson || {}).boxscore?.players || [];
|
||
for (const t of teams) {
|
||
const teamName = t.team?.displayName ?? t.team?.abbreviation ?? null;
|
||
const block = (t.statistics || [])[0];
|
||
if (!block || !Array.isArray(block.keys) || !Array.isArray(block.athletes)) continue;
|
||
const idx = {};
|
||
block.keys.forEach((k, i) => { idx[k] = i; });
|
||
for (const a of block.athletes) {
|
||
const name = a?.athlete?.displayName;
|
||
if (!name) continue;
|
||
const row = a.stats;
|
||
if (a.didNotPlay || !Array.isArray(row) || row.length === 0) continue; // absent
|
||
const values = {};
|
||
for (const [statType, key] of Object.entries(WNBA_BOX_KEY)) {
|
||
const i = idx[key];
|
||
if (i == null || row[i] == null) continue;
|
||
const raw = String(row[i]);
|
||
const val = statType === 'threes' ? numOrNull(raw.split('-')[0]) : numOrNull(raw);
|
||
if (val != null) values[statType] = val;
|
||
}
|
||
if (values.points != null && values.rebounds != null && values.assists != null) {
|
||
values.pra = values.points + values.rebounds + values.assists;
|
||
}
|
||
if (Object.keys(values).length === 0) continue;
|
||
out[nameKey(name)] = { name, team: teamName, values };
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Fetch + cache layer
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function defaultFetchJson(url) {
|
||
const axios = require('axios');
|
||
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
|
||
return res && res.data ? res.data : null;
|
||
}
|
||
|
||
function resolveDeps(opts = {}) {
|
||
return {
|
||
fetchJson: opts.fetchJson || defaultFetchJson,
|
||
cacheGet: opts.cacheGet || require('../utils/redis').cacheGet,
|
||
cacheSet: opts.cacheSet || require('../utils/redis').cacheSet,
|
||
};
|
||
}
|
||
|
||
/** Today in ET (sports days roll over on ET, not UTC). */
|
||
function todayET() {
|
||
return new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
|
||
}).format(new Date());
|
||
}
|
||
|
||
/**
|
||
* Build the fresh live envelope for one sport (network path — called only on
|
||
* a cache miss). One schedule/scoreboard call; boxscore calls ONLY for games
|
||
* the schedule marks live. Per-game failures degrade to an empty players map
|
||
* for that game — the rest of the slate still tracks.
|
||
*/
|
||
async function fetchLiveTracking(sport, date, deps) {
|
||
const sp = String(sport || '').toLowerCase();
|
||
if (sp === 'mlb') {
|
||
const sched = await deps.fetchJson(`${MLB_BASE}/schedule?sportId=1&date=${date}&hydrate=linescore`);
|
||
const live = parseMlbLiveSchedule(sched);
|
||
const games = [];
|
||
for (const g of live) {
|
||
let players = {};
|
||
try {
|
||
const box = await deps.fetchJson(`${MLB_BASE}/game/${g.gamePk}/boxscore`);
|
||
players = parseMlbBoxscore(box);
|
||
} catch (e) {
|
||
console.warn('[liveTracking] mlb boxscore failed:', g.gamePk, e.message);
|
||
}
|
||
games.push({ id: String(g.gamePk), home: g.home, away: g.away, progress: g.progress, players });
|
||
}
|
||
return { sport: sp, date, hasLive: games.length > 0, games };
|
||
}
|
||
if (sp === 'wnba') {
|
||
const board = await deps.fetchJson(`${ESPN_WNBA_BASE}/scoreboard`);
|
||
const live = parseWnbaLiveScoreboard(board);
|
||
const games = [];
|
||
for (const g of live) {
|
||
let players = {};
|
||
try {
|
||
const summary = await deps.fetchJson(`${ESPN_WNBA_BASE}/summary?event=${encodeURIComponent(g.id)}`);
|
||
players = parseWnbaBoxscore(summary);
|
||
} catch (e) {
|
||
console.warn('[liveTracking] wnba summary failed:', g.id, e.message);
|
||
}
|
||
games.push({ id: g.id, home: g.home, away: g.away, progress: g.progress, players });
|
||
}
|
||
return { sport: sp, date, hasLive: games.length > 0, games };
|
||
}
|
||
// Other sports: no free live box feed wired — honest empty, never a guess.
|
||
return { sport: sp, date, hasLive: false, games: [] };
|
||
}
|
||
|
||
/**
|
||
* Cache-aside live read (the route's entrypoint). `live:{sport}:{date}` TTL
|
||
* 90s — the shared polling window. A no-live result is ALSO cached for 90s so
|
||
* idle polling costs one schedule check per window, zero boxscore calls.
|
||
* Never throws: any failure returns an empty envelope.
|
||
*/
|
||
async function getLiveTracking(sport, opts = {}) {
|
||
const deps = resolveDeps(opts);
|
||
const sp = String(sport || '').toLowerCase();
|
||
const date = opts.date || todayET();
|
||
const key = `live:${sp}:${date}`;
|
||
try {
|
||
const cached = await deps.cacheGet(key);
|
||
if (cached !== null) return cached;
|
||
const fresh = await fetchLiveTracking(sp, date, deps);
|
||
fresh.updated_at = new Date().toISOString();
|
||
await deps.cacheSet(key, fresh, LIVE_TTL);
|
||
return fresh;
|
||
} catch (err) {
|
||
console.warn(`[liveTracking] ${sp} failed:`, err.message);
|
||
return { sport: sp, date, hasLive: false, games: [], error: 'unavailable' };
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
getLiveTracking,
|
||
fetchLiveTracking,
|
||
parseMlbLiveSchedule,
|
||
parseMlbBoxscore,
|
||
parseWnbaLiveScoreboard,
|
||
parseWnbaBoxscore,
|
||
mlbProgress,
|
||
__internals: { LIVE_BOX_FIELD, WNBA_BOX_KEY, ipToDecimal, numOrNull, ordinal, LIVE_TTL, todayET, MLB_BASE, ESPN_WNBA_BASE },
|
||
};
|