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>
This commit is contained in:
Kev
2026-07-13 16:58:22 -04:00
parent 016758e014
commit 54fa5853f5
22 changed files with 1525 additions and 18 deletions
+6
View File
@@ -152,6 +152,12 @@ app.use('/api/widget', widgetRoutes);
// stat-filtered views over all of them.
const scheduleRoutes = require('./routes/schedule');
app.use('/api/schedule', scheduleRoutes);
// Wave 6 — combat intelligence (MMA/UFC): fight cards + tale-of-the-tape +
// best-effort ML/round-total odds. Read-only, cache-friendly, honest empty
// off-card. NOT in the graded-props pipeline (no settled grades in v1).
const combatRoutes = require('./routes/combat');
app.use('/api/combat', combatRoutes);
app.use('/api/fight', combatRoutes.fightRouter);
// Session 45 — live ticker feed (snapshot exhaust + editorial pins). Public,
// cache-only, never triggers a snapshot.
const tickerRoutes = require('./routes/ticker');
+5 -1
View File
@@ -41,7 +41,11 @@ const SPORTS = Object.freeze({
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
// Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards +
// tale-of-the-tape + style blend + ML/round-total odds). It is NOT in the
// graded-props pipeline (SPORT_CONFIG) or the snapshot/settle loop yet, so
// collectData stays false — active flips true so the UI treats it as live.
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false },
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
});
+81
View File
@@ -0,0 +1,81 @@
/**
* /api/combat/:date + /api/fight/:id (Wave 6 — combat intelligence, honest v1).
*
* Fight-card discovery + tale-of-the-tape + best-effort moneyline / round-total
* odds. FREE ESPN MMA feed for the cards; the paid odds-api feed for ML/round
* totals is BEST-EFFORT + cached (never fails the card). Combat is NOT in the
* snapshot/settle loop → NO settled grades here; method/round/KO are surfaced by
* the frontend as honest "— data-limited", never fabricated.
*
* Response (cards):
* { date, events: [ { id, name, shortName, date, venue,
* bouts: [ { id, weightClass, rounds, status, fighters: [ …tale ],
* odds: { moneyline, roundTotal } | null } ] } ], source }
*/
const express = require('express');
const combat = require('../services/adapters/combatAdapter');
const { createRateLimit } = require('../middleware/rateLimit');
const router = express.Router();
// Public throttle (60/min; ESPN is free, odds are cached — be respectful).
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Styles make fights' };
// Attach cached/best-effort odds onto each bout of each event.
function attachOdds(events, oddsMap) {
for (const ev of events || []) {
for (const bout of ev.bouts || []) {
const o = combat.matchBoutOdds(bout, oddsMap);
bout.odds = o ? { moneyline: o.moneyline, roundTotal: o.roundTotal } : null;
}
}
return events;
}
// GET /api/combat/:date — fight cards for an ET date (defaults to today).
router.get('/:date', async (req, res) => {
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(req.params.date || ''))
? req.params.date
: combat.todayET();
try {
const [cards, oddsMap] = await Promise.all([
combat.getFightCards(date),
combat.getCombatOdds().catch(() => ({})),
]);
attachOdds(cards.events, oddsMap);
res.set('Cache-Control', 'public, max-age=300');
return res.set(MISSION_HEADER).json(cards);
} catch (err) {
console.error('[combat/:date]', err && err.message);
// The board is never a crash — honest empty on failure.
return res.set(MISSION_HEADER).json({ date, events: [], source: 'espn' });
}
});
module.exports = router;
// Separate router for /api/fight/:id (a single card by event id).
const fightRouter = express.Router();
fightRouter.use(createRateLimit({ windowMs: 60_000, max: 60 }));
fightRouter.get('/:id', async (req, res) => {
const id = String(req.params.id || '').replace(/[^0-9]/g, '');
if (!id) return res.status(404).set(MISSION_HEADER).json({ error: 'not found' });
try {
const [card, oddsMap] = await Promise.all([
combat.getFightCard(id),
combat.getCombatOdds().catch(() => ({})),
]);
if (!card) return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' });
attachOdds([card], oddsMap);
res.set('Cache-Control', 'public, max-age=300');
return res.set(MISSION_HEADER).json({ event: card, source: 'espn' });
} catch (err) {
console.error('[fight/:id]', err && err.message);
return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' });
}
});
module.exports.fightRouter = fightRouter;
+358
View File
@@ -0,0 +1,358 @@
/**
* 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,
};
+182 -3
View File
@@ -272,7 +272,40 @@ const ARCHETYPES = {
},
};
// ── Combat archetype registry (Wave 6 — MMA/UFC) ────────────────────
// PINNED by specs/combat-intelligence.md. Kept in a SEPARATE registry
// (NOT merged into ARCHETYPES) for two reasons:
// 1. FINISHER collides with the soccer archetype name — combat FINISHER
// is a DIFFERENT color/glyph, and the global getArchetype()/ARCHETYPES
// lookup is keyed by uppercase name with no sport dimension.
// 2. Combat FINISHER's green (#12B886) sits close to the signal green,
// which the colorContract gate forbids for shared player archetypes.
// Isolating combat keeps that gate (edge-green purity) intact while
// honoring the pinned combat palette.
// The frontend mirror lives in web/src/lib/archetypes.js COMBAT_ARCHETYPE_MAP;
// tests/unit/combatArchetypes.test.js asserts the two agree (colors + glyphs),
// same discipline as the cross-sport color-match test.
const COMBAT_ARCHETYPES = {
STRIKER: { tag: 'STRIKER', sport: 'mma', color: '#E8703A', glyph: '✦', axis: 'range', description: 'Wins on the feet — volume + power at range.' },
GRAPPLER: { tag: 'GRAPPLER', sport: 'mma', color: '#2FA4E7', glyph: '⊗', axis: 'range', description: 'Fight hits the mat on his terms — control + subs.' },
PRESSURE: { tag: 'PRESSURE', sport: 'mma', color: '#E4574C', glyph: '➤', axis: 'tempo', description: 'Forward, relentless, breaks the pace.' },
COUNTER: { tag: 'COUNTER', sport: 'mma', color: '#8E7BE0', glyph: '◊', axis: 'tempo', description: 'Patient — punishes what you show him.' },
FINISHER: { tag: 'FINISHER', sport: 'mma', color: '#12B886', glyph: '▲', axis: 'outcome', description: 'Ends nights — high KO/SUB rate.' },
GRINDER: { tag: 'GRINDER', sport: 'mma', color: '#B0883B', glyph: '▦', axis: 'outcome', description: 'Goes the distance, wins the rounds.' },
};
// Discipline pedigree tags — VERIFIABLE credentials only (rendered separately
// from the archetype blend). Never inferred/guessed: absent when unknown.
const DISCIPLINE_PEDIGREES = [
'Combat Sambo', 'Dagestan Wrestling', 'BJJ', 'Wrestling Base',
'Kickboxing', 'Muay Thai', 'Boxing',
];
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
// Strict presence check — an ABSENT stat must not score an axis (Number(null)
// === 0 would fabricate a "0 output" claim). Only a real finite number counts.
const has = (v) => typeof v === 'number' && Number.isFinite(v);
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
/** NBA scorers — VYNDR Original keys. */
function scoreNBA(s) {
@@ -358,7 +391,76 @@ function scoreMLB(s) {
};
}
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB };
/**
* MMA fighter scorer. Style is a BLEND across three axes:
* range → STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs)
* tempo → PRESSURE (forward volume) ↔ COUNTER (patient, high defense)
* outcome → FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance)
*
* Best-effort from what the ESPN feed exposes; ESPN's striking/grappling
* granularity is THINNER than ufcstats. Every axis scores ONLY when its
* inputs are real finite numbers — thin data yields fewer style claims,
* never a fabricated one. Inputs (all optional):
* slpm/sapm sig strikes landed/absorbed per min
* strAcc/strDef striking accuracy / defense (0-1)
* tdAvg takedowns per 15
* subAvg sub attempts per 15
* koRate/subRate/decRate fraction of WINS by method (0-1)
* koWins/subWins/decWins method counts (rates derived if rates absent)
*/
function scoreMMA(s = {}) {
const out = {};
// ── range axis ──
if (has(s.slpm)) {
let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite
if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15;
if (v > 0) out.STRIKER = clamp01(v);
}
if (has(s.tdAvg) || has(s.subAvg)) {
let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 ≈ elite control
if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5;
if (v > 0) out.GRAPPLER = clamp01(v);
}
// ── tempo axis ──
if (has(s.slpm) && has(s.sapm)) {
const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure
if (vol > 0) out.PRESSURE = vol;
}
if (has(s.strDef) || has(s.strAcc)) {
let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0;
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4;
if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter
if (v > 0) out.COUNTER = clamp01(v);
}
// ── outcome axis ──
const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s);
const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s);
const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s);
if (koR != null || subR != null) {
const finish = (koR || 0) + (subR || 0);
if (finish > 0) out.FINISHER = clamp01(finish);
}
if (decR != null && decR > 0) out.GRINDER = clamp01(decR);
return out;
}
// Derive a method rate from a win count when explicit rates are absent.
// Returns null (not 0) when totals are unknown — absent, never fabricated.
function deriveRate(count, s) {
if (!has(count)) return null;
const total = has(s.totalWins)
? s.totalWins
: (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0);
if (!total || total <= 0) return null;
return clamp01(count / total);
}
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA };
/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */
function getArchetype(name) {
@@ -370,6 +472,13 @@ function getArchetype(name) {
return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null;
}
/** Look up a COMBAT archetype descriptor by name (case-insensitive). */
function getCombatArchetype(name) {
if (!name) return null;
const key = String(name).toUpperCase();
return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null;
}
/**
* Classify a player. Returns:
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
@@ -384,7 +493,12 @@ function classify(sport, stats = {}) {
.filter(([, v]) => v > 0)
.sort((a, b) => b[1] - a[1]);
// Combat is HONEST-empty on thin data: no forced fallback archetype (the
// other sports fall back to a low-usage role, but inventing a fighter's
// style from no data would be a fabrication — spec §STYLE-MATCHUP).
const resolver = sp === 'mma' ? getCombatArchetype : getArchetype;
if (ranked.length === 0) {
if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] };
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
}
@@ -393,23 +507,88 @@ function classify(sport, stats = {}) {
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
const primary = getArchetype(ranked[0][0]);
const primary = resolver(ranked[0][0]);
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
? getArchetype(ranked[1][0])
? resolver(ranked[1][0])
: null;
return { sport: sp, primary, secondary, blend };
}
/** Weight of an archetype within a blend (0 when absent). */
function blendWeight(blend, name) {
const hit = (blend || []).find((b) => b.archetype === name);
return hit ? hit.weight : 0;
}
// Accept either a classify() result ({ blend }) or a raw blend array.
function asBlend(x) {
if (Array.isArray(x)) return x;
if (x && Array.isArray(x.blend)) return x.blend;
return [];
}
/**
* styleMatchup(a, b) — a DESCRIPTIVE MODEL style-edge read (the mockup's
* CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated
* confidence. Compares two style blends; when the data is too thin or the
* styles are too close to call, it says so honestly.
*
* a/b may be classify('mma', …) results or raw blend arrays.
* Returns { verdict, edgeSide: 'a'|'b'|null, summary }.
*/
const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER'];
const MIN_EDGE = 0.2; // below this stylistic gap → too close to call
function styleMatchup(a, b) {
const A = asBlend(a);
const B = asBlend(b);
if (A.length === 0 || B.length === 0) {
return {
verdict: 'INSUFFICIENT READ',
edgeSide: null,
summary: 'Not enough style data to call this matchup — a MODEL read needs both fighters profiled.',
};
}
let best = null;
for (const ax of STYLE_AXES) {
const diff = blendWeight(A, ax) - blendWeight(B, ax);
if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff };
}
if (!best || Math.abs(best.diff) < MIN_EDGE) {
return {
verdict: 'STYLES EVEN',
edgeSide: null,
summary: 'Two closely matched styles — no clear stylistic edge. A MODEL read, not a graded pick.',
};
}
const edgeSide = best.diff > 0 ? 'a' : 'b';
return {
verdict: `${best.ax} EDGE`,
edgeSide,
summary: `${best.ax} advantage tilts this on style — a MODEL read, not a settled grade.`,
};
}
const classifyNBA = (stats) => classify('nba', stats);
const classifyWNBA = (stats) => classify('wnba', stats);
const classifyMLB = (stats) => classify('mlb', stats);
const classifyMMA = (stats) => classify('mma', stats);
module.exports = {
ARCHETYPES,
COMBAT_ARCHETYPES,
DISCIPLINE_PEDIGREES,
getArchetype,
getCombatArchetype,
classify,
classifyNBA,
classifyWNBA,
classifyMLB,
classifyMMA,
styleMatchup,
};
+13
View File
@@ -56,6 +56,11 @@ const SPORT_KEYS = {
// keys added in Session 31; NHL keys were added alongside this wiring.
nfl: 'americanfootball_nfl',
nhl: 'icehockey_nhl',
// MMA / UFC (Wave 6 — combat intelligence). odds-api sport key for the
// moneyline + round-total feed. PropLine carries NO combat → odds-api-only
// (combat never enters the abundant player-props path). Off-card windows
// return an empty events array and the combat surface self-hides honestly.
mma: 'mma_mixed_martial_arts',
// Soccer (Session 7j) — odds-api sport keys verified against
// https://the-odds-api.com/sports-odds-data/sports-apis.html
soccer_wc: 'soccer_fifa_world_cup',
@@ -142,6 +147,10 @@ const SOCCER_MARKETS = [
'player_passes',
'team_clean_sheet',
];
// MMA / UFC (Wave 6) — GAME-level markets only: moneyline (h2h) + round total
// (totals). No player props on the free feed → NO 'spreads' suffix (unlike the
// player-prop sports, whose buildMarketString appends spreads).
const MMA_MARKETS = ['h2h', 'totals'];
function buildMarketString(markets) {
return [...markets, 'spreads'].join(',');
@@ -155,6 +164,8 @@ const SPORT_MARKETS = Object.freeze({
mlb: buildMarketString(MLB_MARKETS),
nfl: buildMarketString(NFL_MARKETS),
nhl: buildMarketString(NHL_MARKETS),
// MMA carries no player-prop spreads → join the game-level markets directly.
mma: MMA_MARKETS.join(','),
ncaab: buildMarketString(NBA_MARKETS), // NCAAB markets mirror NBA
// Every soccer league code shares the same market set.
...Object.fromEntries(
@@ -499,6 +510,8 @@ module.exports = {
getCacheKey,
SPORT_KEYS,
SOCCER_SPORT_KEYS,
// Wave 6 — combat game-level markets (moneyline + round total).
MMA_MARKETS,
// Session 16 — per-sport market scoping.
SPORT_MARKETS,
getMarketsForSport,
+10
View File
@@ -83,6 +83,16 @@ const MARKET_MAP = {
// are shared with soccer/NBA — sport context discriminates downstream.
player_shots_on_goal: 'shots_on_goal',
goalie_saves: 'saves',
// MMA / UFC (Wave 6 — combat intelligence). Game-level markets, NOT player
// props: h2h = moneyline (per fighter), totals = round total (over/under).
// Mapped here so combat odds don't silently normalize to zero when wired
// through the odds-api path (same silent-failure class as the MLB/NHL gaps).
// The combat surface parses these via combatAdapter.normalizeCombatOdds —
// normalizeProps (this file) is player-prop-shaped and skips them, which is
// correct: MMA carries no per-player point props on the free feed.
h2h: 'moneyline',
totals: 'round_total',
};
function normalizeProps(eventsWithOdds) {