/* ============================================================ pricedLines (Session 79) — the REAL priced lines from the current snapshot, indexed for the scanner to surface. Plain CommonJS so it's unit-testable and importable by the scan page (allowJs). NON-FABRICATING BY CONSTRUCTION: it only ever returns lines that are actually in the snapshot with a real book price. Nothing is suggested, interpolated, or rounded to a nearest. A player+stat the board didn't price returns []. ============================================================ */ const { nameKey } = require('./playerName'); /** * indexPricedLines(grades) — snapshot grades → Map(playerKey|stat → [rows]). * A row is kept ONLY if it carries a real book price — a graded prop with no * market is not a priceable line to surface. */ function indexPricedLines(grades) { const idx = new Map(); for (const g of Array.isArray(grades) ? grades : []) { if (!g) continue; const book = g.book_odds; if (book == null || book === '') continue; // no market → not surfaceable const player = g.player_name || g.player; const stat = String(g.stat_type || g.stat || '').toLowerCase(); if (!player || !stat) continue; const line = Number(g.line); if (!Number.isFinite(line)) continue; const key = `${nameKey(player)}|${stat}`; const side = String(g.direction || g.side || 'over').toLowerCase() === 'under' ? 'under' : 'over'; const rows = idx.get(key) || []; // Dedupe on line+side — one snapshot shouldn't carry the same line twice, // but be defensive so the chip list never repeats. if (!rows.some((r) => r.line === line && r.direction === side)) { // fair_odds is the de-vigged MARKET price (free-tier visible — NOT the // gated model_odds). Carried through so the S7 spec row can render // `◆ FAIR`; strict null (Number(null) === 0 would fabricate a price). const fair = g.fair_odds; rows.push({ line, direction: side, book_odds: Number(book), fair_odds: fair == null || fair === '' ? null : Number(fair), }); } idx.set(key, rows); } return idx; } /** * pricedLinesFor(index, player, stat) — the priced lines for an EXACT * player+stat. A different stat priced for the same player returns nothing for * the picked stat; an off-slate player returns nothing. Sorted by line. */ function pricedLinesFor(index, player, stat) { if (!index || !player || !stat) return []; const key = `${nameKey(player)}|${String(stat).toLowerCase()}`; const rows = index.get(key) || []; return [...rows].sort((a, b) => a.line - b.line); } /** * pricedStatsForPlayer(index, player) — the player's priced stats ACROSS the * board (S7 Case A "WE PRICE THESE FOR [player]"). Reads the SAME fresh index * the per-selection chips use — filters keys by the player's nameKey prefix so * the count and the chips can never disagree with the rows above them. Returns * [{ stat, lines }] sorted by stat; `excludeStat` drops the already-selected * stat (Case A is "this stat has none, but these others do"). */ function pricedStatsForPlayer(index, player, excludeStat) { if (!index || !player) return []; const prefix = `${nameKey(player)}|`; const skip = excludeStat ? String(excludeStat).toLowerCase() : null; const out = []; for (const [key, rows] of index) { if (!key.startsWith(prefix)) continue; const stat = key.slice(prefix.length); if (skip && stat === skip) continue; if (Array.isArray(rows) && rows.length) { out.push({ stat, lines: [...rows].sort((a, b) => a.line - b.line) }); } } return out.sort((a, b) => a.stat.localeCompare(b.stat)); } /** * pricedCountForPlayer(index, player) — how many priced props (distinct stats) * the board carries for this player tonight. The number behind the green CTA * "PLAYER · N PRICED PROPS ▸"; 0 → the caller must degrade to the board path, * never render "0 PRICED PROPS". */ function pricedCountForPlayer(index, player) { return pricedStatsForPlayer(index, player).length; } /** * totalPricedCount(index) — board-wide priced-prop count ("N PROPS LIVE"). Each * index key is one player+stat prop. Real exhaust or 0 — never a placeholder. */ function totalPricedCount(index) { return index && typeof index.size === 'number' ? index.size : 0; } /** * nearestPricedLine(index, player, stat, typedLine) — for the typed-line * mismatch fact line: the priced line closest to what the user typed, so the * strip can say "o 30.5 ISN'T PRICED · NEAREST ↓ o 27.5" WITHOUT rewriting the * user's intent. Null when nothing is priced for that exact player+stat. */ function nearestPricedLine(index, player, stat, typedLine) { const rows = pricedLinesFor(index, player, stat); if (!rows.length) return null; const t = Number(typedLine); if (!Number.isFinite(t)) return rows[0]; return rows.reduce((best, r) => (Math.abs(r.line - t) < Math.abs(best.line - t) ? r : best), rows[0]); } module.exports = { indexPricedLines, pricedLinesFor, pricedStatsForPlayer, pricedCountForPlayer, totalPricedCount, nearestPricedLine, };