'use strict'; /** * last10Dots — S6 (A1 board, ROW-GRAMMAR §4). * * ●●○●● — a grade's last 10 REAL games scored against TONIGHT'S locked line: * per game, hit = stat value ≥ line → boolean, newest first. Pure math over * the `rosterlogs:{sport}` blob the snapshot pipeline already writes (games * are stored most-recent-first) — zero API calls, zero new keys. * * Stat-field access reuses the SAME accessors as streaksService (one source * of truth for the several field spellings each feed uses). A stat type with * no accessor, an empty log, or a non-finite line → null: absent beats wrong, * the UI renders nothing. */ const { __internals: streakAccessors } = require('./streaksService'); const { nameKey } = require('../utils/playerName'); const { nba, mlb, soccer } = streakAccessors; // grade stat_type → streaksService accessor, per sport. Keep keyed on the // grade-gate stat names (routes/analyze.js vocabulary), not market keys. const STAT_ACCESSORS = { mlb: { hits: mlb.hits, total_bases: mlb.totalBases, home_runs: mlb.homeRuns, rbi: mlb.rbi, runs: mlb.runs, stolen_bases: mlb.stolenBases, walks: mlb.walks, strikeouts: mlb.strikeouts, earned_runs: mlb.earnedRuns, hits_allowed: mlb.hits, // pitching log's hits = hits allowed innings_pitched: mlb.inningsPitched, doubles: mlb.doubles, triples: mlb.triples, outs: mlb.outs, }, nba: { points: nba.points, rebounds: nba.rebounds, assists: nba.assists, threes: nba.threes, steals: nba.steals, blocks: nba.blocks, pra: nba.pra, }, soccer: { goals: soccer.goals, assists: soccer.assists, shots_on_target: soccer.shotsOnTarget, }, }; STAT_ACCESSORS.wnba = STAT_ACCESSORS.nba; const MAX_DOTS = 10; /** * Compute the dot booleans for one player's game log vs a line. * `games` most-recent-first (the rosterlogs order). Returns boolean[] * (newest first, ≤10) or null when it can't be computed honestly. */ function computeLast10Dots(games, sport, statType, line) { const sp = String(sport || '').toLowerCase(); const accessor = (STAT_ACCESSORS[sp] || {})[String(statType || '').toLowerCase()]; // Strict: Number(null) is 0 — a fabricated line (Data Semantics Rule). const ln = line == null ? NaN : Number(line); if (!accessor || !Number.isFinite(ln)) return null; const rows = Array.isArray(games) ? games.filter(Boolean).slice(0, MAX_DOTS) : []; if (rows.length === 0) return null; return rows.map((g) => accessor(g) >= ln); } /** Index a rosterlogs blob by nameKey → games (most-recent-first). */ function indexRosterLogs(blob) { const map = {}; for (const e of Array.isArray(blob) ? blob : []) { if (e && e.name && Array.isArray(e.games) && e.games.length > 0) { map[nameKey(e.name)] = e.games; } } return map; } /** * Attach `last10_dots` to each grade that has a real log + locked line. * Non-destructive: grades without a computable strip pass through untouched. */ function attachLast10Dots(grades, rosterIndex, sport) { if (!rosterIndex || Object.keys(rosterIndex).length === 0) return grades; return (grades || []).map((g) => { const player = g.player || g.player_name; if (!player) return g; const games = rosterIndex[nameKey(player)]; if (!games) return g; const line = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line; const dots = computeLast10Dots(games, sport, g.stat_type || g.stat, line); return dots ? { ...g, last10_dots: dots } : g; }); } module.exports = { computeLast10Dots, indexRosterLogs, attachLast10Dots, __internals: { STAT_ACCESSORS, MAX_DOTS }, };