7969a4971a
Frontend + wiring only. Wires existing backend into the pages users see. - VYNDR Original archetype rename (41) across archetypeService.js + lib/ archetypes.js + ArchetypeBadge, each keeping legacyName (resolves stale data). Judge -> BOMBER. Old POWER PULL slot -> WHIFF strikeout-artist pitcher. - BACKEND_HANDOFF.md: canonical frontend<->backend data contract. - Grade card intel: scan/page.tsx now forwards the engine's intel fields (season_avg/form/usage/matchup_grade/archetype/...) into mapScanToGradeResult -> STAT CONTEXT + VYNDR INTELLIGENCE sections populate. The chain already preserved them (tierGating + /api/scan spread); the page was dropping them. - Schedule freshness: slateAdapter.isRelevantGame drops completed games >24h old; Slate.filteredGames applies it. (TTL already 60s.) - Landing: Features.tsx rewritten to user-facing copy (no Point-biserial/Zone 14/ABS/Phi-coefficient). - Depth chart Next proxies added (/api/stats/lineup|depth|cascade) - were 404. - GameCard swap DEFERRED (Kev): legacy on-demand card stays as a bridge until the snapshot pipeline populates the grades cache; vyndr/GameCard swaps in then. Backend 2045 -> 2061 tests (+16), 167 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
132 lines
5.3 KiB
JavaScript
132 lines
5.3 KiB
JavaScript
/**
|
|
* 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 },
|
|
};
|