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>
201 lines
7.9 KiB
JavaScript
201 lines
7.9 KiB
JavaScript
/* ============================================================
|
||
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,
|
||
};
|