Files
vyndr/src/services/adapters/combatAdapter.js
T
builtbykev 54fa5853f5 Wave 6: Combat Intelligence Layer (honest free v1)
Net-new MMA/UFC vertical — fight-card discovery, tale-of-the-tape,
style-blend archetypes, ML + round-total odds, and a style-edge VERDICT
(a MODEL read, explicitly NOT a settled grade). Built to
specs/combat-intelligence.md.

Backend:
- combatAdapter: ESPN MMA scoreboard (date-pinned, free JSON) -> fight
  cards + tale-of-tape (record/weight class/rounds/ESPN athlete id);
  defensive parse (null on unknown shape, never throws); injectable
  fetchImpl + cache; pure normalizeCombatOdds (odds-api h2h/totals ->
  ML + round total, allow-listed books, best price). Number(null) guard.
- archetypeService: 6 pinned combat styles in a SEPARATE COMBAT_ARCHETYPES
  registry (FINISHER collides with soccer + its green trips the signal-
  green gate); classify('mma') blends range/tempo/outcome, honest-empty on
  thin data (no forced fallback); styleMatchup() honest verdict.
- oddsService: SPORT_KEYS.mma + MMA_MARKETS=['h2h','totals'] + SPORT_MARKETS
  (no spreads suffix). oddsNormalizer MARKET_MAP h2h/totals.
- config/sports.js + web mirror: mma.active=true (collectData stays false;
  NOT in the graded-props pipeline SPORT_CONFIG or snapshot/settle loop).
- routes/combat.js: GET /api/combat/:date + GET /api/fight/:id (public,
  cached, honest empty off-card) + Next proxies.

Frontend:
- FightCard: two-fighter tale-of-the-tape (initials monogram — no photos),
  GRAPPLER/STRIKER blend bars, discipline pedigree tags, shared
  ArchetypeBadge (sport="mma", unicode glyphs), CENTER VERDICT, ML +
  round-total real; method/round/KO = honest "data-limited", never
  fabricated. Self-hides on a non-two-fighter bout.
- /fight/[id] page (server wrapper + client), EmptyState off-season.
- MMA SportBadge token (#D4AF37); archetypes.js sport-aware resolution.

DEFERRED (per spec, NOT built): matchup-GRADE engine, method/round/props
board, combat settlement, ufcstats scraping.

Tests: +3 suites (31 tests) — combat archetype cross-file color/glyph
match, classify blends, styleMatchup honesty, adapter defensive parse +
odds normalize, FightCard honesty grep; extended oddsNormalizer +
sportMarkets. Full suite 253/253 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:58:22 -04:00

359 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* combatAdapter — ESPN MMA/UFC (FREE JSON, no auth) → normalized fight cards
* + tale-of-the-tape (Wave 6, combat intelligence).
*
* DATA SEMANTICS: fighter records / physicals are REAL sourced facts. We never
* fabricate. `Number(null) === 0` is the trap — an absent stat (reach, stance,
* finish counts) stays ABSENT (null), never coerced to 0. ESPN's MMA striking/
* grappling granularity is THINNER than ufcstats; when a field is absent the
* tape says less, never invents.
*
* Source: site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard
* - The scoreboard `events[]` are UFC CARDS. Each event carries many
* `competitions[]` — one per BOUT. Each bout has 2 competitors (fighters)
* with athlete name, W-L-D record, weight class, scheduled rounds, and the
* ESPN athlete id (parsed from the player-card link href). Stance/reach are
* NOT in the free scoreboard → left null (absent), wired for a later enrich.
*
* Odds (moneyline + round total) come from the odds-api MMA feed and are parsed
* by the PURE `normalizeCombatOdds` here (odds-api event shape → per-bout ML +
* round total). VYNDR never generates odds — these are REAL book numbers.
*
* Everything is defensive: an unrecognized shape yields an empty result, never
* a throw. `fetchImpl` is injectable so tests never touch the network.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const { ALLOWED_BOOKS } = require('../../utils/oddsNormalizer');
const ESPN_MMA_SCOREBOARD = 'https://site.api.espn.com/apis/site/v2/sports/mma/ufc/scoreboard';
const HTTP_TIMEOUT_MS = 10_000;
const CARDS_TTL = 15 * 60; // 15 min — cards move slowly; keep it cheap
const STALE_TTL = 6 * 3600; // stale-while-error fallback
/** Real finite number or null — the absent-beats-zero guard. */
function numOrNull(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** Today's ET date (YYYY-MM-DD). Fight days roll on ET like the other sports. */
function todayET() {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(new Date());
}
/** ET date (YYYY-MM-DD) of an ISO timestamp, or null if unparseable. */
function dateET(iso) {
if (!iso) return null;
const t = new Date(iso);
if (Number.isNaN(t.getTime())) return null;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(t);
}
/** Extract the ESPN athlete id from a fighter's link hrefs (…/id/4801725/…). */
function parseAthleteId(links) {
if (!Array.isArray(links)) return null;
for (const l of links) {
const m = /\/id\/(\d+)\//.exec(l && l.href ? String(l.href) : '');
if (m) return m[1];
}
return null;
}
/** Parse a "W-L-D" record summary into structured parts. Absent → nulls. */
function parseRecord(competitor) {
const recs = competitor && Array.isArray(competitor.records) ? competitor.records : [];
const overall = recs.find((r) => r && (r.type === 'total' || r.name === 'overall')) || recs[0];
const summary = overall && typeof overall.summary === 'string' ? overall.summary : null;
let wins = null, losses = null, draws = null;
if (summary) {
const m = /^(\d+)\s*-\s*(\d+)(?:\s*-\s*(\d+))?/.exec(summary.trim());
if (m) {
wins = numOrNull(m[1]);
losses = numOrNull(m[2]);
draws = m[3] != null ? numOrNull(m[3]) : 0;
}
}
// Display form (261 with an en-dash, or 2610 when a draw exists).
let display = null;
if (wins != null && losses != null) {
display = draws ? `${wins}${losses}${draws}` : `${wins}${losses}`;
}
return { wins, losses, draws, summary, display };
}
/** Normalize ONE fighter (an ESPN competitor). Defensive; unknown fields null. */
function normalizeFighter(competitor) {
if (!competitor || typeof competitor !== 'object') return null;
const a = competitor.athlete || {};
const name = a.displayName || a.fullName || a.shortName || null;
if (!name) return null;
return {
id: parseAthleteId(a.links),
name,
shortName: a.shortName || null,
record: parseRecord(competitor),
winner: competitor.winner === true ? true : (competitor.winner === false ? false : null),
// Physicals absent from the free scoreboard — kept for a future enrich.
stance: null,
reach: null,
};
}
/** Normalize ONE bout (an ESPN competition). Returns null on an unusable shape. */
function normalizeBout(comp) {
if (!comp || typeof comp !== 'object') return null;
const competitors = Array.isArray(comp.competitors) ? comp.competitors : [];
if (competitors.length < 2) return null;
const ordered = [...competitors].sort((x, y) => (x.order ?? 0) - (y.order ?? 0));
const fighters = ordered.map(normalizeFighter).filter(Boolean);
if (fighters.length < 2) return null;
const st = comp.status && comp.status.type ? comp.status.type : {};
return {
id: comp.id != null ? String(comp.id) : null,
weightClass: (comp.type && (comp.type.text || comp.type.abbreviation)) || null,
rounds: numOrNull(comp.format && comp.format.regulation && comp.format.regulation.periods),
status: st.state || null, // pre | in | post
completed: st.completed === true,
fighters,
};
}
/**
* Normalize a raw ESPN scoreboard payload into VYNDR fight cards. PURE +
* defensive — a shape it doesn't recognize returns { events: [] }, never throws.
*/
function normalizeScoreboard(raw, { date } = {}) {
const events = raw && Array.isArray(raw.events) ? raw.events : [];
const cards = [];
for (const ev of events) {
if (!ev || typeof ev !== 'object') continue;
const evDate = ev.date || null;
// Date-pin defensively (same discipline as scheduleService S57): when a
// date is requested, only events on that ET date survive.
if (date && dateET(evDate) !== date) continue;
const comps = Array.isArray(ev.competitions) ? ev.competitions : [];
const bouts = comps.map(normalizeBout).filter(Boolean);
if (bouts.length === 0 && !ev.id) continue;
const comp0 = comps[0] || {};
cards.push({
id: ev.id != null ? String(ev.id) : null,
name: ev.name || null,
shortName: ev.shortName || null,
date: evDate,
dateET: dateET(evDate),
venue: (comp0.venue && (comp0.venue.fullName || comp0.venue.shortName)) || null,
bouts,
});
}
return { events: cards };
}
async function doFetch(url, fetchImpl) {
if (fetchImpl) return fetchImpl(url);
const res = await axios.get(url, { timeout: HTTP_TIMEOUT_MS });
return res.data;
}
/**
* getFightCards(date, opts) — cache-aside UFC cards for an ET date.
* opts: { fetchImpl, cacheGet, cacheSet }. NO odds-api credits (ESPN is free).
* Off-card windows return { events: [] } (honest empty), never a throw.
*/
async function getFightCards(date = todayET(), opts = {}) {
const cGet = opts.cacheGet || cacheGet;
const cSet = opts.cacheSet || cacheSet;
const key = `combat:cards:${date}`;
try {
const cached = await cGet(key);
if (cached && Array.isArray(cached.events)) return { ...cached, source: 'cache' };
} catch { /* cache miss → live */ }
try {
const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?';
const url = `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(date).replace(/-/g, '')}`;
const raw = await doFetch(url, opts.fetchImpl);
const normalized = normalizeScoreboard(raw, { date });
const payload = { date, events: normalized.events, source: 'espn' };
try { await cSet(key, payload, CARDS_TTL); } catch { /* best-effort */ }
return payload;
} catch (err) {
// Stale-while-error, else honest empty.
try {
const stale = await cGet(key);
if (stale && Array.isArray(stale.events)) return { ...stale, source: 'stale' };
} catch { /* ignore */ }
console.warn('[combatAdapter] getFightCards failed:', err && err.message);
return { date, events: [], source: 'espn' };
}
}
/**
* getFightCard(eventId, opts) — one UFC card (all its bouts). The scoreboard
* carries every bout, so we locate the event by id. opts may pass `date` to
* pin the scoreboard fetch. Returns null when the id isn't found.
*/
async function getFightCard(eventId, opts = {}) {
const id = String(eventId || '');
if (!id) return null;
const cGet = opts.cacheGet || cacheGet;
const cSet = opts.cacheSet || cacheSet;
try {
const sep = ESPN_MMA_SCOREBOARD.includes('?') ? '&' : '?';
const url = opts.date
? `${ESPN_MMA_SCOREBOARD}${sep}dates=${String(opts.date).replace(/-/g, '')}`
: ESPN_MMA_SCOREBOARD;
const key = `combat:card:${id}`;
if (!opts.fetchImpl) {
try {
const cached = await cGet(key);
if (cached && cached.id) return { ...cached, source: 'cache' };
} catch { /* miss */ }
}
const raw = await doFetch(url, opts.fetchImpl);
// Don't date-filter here — we're locating a specific event by id.
const normalized = normalizeScoreboard(raw, {});
const card = normalized.events.find((e) => e.id === id) || null;
if (card) { try { await cSet(key, card, CARDS_TTL); } catch { /* ignore */ } }
return card;
} catch (err) {
console.warn('[combatAdapter] getFightCard failed:', err && err.message);
return null;
}
}
/** Best (highest) American-odds price wins for a given side. Absent → null. */
function bestPrice(current, price) {
const p = numOrNull(price);
if (p == null) return current;
if (current == null) return p;
return p > current ? p : current; // best payout for the bettor
}
/**
* normalizeCombatOdds(eventsWithOdds) — PURE parse of the odds-api MMA event
* odds array into per-bout moneyline + round total. VYNDR never generates
* these — they are REAL book numbers. Only allow-listed US books count.
* Returns a map keyed by matchup ("fighterA|fighterB", lowercased) so the
* combat surface can join odds to the ESPN bout. Absent market → absent side.
*/
function normalizeCombatOdds(eventsWithOdds) {
const out = {};
const list = Array.isArray(eventsWithOdds) ? eventsWithOdds : [];
for (const ev of list) {
if (!ev || typeof ev !== 'object') continue;
const home = ev.home_team || null;
const away = ev.away_team || null;
if (!home && !away) continue;
const ml = { home: null, away: null };
const roundTotal = { line: null, over: null, under: null };
const books = Array.isArray(ev.bookmakers) ? ev.bookmakers : [];
for (const bk of books) {
if (!bk || !ALLOWED_BOOKS.has(bk.key)) continue;
const markets = Array.isArray(bk.markets) ? bk.markets : [];
for (const mk of markets) {
const outcomes = Array.isArray(mk && mk.outcomes) ? mk.outcomes : [];
if (mk.key === 'h2h') {
for (const o of outcomes) {
if (o.name === home) ml.home = bestPrice(ml.home, o.price);
else if (o.name === away) ml.away = bestPrice(ml.away, o.price);
}
} else if (mk.key === 'totals') {
for (const o of outcomes) {
const point = numOrNull(o.point);
if (point == null) continue;
if (roundTotal.line == null) roundTotal.line = point;
// Only pair the primary posted line (first seen).
if (point !== roundTotal.line) continue;
if (o.name === 'Over') roundTotal.over = bestPrice(roundTotal.over, o.price);
else if (o.name === 'Under') roundTotal.under = bestPrice(roundTotal.under, o.price);
}
}
}
}
const key = `${String(home || '').toLowerCase()}|${String(away || '').toLowerCase()}`;
out[key] = {
eventId: ev.id != null ? String(ev.id) : null,
home,
away,
commence_time: ev.commence_time || null,
moneyline: ml,
roundTotal: (roundTotal.over != null || roundTotal.under != null) ? roundTotal : null,
};
}
return out;
}
const ODDS_API_MMA_ODDS = 'https://api.the-odds-api.com/v4/sports/mma_mixed_martial_arts/odds';
const ODDS_TTL = 30 * 60; // 30 min — combat ML/round totals move slowly; conserve odds-api credits
/**
* getCombatOdds(opts) — BEST-EFFORT, CACHED combat moneyline + round totals.
* Cache-aside on `combat:odds`; on a cold cache it hits the odds-api MMA BULK
* odds endpoint ONCE (h2h + totals in a single request) rather than per-event,
* to conserve the paid odds-api quota. No key / any error → {} (odds absent;
* cards still render, the ML cell shows an honest "—"). Never throws.
* opts: { fetchImpl, cacheGet, cacheSet, apiKey }.
*/
async function getCombatOdds(opts = {}) {
const cGet = opts.cacheGet || cacheGet;
const cSet = opts.cacheSet || cacheSet;
const key = 'combat:odds';
try {
const cached = await cGet(key);
if (cached && typeof cached === 'object' && !opts.fetchImpl) return cached;
} catch { /* miss → live */ }
const apiKey = opts.apiKey || process.env.ODDS_API_KEY;
if (!apiKey && !opts.fetchImpl) return {};
try {
const url = `${ODDS_API_MMA_ODDS}?apiKey=${encodeURIComponent(apiKey || '')}&regions=us&markets=h2h,totals&oddsFormat=american`;
const raw = await doFetch(url, opts.fetchImpl);
const map = normalizeCombatOdds(Array.isArray(raw) ? raw : []);
try { await cSet(key, map, ODDS_TTL); } catch { /* best-effort */ }
return map;
} catch (err) {
console.warn('[combatAdapter] getCombatOdds failed:', err && err.message);
return {};
}
}
/**
* matchBoutOdds(bout, oddsMap) — join the odds map onto an ESPN bout by fighter
* names (either orientation). Returns the odds record or null. Never throws.
*/
function matchBoutOdds(bout, oddsMap) {
if (!bout || !oddsMap || typeof oddsMap !== 'object') return null;
const fs = Array.isArray(bout.fighters) ? bout.fighters : [];
if (fs.length < 2) return null;
const a = String(fs[0].name || '').toLowerCase();
const b = String(fs[1].name || '').toLowerCase();
return oddsMap[`${a}|${b}`] || oddsMap[`${b}|${a}`] || null;
}
module.exports = {
// read paths
getFightCards,
getFightCard,
getCombatOdds,
matchBoutOdds,
// pure, tested transforms
normalizeScoreboard,
normalizeBout,
normalizeFighter,
normalizeCombatOdds,
parseAthleteId,
parseRecord,
// helpers
todayET,
dateET,
numOrNull,
ESPN_MMA_SCOREBOARD,
};