'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 }, };