S11 (a1): live tracking — the read locked, the game watched

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>
This commit is contained in:
Kev
2026-07-12 00:45:29 -04:00
parent 14dc9cf270
commit b5d3fd14bb
18 changed files with 1666 additions and 18 deletions
+368
View File
@@ -0,0 +1,368 @@
'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 },
};