/** * Roster game-log loader (Session 23). * * Streaks and hot lists both need "every player's recent game log" — but * VYNDR caches logs per-player on demand (`gamelogs:{sport}:{player}:{n}`) * as the grading flow touches them. There's no roster-wide pull, and we * will NOT add API calls to build one (free/cheap-only session). * * So we read what's ALREADY cached, in priority order: * 1. A precomputed roster blob `rosterlogs:{sport}` if a prefetch wrote * one (fast path — a single read). * 2. The per-player `gamelogs:{sport}:*` keys (NBA/WNBA: written by * gameLogService during grading, with real multi-game logs). * 3. (Session 25) The Tank01 box-score cache `tank01:{sport}:boxscore:*` * that the prefetch writes. Each key is ONE game; we aggregate by * player across games into the same roster shape. This closes the * key-alignment gap Session 25 traced: the prefetch wrote box scores * under a key rosterLogs never read, so MLB streaks were always empty. * * Streaks need 2+ games to surface (MIN_STREAK), so a single cached game * day yields no streak — correct, not a bug. Coverage grows as box scores * accumulate across prefetch runs. * * Everything here is Redis-only (free) and defensive — any failure yields * an empty roster, never a throw. An empty roster is a valid state: the * streaks/hot-list panels simply render nothing while other layers carry * the slate. */ const { cacheGet, getRedisClient, isDegraded } = require('../utils/redis'); const SCAN_COUNT = 200; const MAX_KEYS = 600; // safety cap so a huge cache can't stall a request /** * Parse a player display name out of a gamelogs key. * Key shape: `gamelogs:{sport}:{playerName}:{count}` — playerName may * itself contain colons in theory, so split off the known head/tail. */ function playerFromKey(key, sport) { const prefix = `gamelogs:${sport}:`; if (!key.startsWith(prefix)) return null; const rest = key.slice(prefix.length); const lastColon = rest.lastIndexOf(':'); if (lastColon === -1) return rest; return rest.slice(0, lastColon); } async function scanKeys(match) { if (isDegraded && isDegraded()) return []; const redis = getRedisClient(); if (!redis || typeof redis.scan !== 'function') return []; const keys = []; let cursor = '0'; try { do { const [next, batch] = await redis.scan(cursor, 'MATCH', match, 'COUNT', SCAN_COUNT); cursor = next; for (const k of batch) { if (!keys.includes(k)) keys.push(k); if (keys.length >= MAX_KEYS) return keys; } } while (cursor !== '0'); } catch (err) { console.warn('[rosterLogs] scan failed:', err.message); return keys; } return keys; } function scanGameLogKeys(sport) { return scanKeys(`gamelogs:${sport}:*`); } // Session 25 — box-score-cache aggregation (the prefetch-alignment bridge). // Extract the YYYYMMDD date from a `tank01:{sport}:boxscore:{gameId}` key, // where gameId is `YYYYMMDD_AWAY@HOME`. Used to order games most-recent-first // (streaksService counts the streak from games[0] backward). function boxScoreKeyDate(key) { const m = String(key || '').match(/boxscore:(\d{8})/); return m ? m[1] : '0'; } // Project one cached box-score row into a stat row the streaks/hot-list // engines can read. NBA rows already carry pts/reb/ast/etc. at the top // level; MLB rows keep stats under `_raw`, so we flatten that up. function projectBoxRow(sport, row) { if (!row || typeof row !== 'object') return null; if (sport === 'mlb') { const raw = row._raw && typeof row._raw === 'object' ? row._raw : {}; return { ...raw, team: row.team, playerId: row.playerId, _final: row._final }; } // nba / wnba — already flat. return row; } /** * Aggregate cached Tank01 box scores into [{ name, playerId, team, games }]. * One box-score key = one game; a player appearing across N cached games * accumulates an N-length log. Games are ordered most-recent-first by the * date embedded in the key. */ async function aggregateBoxScores(sport) { const keys = await scanKeys(`tank01:${sport}:boxscore:*`); if (keys.length === 0) return []; // Most-recent game first. keys.sort((a, b) => boxScoreKeyDate(b).localeCompare(boxScoreKeyDate(a))); const byPlayer = new Map(); for (const k of keys) { const box = await cacheGet(k); if (!Array.isArray(box)) continue; for (const row of box) { const name = row?.name; if (!name) continue; const stat = projectBoxRow(sport, row); if (!stat) continue; if (!byPlayer.has(name)) { byPlayer.set(name, { name, playerId: row.playerId ?? null, team: row.team ?? null, games: [] }); } byPlayer.get(name).games.push(stat); } } return Array.from(byPlayer.values()); } /** * Returns [{ name, playerId, team, games }] for a sport. Dedupes players * (the highest game-count key wins) so one player isn't double-counted * across `:10` / `:20` cache variants. */ async function loadRosterLogs(sport) { const key = String(sport || '').toLowerCase(); if (!key) return []; // Fast path — a prefetched roster blob. const blob = await cacheGet(`rosterlogs:${key}`); if (Array.isArray(blob) && blob.length > 0) return blob; // Per-player game-log keys (NBA/WNBA grading flow writes these). const keys = await scanGameLogKeys(key); const byPlayer = new Map(); for (const k of keys) { const name = playerFromKey(k, key); if (!name) continue; const games = await cacheGet(k); if (!Array.isArray(games) || games.length === 0) continue; const existing = byPlayer.get(name); if (!existing || games.length > existing.games.length) { const playerId = games[0]?.playerId ?? games[0]?.player_id ?? null; const team = games[0]?.team ?? games[0]?.teamAbv ?? null; byPlayer.set(name, { name, playerId, team, games }); } } if (byPlayer.size > 0) return Array.from(byPlayer.values()); // Session 25 — fall back to the Tank01 box-score cache the prefetch // writes. Closes the key-alignment gap that left MLB streaks empty. return aggregateBoxScores(key); } module.exports = { loadRosterLogs, __internals: { playerFromKey, scanGameLogKeys, scanKeys, boxScoreKeyDate, projectBoxRow, aggregateBoxScores, }, };