54fa5853f5
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>
359 lines
14 KiB
JavaScript
359 lines
14 KiB
JavaScript
/**
|
||
* 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 (26–1 with an en-dash, or 26–1–0 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 || '')}®ions=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,
|
||
};
|