/** * depthChartService — lineup / depth-chart / cascade foundation (Session 43). * * Provides the data model + graceful aggregation for: * - getLineup(sport, team) — tonight's projected lineup / starters * - getDepthChart(sport, team) — starters + backups per position * - getCascadeProjection(...) — "when X is OUT, teammate Y gets +delta" * * Sources (all best-effort, injectable for tests, never throws): * - mlbStatsAdapter.getScheduleWithPitchers → probable pitchers (MLB) * - scheduleService.getGameSummary → ESPN injuries / leaders * * This is the FOUNDATION: contracts + real data where it's freely available, * graceful empty defaults elsewhere. The minutes/usage projection model and * full batting orders arrive with the Session-44/45 pipelines. */ const norm = (s) => String(s == null ? '' : s).trim().toUpperCase(); /** Today's UTC date (YYYY-MM-DD). Injectable for deterministic tests. */ function todayISO(now) { return (now || new Date()).toISOString().slice(0, 10); } /** * Tonight's projected lineup for a team. MLB returns the probable starting * pitcher (the one lineup slot the free schedule feed exposes); other sports * fall back to the ESPN summary leaders when a game is found. Always returns an * array (possibly empty). */ async function getLineup(sport, team, opts = {}) { const sp = String(sport || 'nba').toLowerCase(); const t = norm(team); if (!t) return []; try { if (sp === 'mlb') { const mlb = opts.mlbAdapter || require('./adapters/mlbStatsAdapter'); const date = opts.date || todayISO(opts.now); const games = await mlb.getScheduleWithPitchers(date); const game = (games || []).find((g) => matchesTeam(g.home, t) || matchesTeam(g.away, t)); if (!game) return []; const side = matchesTeam(game.home, t) ? game.home : game.away; const out = []; if (side?.probablePitcher?.name) { out.push({ player: side.probablePitcher.name, position: 'SP', battingOrder: null, projectedMinutes: null }); } return out; } } catch (err) { console.warn('[depthChart] getLineup failed:', sport, team, err.message); } return []; } function matchesTeam(side, t) { if (!side || !t) return false; const name = norm(side.team || side.name); if (!name) return false; const lastWord = name.split(' ').pop(); return name === t || name.includes(t) || (lastWord.length >= 3 && t.includes(lastWord)); } /** * Depth chart for a team: positions with starter/backup/thirdString. When no * roster source is wired, returns a valid empty structure (graceful default). * Pass opts.roster ([{player, position, depth}]) to build a real chart. */ async function getDepthChart(sport, team, opts = {}) { const sp = String(sport || 'nba').toLowerCase(); const t = norm(team); const base = { sport: sp, team: t, positions: [] }; const roster = Array.isArray(opts.roster) ? opts.roster : null; if (!roster) return base; const byPos = {}; for (const r of roster) { const pos = norm(r.position) || 'UTIL'; (byPos[pos] = byPos[pos] || []).push(r); } base.positions = Object.entries(byPos).map(([position, players]) => { const sorted = players.slice().sort((a, b) => (a.depth || 99) - (b.depth || 99)); return { position, starter: sorted[0]?.player || null, backup: sorted[1]?.player || null, thirdString: sorted[2]?.player || null, }; }); return base; } /** * Cascade projection: what happens to teammates' production when `player` is * OUT. Foundation heuristic — uses the design's archetype cascade weights * (usage sponges benefit most). Returns [] when we can't establish that the * player is actually out or have no teammates to project onto. * opts.teammates: [{player, archetype, baseUsage}] — injected by the caller. */ async function getCascadeProjection(sport, player, team, opts = {}) { const teammates = Array.isArray(opts.teammates) ? opts.teammates : []; if (!player || teammates.length === 0) return []; // Distribute a fixed usage pool across teammates, weighting usage sponges and // high-usage creators heavier (mirrors the design's cascade framing). const POOL = Number.isFinite(opts.usagePool) ? opts.usagePool : 12; // % // VYNDR Originals (Session 44): SURGE (usage sponge) benefits most; primary // creators (TORCH/DUAL THREAT/SWITCHBOARD) next; low-usage glue least. const weightFor = (a) => { const k = norm(a); if (k === 'SURGE' || k.includes('USAGE SPONGE')) return 3; if (['TORCH', 'DUAL THREAT', 'SWITCHBOARD', 'IGNITER'].includes(k) || k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2; if (k === 'CONNECTOR' || k === 'LOCKDOWN' || k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5; return 1; }; const weights = teammates.map((m) => weightFor(m.archetype)); const total = weights.reduce((s, w) => s + w, 0) || 1; return teammates.map((m, i) => { const delta = +((POOL * weights[i]) / total).toFixed(1); return { player: m.player, stat: 'usage', delta: `+${delta}%`, reason: `${player} OUT → ${m.player} absorbs touches`, }; }).sort((a, b) => parseFloat(b.delta) - parseFloat(a.delta)); } module.exports = { getLineup, getDepthChart, getCascadeProjection, _internals: { norm, matchesTeam, todayISO }, };