/* ============================================================ VYNDR — live progress engine (A1 board, Session 11). LIVE TRACKING math + the strip join. The read is locked pre-game; these marks are PROTO-OUTCOMES rendered in the ROW-GRAMMAR outcome slot (specs/LIVE-TRACKING.md, specs/ROW-GRAMMAR.md §2 slot 6). GRADES NEVER CHANGE IN-GAME. Plain CommonJS so the .tsx components import it (allowJs) AND the plain-JS Jest suite exercises every branch directly. COLOR LAW (one meaning per color): green = on-pace / already-cleared / holding; amber = needs-more / line-passed caution. Red is RESERVED for settled-negative truth — an in-progress prop is NEVER red. DATA SEMANTICS: a player not in the box has no live entry → no mark, never a fabricated 0. All numeric paths are strict-null. ============================================================ */ const { nameKey } = require('./playerName'); /** Strict numeric read — null when absent/unparseable, never 0-by-default. */ function numOrNull(v) { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** * Pure prop-state math. { side, line, current, progress } → state or null. * * over, current > line → 'hit' (✓ the over has already cleared * — a counting stat cannot un-clear; still * TRACKING until the settle pass owns it) * over, projected to clear → 'on_pace' (green) * over, otherwise → 'needs' (amber, NEEDS N) * under, current < line → 'holding' (green HOLDS — an under is * never 'hit' until final) * under, current ≥ line → 'past' (amber LINE PASSED — not red; * nothing settles until the game is final) * current/line missing → null (absent beats wrong) * * NEEDS N beats a push on integer lines: N = floor(line) + 1 − current, * ceil'd for fractional stats (IP thirds) — over-strict amber beats an * over-claimed green. `progress` is the game fraction (innings/9, quarters/4); * on-pace = current / progress ≥ floor(line) + 1. No progress → no pace * judgement (stays NEEDS N — honest, not optimistic). */ function propState({ side, line, current, progress } = {}) { const ln = numOrNull(line); const cur = numOrNull(current); if (ln == null || cur == null) return null; const under = /^u/i.test(String(side || 'O')); if (!under) { if (cur > ln) return { state: 'hit', label: 'HIT ✓', needs: 0 }; const clearAt = Math.floor(ln) + 1; const needs = Math.max(1, Math.ceil(clearAt - cur - 1e-9)); const p = numOrNull(progress); const onPace = p != null && p > 0 && cur / p >= clearAt; return onPace ? { state: 'on_pace', label: 'ON PACE', needs } : { state: 'needs', label: `NEEDS ${needs}`, needs }; } if (cur < ln) return { state: 'holding', label: 'HOLDS' }; return { state: 'past', label: 'LINE PASSED' }; } /** * Flatten /api/live/:sport response(s) → one join index: * { hasLive, count, players: { [nameKey]: { name, team, values, progress, gameId } } }. * Accepts a single response or an array (the Slate merges sports). */ function buildLiveIndex(responses) { const list = Array.isArray(responses) ? responses : [responses]; const players = {}; let hasLive = false; let count = 0; for (const resp of list) { if (!resp) continue; if (resp.hasLive) hasLive = true; for (const g of resp.games || []) { for (const [key, rec] of Object.entries(g.players || {})) { if (!rec) continue; players[key] = { name: rec.name, team: rec.team || null, values: rec.values || {}, progress: g.progress || null, gameId: g.id, }; count += 1; } } } return { hasLive, count, players }; } /** * Join built player strips ↔ the live index (PURE). Only GRADED, unsettled, * non-dead props get `prop.live` — settled outcomes, dead reads and awaiting * rows are untouched, and a player absent from the box gets NO mark. The * state is computed against the LOCKED line (gradedAt.line when present) — * the read never re-grades. */ function attachLiveProgress(strips, liveIndex) { const idx = liveIndex && liveIndex.players ? liveIndex.players : null; if (!Array.isArray(strips) || !idx || Object.keys(idx).length === 0) return strips || []; return strips.map((strip) => { const entry = idx[nameKey(strip.player)]; if (!entry) return strip; const fraction = entry.progress ? numOrNull(entry.progress.fraction) : null; let touched = false; const props = (strip.props || []).map((p) => { if (!p || !p.grade || p.outcome || p.dead || p.awaiting) return p; const st = String(p.statType || '').toLowerCase(); if (!st) return p; const current = entry.values ? numOrNull(entry.values[st]) : null; if (current == null) return p; // not in the box for this stat — absent const line = p.gradedAt && numOrNull(p.gradedAt.line) != null ? p.gradedAt.line : p.line; const state = propState({ side: p.side, line, current, progress: fraction }); if (!state) return p; touched = true; return { ...p, live: { current, line: numOrNull(line), ...state, progressLabel: entry.progress ? entry.progress.label || null : null, progressFraction: fraction, }, }; }); return touched ? { ...strip, props } : strip; }); } /** * Proximity-to-hit for one game's raw odds props (the Slate's sort key). * A prop is TRACKED when it's snapshot-graded AND its player has a live box * value for the stat. Proximity (overs only, per spec): current / needed-total * = current / (floor(line)+1), capped at 1; already-cleared overs count 1. * Unders count as tracked but contribute no over-proximity. * Returns { tracked, proximity }. */ function gameLiveProximity(rawProps, gradeIndex, liveIndex) { const idx = liveIndex && liveIndex.players ? liveIndex.players : null; if (!Array.isArray(rawProps) || !idx || !gradeIndex) return { tracked: false, proximity: 0 }; let tracked = false; let proximity = 0; for (const p of rawProps) { if (!p || !p.player) continue; const stat = String(p.stat_type || p.stat || '').toLowerCase(); const key = nameKey(p.player); const rec = gradeIndex[`${key}|${stat}`]; if (!rec || !rec.grade) continue; const entry = idx[key]; if (!entry) continue; const current = entry.values ? numOrNull(entry.values[stat]) : null; if (current == null) continue; tracked = true; const under = /^u/i.test(String(rec.direction || 'over')); if (under) continue; const line = rec.gradedAt && numOrNull(rec.gradedAt.line) != null ? rec.gradedAt.line : rec.line; const ln = numOrNull(line); if (ln == null) continue; const clearAt = Math.floor(ln) + 1; const frac = clearAt > 0 ? Math.min(1, current / clearAt) : 0; if (frac > proximity) proximity = frac; } return { tracked, proximity }; } /** * Stable partition sort: items whose scorer says tracked float to the top, * ordered by proximity desc; everything else keeps its original order. */ function sortLiveFirst(items, scorer) { if (!Array.isArray(items) || typeof scorer !== 'function') return items || []; const scored = items.map((it, i) => { const s = scorer(it) || { tracked: false, proximity: 0 }; return { it, i, tracked: !!s.tracked, proximity: numOrNull(s.proximity) || 0 }; }); return scored .sort((a, b) => { if (a.tracked !== b.tracked) return a.tracked ? -1 : 1; if (a.tracked && b.tracked && b.proximity !== a.proximity) return b.proximity - a.proximity; return a.i - b.i; }) .map((x) => x.it); } module.exports = { propState, buildLiveIndex, attachLiveProgress, gameLiveProximity, sortLiveFirst, numOrNull, };