Merge Wave 6 (wiring/data): combat intelligence v1 (MMA)
ESPN-MMA fight cards + tale-of-the-tape + style-blend archetypes + odds-api ML/round-totals + style-edge verdict. Honest free v1 — no settled grades, method/round data-limited, no fighter photos, no scraping dep. +31 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
// Wave 6 — combatAdapter defensive parse. Fixtures modeled on the REAL ESPN
|
||||
// MMA scoreboard shape (site.api.espn.com/.../mma/ufc/scoreboard) captured
|
||||
// live during the build. NO network — fetchImpl + cache are injected.
|
||||
|
||||
const combat = require('../../src/services/adapters/combatAdapter');
|
||||
|
||||
// A trimmed but structurally-faithful ESPN MMA scoreboard payload: one UFC
|
||||
// event ("card") with two bouts. Athlete ids live in the player-card link href.
|
||||
const ESPN_FIXTURE = {
|
||||
events: [
|
||||
{
|
||||
id: '600059599',
|
||||
name: 'UFC Fight Night: Du Plessis vs. Usman',
|
||||
shortName: 'UFC Fight Night',
|
||||
date: '2026-07-18T21:00Z',
|
||||
competitions: [
|
||||
{
|
||||
id: '1',
|
||||
type: { id: '1007', abbreviation: 'W Flyweight', text: "Women's Flyweight" },
|
||||
format: { regulation: { periods: 5 } },
|
||||
venue: { fullName: 'UFC APEX' },
|
||||
status: { type: { state: 'pre', completed: false } },
|
||||
competitors: [
|
||||
{
|
||||
id: '10', order: 0, winner: false,
|
||||
athlete: {
|
||||
fullName: 'Dricus du Plessis', displayName: 'Dricus du Plessis', shortName: 'D. du Plessis',
|
||||
links: [{ href: 'https://www.espn.com/mma/fighter/_/id/4801725/dricus-du-plessis' }],
|
||||
},
|
||||
records: [{ name: 'overall', type: 'total', summary: '22-2-0' }],
|
||||
},
|
||||
{
|
||||
id: '11', order: 1, winner: false,
|
||||
athlete: {
|
||||
fullName: 'Kamaru Usman', displayName: 'Kamaru Usman', shortName: 'K. Usman',
|
||||
links: [{ href: 'https://www.espn.com/mma/fighter/_/id/3088843/kamaru-usman' }],
|
||||
},
|
||||
records: [{ name: 'overall', type: 'total', summary: '20-4-0' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: { abbreviation: 'Lightweight' },
|
||||
format: { regulation: { periods: 3 } },
|
||||
competitors: [
|
||||
{ id: '20', order: 0, athlete: { displayName: 'Fighter A', links: [] }, records: [{ type: 'total', summary: '10-0' }] },
|
||||
{ id: '21', order: 1, athlete: { displayName: 'Fighter B', links: [] }, records: [{ type: 'total', summary: '8-3' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('combatAdapter.normalizeScoreboard — ESPN shape → fight cards', () => {
|
||||
it('normalizes a real-shaped payload into a card with bouts + tale-of-tape', () => {
|
||||
const { events } = combat.normalizeScoreboard(ESPN_FIXTURE);
|
||||
expect(events).toHaveLength(1);
|
||||
const card = events[0];
|
||||
expect(card.id).toBe('600059599');
|
||||
expect(card.name).toMatch(/Du Plessis/);
|
||||
expect(card.bouts).toHaveLength(2);
|
||||
|
||||
const bout = card.bouts[0];
|
||||
expect(bout.weightClass).toBe("Women's Flyweight");
|
||||
expect(bout.rounds).toBe(5);
|
||||
expect(bout.fighters).toHaveLength(2);
|
||||
|
||||
const a = bout.fighters[0];
|
||||
expect(a.name).toBe('Dricus du Plessis');
|
||||
expect(a.id).toBe('4801725'); // parsed from the link href
|
||||
expect(a.record.wins).toBe(22);
|
||||
expect(a.record.losses).toBe(2);
|
||||
expect(a.record.display).toBe('22–2'); // en-dash display form
|
||||
// Physicals absent from the free feed → null, NEVER fabricated 0.
|
||||
expect(a.stance).toBeNull();
|
||||
expect(a.reach).toBeNull();
|
||||
});
|
||||
|
||||
it('date-pins defensively — an off-date event is dropped', () => {
|
||||
const onDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-07-18' });
|
||||
expect(onDate.events).toHaveLength(1);
|
||||
const offDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-01-01' });
|
||||
expect(offDate.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('DEFENSIVE: unrecognized/garbage shapes return empty, never throw', () => {
|
||||
expect(() => combat.normalizeScoreboard(null)).not.toThrow();
|
||||
expect(combat.normalizeScoreboard(null).events).toEqual([]);
|
||||
expect(combat.normalizeScoreboard({}).events).toEqual([]);
|
||||
expect(combat.normalizeScoreboard({ events: 'nope' }).events).toEqual([]);
|
||||
// An identifiable event with only unusable bouts is kept with empty bouts.
|
||||
expect(combat.normalizeScoreboard({ events: [{ id: 'x', competitions: [{ competitors: [{}] }] }] }).events[0].bouts).toEqual([]);
|
||||
// An event with no id AND no usable bouts is dropped entirely.
|
||||
expect(combat.normalizeScoreboard({ events: [{ competitions: [{ competitors: [{}] }] }] }).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('numOrNull never coerces null/empty to 0 (the fabrication trap)', () => {
|
||||
expect(combat.numOrNull(null)).toBeNull();
|
||||
expect(combat.numOrNull('')).toBeNull();
|
||||
expect(combat.numOrNull(undefined)).toBeNull();
|
||||
expect(combat.numOrNull('5')).toBe(5);
|
||||
expect(combat.numOrNull(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('parseAthleteId is defensive on bad links', () => {
|
||||
expect(combat.parseAthleteId(null)).toBeNull();
|
||||
expect(combat.parseAthleteId([{ href: 'no-id-here' }])).toBeNull();
|
||||
expect(combat.parseAthleteId([{ href: '/mma/fighter/_/id/999/x' }])).toBe('999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combatAdapter.getFightCards — injectable, no network', () => {
|
||||
it('fetches via injected fetchImpl + normalizes (cache stubbed)', async () => {
|
||||
const calls = [];
|
||||
const res = await combat.getFightCards('2026-07-18', {
|
||||
fetchImpl: async (url) => { calls.push(url); return ESPN_FIXTURE; },
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => true,
|
||||
});
|
||||
expect(res.events).toHaveLength(1);
|
||||
expect(calls[0]).toMatch(/dates=20260718/);
|
||||
});
|
||||
|
||||
it('returns honest empty on a fetch error, never throws', async () => {
|
||||
const res = await combat.getFightCards('2026-07-18', {
|
||||
fetchImpl: async () => { throw new Error('network'); },
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => true,
|
||||
});
|
||||
expect(res.events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combatAdapter.normalizeCombatOdds — odds-api MMA → ML + round total', () => {
|
||||
const ODDS_FIXTURE = [
|
||||
{
|
||||
id: 'evt1', commence_time: '2026-07-18T21:00Z',
|
||||
home_team: 'Dricus du Plessis', away_team: 'Kamaru Usman',
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
markets: [
|
||||
{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -230 }, { name: 'Kamaru Usman', price: 190 }] },
|
||||
{ key: 'totals', outcomes: [{ name: 'Over', price: -110, point: 2.5 }, { name: 'Under', price: -110, point: 2.5 }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'fanduel',
|
||||
markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -215 }, { name: 'Kamaru Usman', price: 200 }] }],
|
||||
},
|
||||
// A non-allow-listed book must be ignored.
|
||||
{ key: 'bovada', markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -999 }] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('maps h2h to per-fighter moneyline (best price) + totals to round total', () => {
|
||||
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
|
||||
const rec = map['dricus du plessis|kamaru usman'];
|
||||
expect(rec).toBeDefined();
|
||||
expect(rec.moneyline.home).toBe(-215); // best (higher) of -230 / -215
|
||||
expect(rec.moneyline.away).toBe(200); // best of 190 / 200
|
||||
expect(rec.roundTotal.line).toBe(2.5);
|
||||
expect(rec.roundTotal.over).toBe(-110);
|
||||
});
|
||||
|
||||
it('ignores non-allow-listed books (bovada never leaks a price)', () => {
|
||||
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
|
||||
expect(map['dricus du plessis|kamaru usman'].moneyline.home).not.toBe(-999);
|
||||
});
|
||||
|
||||
it('matchBoutOdds joins in either name orientation', () => {
|
||||
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
|
||||
const bout = { fighters: [{ name: 'Kamaru Usman' }, { name: 'Dricus du Plessis' }] };
|
||||
expect(combat.matchBoutOdds(bout, map)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('DEFENSIVE: garbage odds input returns {}, never throws', () => {
|
||||
expect(() => combat.normalizeCombatOdds(null)).not.toThrow();
|
||||
expect(combat.normalizeCombatOdds(null)).toEqual({});
|
||||
expect(combat.normalizeCombatOdds([{ bookmakers: 'x' }])).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Wave 6 — combat intelligence: archetype registry cross-file match,
|
||||
// classify('mma') blends from fixtures (thin data → fewer claims, never
|
||||
// fabricated), and styleMatchup honesty. NO network.
|
||||
|
||||
const svc = require('../../src/services/archetypeService');
|
||||
const arch = require('../../web/src/lib/archetypes');
|
||||
|
||||
describe('combat archetype registry — pinned + cross-file agreement', () => {
|
||||
const PINNED = {
|
||||
STRIKER: { color: '#E8703A', glyph: '✦' },
|
||||
GRAPPLER: { color: '#2FA4E7', glyph: '⊗' },
|
||||
PRESSURE: { color: '#E4574C', glyph: '➤' },
|
||||
COUNTER: { color: '#8E7BE0', glyph: '◊' },
|
||||
FINISHER: { color: '#12B886', glyph: '▲' },
|
||||
GRINDER: { color: '#B0883B', glyph: '▦' },
|
||||
};
|
||||
|
||||
it('backend COMBAT_ARCHETYPES carries exactly the six pinned styles', () => {
|
||||
expect(Object.keys(svc.COMBAT_ARCHETYPES).sort()).toEqual(Object.keys(PINNED).sort());
|
||||
});
|
||||
|
||||
it('backend colors + glyphs match the pinned spec', () => {
|
||||
for (const [name, p] of Object.entries(PINNED)) {
|
||||
expect(svc.COMBAT_ARCHETYPES[name].color).toBe(p.color);
|
||||
expect(svc.COMBAT_ARCHETYPES[name].glyph).toBe(p.glyph);
|
||||
}
|
||||
});
|
||||
|
||||
it('frontend COMBAT_ARCHETYPE_MAP colors + glyph chars MATCH the backend', () => {
|
||||
for (const [name, a] of Object.entries(svc.COMBAT_ARCHETYPES)) {
|
||||
const front = arch.COMBAT_ARCHETYPE_MAP[name];
|
||||
expect(front).toBeDefined();
|
||||
expect(front.c).toBe(a.color);
|
||||
expect(front.char).toBe(a.glyph);
|
||||
}
|
||||
// No extra frontend combat archetypes beyond the pinned six.
|
||||
expect(Object.keys(arch.COMBAT_ARCHETYPE_MAP).sort()).toEqual(Object.keys(svc.COMBAT_ARCHETYPES).sort());
|
||||
});
|
||||
|
||||
it('combat FINISHER is namespaced — it does NOT collide with the soccer FINISHER', () => {
|
||||
// Soccer FINISHER stays #FF5C5C in the shared map; combat FINISHER is #12B886.
|
||||
expect(arch.ARCHETYPE_MAP.FINISHER.c).toBe('#FF5C5C');
|
||||
expect(arch.combatArchetypeColor('FINISHER')).toBe('#12B886');
|
||||
// sport-aware resolution keeps them apart:
|
||||
expect(arch.archetypeColor('FINISHER')).toBe('#FF5C5C'); // no sport → soccer
|
||||
expect(arch.archetypeColor('FINISHER', 'mma')).toBe('#12B886'); // mma → combat
|
||||
});
|
||||
});
|
||||
|
||||
describe("classify('mma', …) — blends from fixtures", () => {
|
||||
it('a high-volume distance striker profiles STRIKER-primary', () => {
|
||||
const r = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, strDef: 0.62, tdAvg: 0.2, koRate: 0.6, decRate: 0.3 });
|
||||
expect(r.primary && r.primary.name).toBe('STRIKER');
|
||||
expect(r.blend.length).toBeGreaterThan(0);
|
||||
expect(r.blend.map((b) => b.archetype)).toContain('STRIKER');
|
||||
});
|
||||
|
||||
it('a takedown-heavy submission threat profiles GRAPPLER-primary', () => {
|
||||
const r = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, sapm: 2, strDef: 0.5, koRate: 0.1, subRate: 0.5, decRate: 0.4 });
|
||||
expect(r.primary && r.primary.name).toBe('GRAPPLER');
|
||||
});
|
||||
|
||||
it('a high-finish record profiles FINISHER via method rates', () => {
|
||||
const r = svc.classify('mma', { koWins: 10, subWins: 5, decWins: 1, totalWins: 16 });
|
||||
expect(r.blend.map((b) => b.archetype)).toContain('FINISHER');
|
||||
});
|
||||
|
||||
it('THIN data yields NO fabricated style — empty blend, null primary (honest)', () => {
|
||||
const r = svc.classify('mma', { record: '9-4-0' }); // no strike/td/method inputs
|
||||
expect(r.blend).toEqual([]);
|
||||
expect(r.primary).toBeNull();
|
||||
expect(r.secondary).toBeNull();
|
||||
});
|
||||
|
||||
it('an absent stat never scores an axis (Number(null) === 0 guard)', () => {
|
||||
const r = svc.classify('mma', { slpm: null, tdAvg: undefined });
|
||||
expect(r.blend).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('styleMatchup — honest MODEL read, never a fabricated grade', () => {
|
||||
const striker = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, tdAvg: 0.2, koRate: 0.6 });
|
||||
const grappler = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, subRate: 0.5, decRate: 0.4 });
|
||||
|
||||
it('divergent styles produce a clear edge to one side (no confidence %)', () => {
|
||||
const v = svc.styleMatchup(striker, grappler);
|
||||
expect(v.verdict).toMatch(/EDGE$/);
|
||||
expect(['a', 'b']).toContain(v.edgeSide);
|
||||
expect(v).not.toHaveProperty('edge'); // no fabricated edge %
|
||||
expect(v).not.toHaveProperty('confidence'); // no fabricated confidence
|
||||
});
|
||||
|
||||
it('thin data on either side → INSUFFICIENT READ', () => {
|
||||
expect(svc.styleMatchup(striker, svc.classify('mma', {})).verdict).toBe('INSUFFICIENT READ');
|
||||
expect(svc.styleMatchup([], grappler).verdict).toBe('INSUFFICIENT READ');
|
||||
});
|
||||
|
||||
it('two near-identical style blends → STYLES EVEN, no edge side', () => {
|
||||
const v = svc.styleMatchup(striker, striker);
|
||||
expect(v.verdict).toBe('STYLES EVEN');
|
||||
expect(v.edgeSide).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts raw blend arrays as well as classify() results', () => {
|
||||
const v = svc.styleMatchup(striker.blend, grappler.blend);
|
||||
expect(v.verdict).toMatch(/EDGE$|EVEN|INSUFFICIENT/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Wave 6 — FightCard honesty locks (source-grep, same discipline as
|
||||
// colorContract.test.js). The card must: self-hide on a non-two-fighter bout,
|
||||
// render tale-of-the-tape as MONO data, label the verdict a MODEL read, and
|
||||
// show method/round/KO as honest "data-limited" — never a fabricated grade.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('FightCard.tsx — honest v1 tale-of-the-tape', () => {
|
||||
const src = read('components/vyndr/FightCard.tsx');
|
||||
|
||||
it('self-hides (returns null) when there arent two named fighters', () => {
|
||||
expect(src).toMatch(/fighters\.length < 2/);
|
||||
expect(src).toMatch(/return null/);
|
||||
});
|
||||
|
||||
it('data rows are MONO, and no glitch CLASS is applied to any element (data never glitches)', () => {
|
||||
expect(src).toContain('className="mono"');
|
||||
// No glitch animation class on any element (comments about "never glitches" are fine).
|
||||
expect(src).not.toMatch(/className=["'`][^"'`]*glitch/);
|
||||
});
|
||||
|
||||
it('method / round / KO cells are shown as honest "data-limited", not fabricated', () => {
|
||||
expect(src).toMatch(/dataLimited/);
|
||||
expect(src).toContain('data-limited');
|
||||
expect(src).toMatch(/METHOD/);
|
||||
});
|
||||
|
||||
it('the verdict is explicitly labeled a MODEL read, not a settled grade', () => {
|
||||
expect(src).toContain('MODEL READ');
|
||||
expect(src).toContain('INSUFFICIENT READ');
|
||||
});
|
||||
|
||||
it('uses a monogram (no fighter photo / likeness)', () => {
|
||||
expect(src).toContain('Monogram');
|
||||
expect(src).not.toMatch(/headshot|espncdn.*headshots|<img/i);
|
||||
});
|
||||
|
||||
it('renders the archetype chip through the shared ArchetypeBadge with sport="mma"', () => {
|
||||
expect(src).toContain('ArchetypeBadge');
|
||||
expect(src).toMatch(/sport="mma"/);
|
||||
});
|
||||
|
||||
it('absent odds render as a dash, never a fabricated number', () => {
|
||||
expect(src).toMatch(/const DASH = '—'/);
|
||||
expect(src).toMatch(/Number\.isFinite/);
|
||||
});
|
||||
});
|
||||
@@ -244,4 +244,14 @@ describe('oddsNormalizer', () => {
|
||||
expect(result[0].away_team).toBe('PHX');
|
||||
});
|
||||
});
|
||||
|
||||
// Wave 6 — combat (MMA) game-level markets. Without these MARKET_MAP keys,
|
||||
// combat moneyline/round-total odds would silently normalize to zero (same
|
||||
// silent-failure class as the MLB/NHL gaps closed earlier).
|
||||
describe('MMA / combat market keys (Wave 6)', () => {
|
||||
it('maps h2h → moneyline and totals → round_total', () => {
|
||||
expect(MARKET_MAP.h2h).toBe('moneyline');
|
||||
expect(MARKET_MAP.totals).toBe('round_total');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,14 +64,23 @@ describe('SPORT_MARKETS — isolation', () => {
|
||||
expect(wc).not.toMatch(/batter_/);
|
||||
});
|
||||
|
||||
test('every market list ends with `spreads`', () => {
|
||||
for (const list of Object.values(SPORT_MARKETS)) {
|
||||
test('every PLAYER-PROP market list ends with `spreads`', () => {
|
||||
for (const [sport, list] of Object.entries(SPORT_MARKETS)) {
|
||||
// Wave 6 — MMA is a GAME-level sport (h2h + round totals only); it has
|
||||
// no player-prop `spreads` market and odds-api 422s if one is sent.
|
||||
if (sport === 'mma') continue;
|
||||
// We don't require spreads to be the literal final segment,
|
||||
// only that it's present in the comma-separated list.
|
||||
expect(list.split(',')).toContain('spreads');
|
||||
}
|
||||
});
|
||||
|
||||
test('MMA market list is game-level (h2h + totals), no spreads/player props', () => {
|
||||
expect(SPORT_MARKETS.mma).toBe('h2h,totals');
|
||||
expect(SPORT_MARKETS.mma).not.toMatch(/spreads/);
|
||||
expect(SPORT_MARKETS.mma).not.toMatch(/player_/);
|
||||
});
|
||||
|
||||
test('SPORT_MARKETS is frozen at the top level', () => {
|
||||
expect(Object.isFrozen(SPORT_MARKETS)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Combat (MMA/UFC) fight-cards proxy (Wave 6, S25 rule — Express isn't
|
||||
* reachable from the browser directly). Forwards to /api/combat/:date.
|
||||
* Off-card windows return an empty-but-valid card list so the UI degrades
|
||||
* to the honest empty state, never a crash.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ date: string }> }) {
|
||||
const { date } = await params;
|
||||
const d = String(date || '').toLowerCase();
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/combat/${encodeURIComponent(d)}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ date: d, events: [], source: 'espn' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Single fight-card proxy (Wave 6, S25 rule). Forwards to /api/fight/:id.
|
||||
* Unknown/unavailable card → 404 (honest, no fabricated card).
|
||||
*/
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const fid = String(id || '').replace(/[^0-9]/g, '');
|
||||
if (!fid) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/fight/${encodeURIComponent(fid)}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'card not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FightCard, EmptyState } from '@/components/vyndr';
|
||||
import type { FighterTape } from '@/components/vyndr';
|
||||
|
||||
interface RawFighter {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null;
|
||||
stance?: string | null;
|
||||
reach?: string | number | null;
|
||||
blend?: { archetype: string; weight: number }[] | null;
|
||||
pedigrees?: string[] | null;
|
||||
}
|
||||
interface RawBout {
|
||||
id?: string | null;
|
||||
weightClass?: string | null;
|
||||
rounds?: number | null;
|
||||
status?: string | null;
|
||||
fighters: RawFighter[];
|
||||
odds?: { moneyline?: { home?: number | null; away?: number | null } | null; roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null } | null;
|
||||
verdict?: { verdict: string; edgeSide?: 'a' | 'b' | null; summary?: string } | null;
|
||||
}
|
||||
interface RawEvent {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
shortName?: string | null;
|
||||
date?: string | null;
|
||||
venue?: string | null;
|
||||
bouts: RawBout[];
|
||||
}
|
||||
|
||||
export default function FightCardClient({ id }: { id: string }) {
|
||||
const [event, setEvent] = useState<RawEvent | null>(null);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'empty'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch(`/api/fight/${encodeURIComponent(id)}`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (!active) return;
|
||||
const ev: RawEvent | null = d && d.event ? d.event : null;
|
||||
if (ev && Array.isArray(ev.bouts) && ev.bouts.length > 0) {
|
||||
setEvent(ev);
|
||||
setState('ready');
|
||||
} else {
|
||||
setState('empty');
|
||||
}
|
||||
})
|
||||
.catch(() => { if (active) setState('empty'); });
|
||||
return () => { active = false; };
|
||||
}, [id]);
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="mono" style={{ padding: '80px 24px', textAlign: 'center', color: 'var(--text-2)', letterSpacing: '0.2em', fontSize: 12 }}>
|
||||
LOADING THE CARD…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'empty' || !event) {
|
||||
return (
|
||||
<EmptyState
|
||||
code="NO CARD SCHEDULED"
|
||||
title="No fight card here"
|
||||
message="The octagon is dark right now. Combat cards post the week of a UFC event — check back closer to fight night."
|
||||
actions={[{ label: 'BACK TO THE SLATE', href: '/dashboard', primary: true }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dateStr = event.date ? new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 680, margin: '0 auto', padding: '24px 16px 80px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 10, letterSpacing: '0.18em', color: 'var(--text-2)', marginBottom: 6 }}>
|
||||
{[event.shortName, dateStr, event.venue].filter(Boolean).join(' · ') || 'UFC'}
|
||||
</div>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
|
||||
{event.name || 'Fight Card'}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{event.bouts.map((bout, i) => {
|
||||
const fighters: FighterTape[] = (bout.fighters || []).slice(0, 2).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
record: f.record,
|
||||
stance: f.stance,
|
||||
reach: f.reach,
|
||||
blend: f.blend,
|
||||
pedigrees: f.pedigrees,
|
||||
}));
|
||||
return (
|
||||
<FightCard
|
||||
key={bout.id || `${i}`}
|
||||
weightClass={bout.weightClass}
|
||||
rounds={bout.rounds}
|
||||
status={bout.status}
|
||||
fighters={fighters}
|
||||
odds={bout.odds}
|
||||
verdict={bout.verdict}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import FightCardClient from './FightCardClient';
|
||||
|
||||
/**
|
||||
* /fight/[id] (Wave 6 — combat intelligence). Thin server wrapper for page
|
||||
* metadata; the interactive tale-of-the-tape cards live in the client
|
||||
* component. Off-card windows self-hide to the shared EmptyState.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
await params;
|
||||
return {
|
||||
title: 'Fight Card — VYNDR Combat',
|
||||
description: 'Tale-of-the-tape, style-blend archetypes, and moneyline / round-total lines for the UFC card. A MODEL style read — not a settled grade.',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FightPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <FightCardClient id={String(id || '')} />;
|
||||
}
|
||||
@@ -18,11 +18,12 @@ interface ArchetypeBadgeProps {
|
||||
*/
|
||||
export default function ArchetypeBadge({
|
||||
archetype,
|
||||
sport,
|
||||
variant = 'tint',
|
||||
size = 'sm',
|
||||
showDesc = false,
|
||||
}: ArchetypeBadgeProps) {
|
||||
const s = badgeStyle(archetype, variant, size);
|
||||
const s = badgeStyle(archetype, variant, size, sport);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
||||
<span
|
||||
@@ -46,10 +47,20 @@ export default function ArchetypeBadge({
|
||||
textShadow: s.textShadow,
|
||||
}}
|
||||
>
|
||||
{s.glyphChar ? (
|
||||
// Combat glyphs are unicode chars (data never glitches — chrome label).
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ display: 'inline-flex', flex: 'none', alignItems: 'center', justifyContent: 'center', width: s.glyphSize, fontSize: s.glyphSize, lineHeight: 1, color: s.glyphColor }}
|
||||
>
|
||||
{s.glyphChar}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
|
||||
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
|
||||
/>
|
||||
)}
|
||||
{s.name}
|
||||
</span>
|
||||
{showDesc && s.desc && (
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import ArchetypeBadge from './ArchetypeBadge';
|
||||
import SportBadge from './SportBadge';
|
||||
import { combatArchetypeColor } from '@/lib/archetypes';
|
||||
|
||||
/* ============================================================
|
||||
FightCard (Wave 6 — combat intelligence, honest v1).
|
||||
The tale-of-the-tape head-to-head from the design mockup:
|
||||
FIGHTER A · CENTER VERDICT · FIGHTER B. Two fighters side-by-side
|
||||
(NOT the player-strip row grammar). All data is MONO and never
|
||||
glitches. Physicals/records are REAL sourced facts — absent fields
|
||||
render as "—", never fabricated. No fighter photos (likeness rule):
|
||||
an initials monogram only. Style blend + verdict are a MODEL read,
|
||||
explicitly labeled. Method / round / KO are shown as honest
|
||||
"— data-limited" placeholders (DEFERRED sub-wave), never invented.
|
||||
============================================================ */
|
||||
|
||||
export interface BlendEntry {
|
||||
archetype: string;
|
||||
weight: number; // 0-1
|
||||
}
|
||||
|
||||
export interface FighterTape {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null;
|
||||
stance?: string | null;
|
||||
reach?: string | number | null;
|
||||
/** Style blend (MODEL) — absent when the free feed is too thin to profile. */
|
||||
blend?: BlendEntry[] | null;
|
||||
/** Verifiable discipline credentials only — absent when unknown, never guessed. */
|
||||
pedigrees?: string[] | null;
|
||||
}
|
||||
|
||||
export interface FightCardOdds {
|
||||
moneyline?: { home?: number | null; away?: number | null } | null;
|
||||
roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null;
|
||||
}
|
||||
|
||||
export interface FightVerdict {
|
||||
verdict: string; // e.g. "GRAPPLER EDGE" | "STYLES EVEN" | "INSUFFICIENT READ"
|
||||
edgeSide?: 'a' | 'b' | null;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface FightCardProps {
|
||||
weightClass?: string | null;
|
||||
rounds?: number | null;
|
||||
status?: string | null;
|
||||
fighters: FighterTape[]; // [A, B]
|
||||
odds?: FightCardOdds | null;
|
||||
verdict?: FightVerdict | null;
|
||||
}
|
||||
|
||||
const DASH = '—';
|
||||
const fmtOdds = (v?: number | null) => (typeof v === 'number' && Number.isFinite(v) ? (v > 0 ? `+${v}` : `${v}`) : DASH);
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/** The two range-axis bars the mockup renders (GRAPPLER% / STRIKER%). */
|
||||
function topBars(blend?: BlendEntry[] | null): BlendEntry[] {
|
||||
if (!Array.isArray(blend) || blend.length === 0) return [];
|
||||
return [...blend].sort((a, b) => b.weight - a.weight).slice(0, 3);
|
||||
}
|
||||
|
||||
function Monogram({ name }: { name: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 40, height: 40, borderRadius: 8, flex: 'none',
|
||||
background: 'var(--bg-2, #14141E)', border: '1px solid var(--border, #1E1E2A)',
|
||||
color: 'var(--text-1, #B8BCC8)', fontWeight: 800, fontSize: 14, letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Fighter({ f, align }: { f: FighterTape; align: 'left' | 'right' }) {
|
||||
const bars = topBars(f.blend);
|
||||
const meta: string[] = [];
|
||||
if (f.record?.display) meta.push(f.record.display);
|
||||
if (f.stance) meta.push(String(f.stance).toUpperCase());
|
||||
if (f.reach != null && f.reach !== '') meta.push(`${f.reach}" REACH`);
|
||||
const primary = bars[0]?.archetype || null;
|
||||
const rowDir = align === 'right' ? 'row-reverse' : 'row';
|
||||
const textAlign = align === 'right' ? 'right' : 'left';
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: rowDir, alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<Monogram name={f.name} />
|
||||
<div style={{ minWidth: 0, textAlign }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-0, #F0F0F0)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{f.name}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2, #707080)', marginTop: 2 }}>
|
||||
{meta.length ? meta.join(' · ') : `RECORD ${DASH}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style-blend bars (MODEL) — only when the fighter is profiled. */}
|
||||
{bars.length > 0 ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{bars.map((b) => {
|
||||
const c = combatArchetypeColor(b.archetype);
|
||||
const pct = Math.round((b.weight || 0) * 100);
|
||||
return (
|
||||
<div key={b.archetype} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span className="mono" style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em', color: c }}>
|
||||
{b.archetype}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-1, #B8BCC8)' }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: 5, borderRadius: 3, background: 'var(--bg-2, #14141E)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: c }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2, #707080)', marginBottom: 12, textAlign }}>
|
||||
STYLE PROFILE {DASH} DATA-LIMITED
|
||||
</div>
|
||||
)}
|
||||
|
||||
{primary && (
|
||||
<div style={{ display: 'flex', flexDirection: rowDir, marginBottom: 8 }}>
|
||||
<ArchetypeBadge archetype={primary} sport="mma" variant="tint" size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discipline pedigree tags — verifiable only, absent when unknown. */}
|
||||
{Array.isArray(f.pedigrees) && f.pedigrees.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, flexDirection: rowDir === 'row-reverse' ? 'row-reverse' : 'row' }}>
|
||||
{f.pedigrees.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 9, letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
|
||||
color: 'var(--text-1, #B8BCC8)', border: '1px solid var(--border, #1E1E2A)', background: 'var(--bg-1, #0A0A10)',
|
||||
}}
|
||||
>
|
||||
{p.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OddsCell({ label, value, dataLimited }: { label: string; value?: string; dataLimited?: boolean }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-1, #0A0A10)', padding: '11px 12px', borderRadius: 6, border: '1px solid var(--border, #1E1E2A)' }}>
|
||||
<div className="mono" style={{ fontSize: 9, letterSpacing: '0.14em', color: 'var(--text-2, #707080)', marginBottom: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
{dataLimited ? (
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2, #707080)' }}>{DASH} data-limited</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1, #B8BCC8)', fontWeight: 700 }}>{value}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FightCard({ weightClass, rounds, status, fighters, odds, verdict }: FightCardProps) {
|
||||
// Self-hide honestly if we don't have a two-fighter bout.
|
||||
if (!Array.isArray(fighters) || fighters.length < 2 || !fighters[0]?.name || !fighters[1]?.name) return null;
|
||||
const [a, b] = fighters;
|
||||
|
||||
const v = verdict && verdict.verdict ? verdict : { verdict: 'INSUFFICIENT READ', edgeSide: null as null, summary: 'Not enough style data to call this — a MODEL read needs both fighters profiled.' };
|
||||
const isCall = v.verdict !== 'INSUFFICIENT READ' && v.verdict !== 'STYLES EVEN';
|
||||
const edgeStyle = isCall ? v.verdict.replace(/\s+EDGE$/i, '') : null;
|
||||
const verdictColor = edgeStyle ? combatArchetypeColor(edgeStyle) : 'var(--text-2, #707080)';
|
||||
|
||||
const ml = odds?.moneyline || null;
|
||||
const rt = odds?.roundTotal || null;
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid var(--border, #1E1E2A)', borderRadius: 12,
|
||||
background: 'var(--bg-1, #0A0A10)', padding: 16, maxWidth: 640,
|
||||
}}
|
||||
>
|
||||
{/* Card header — weight class + rounds (mono chrome). */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport="mma" size="sm" />
|
||||
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-2, #707080)' }}>
|
||||
{[weightClass, rounds ? `${rounds} RD` : null].filter(Boolean).join(' · ') || 'BOUT'}
|
||||
</span>
|
||||
</div>
|
||||
{status === 'post' && (
|
||||
<span className="mono" style={{ fontSize: 9, letterSpacing: '0.1em', color: 'var(--text-2, #707080)' }}>FINAL</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FIGHTER A · VERDICT · FIGHTER B */}
|
||||
<div className="fight-tape" style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<Fighter f={a} align="left" />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, paddingTop: 8, flex: 'none', width: 96 }}>
|
||||
<div className="mono" style={{ fontSize: 9, letterSpacing: '0.24em', color: 'var(--text-2, #707080)' }}>VERDICT</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-2, #707080)' }}>VS</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', textAlign: 'center', padding: '3px 8px', borderRadius: 6,
|
||||
background: `${verdictColor}22`, color: verdictColor, fontWeight: 700, fontSize: 9.5,
|
||||
border: `1px solid ${verdictColor}55`, lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{v.verdict}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 8, letterSpacing: '0.08em', color: 'var(--text-2, #707080)' }}>MODEL READ</div>
|
||||
</div>
|
||||
|
||||
<Fighter f={b} align="right" />
|
||||
</div>
|
||||
|
||||
{v.summary && (
|
||||
<p className="mono" style={{ fontSize: 10.5, color: 'var(--text-2, #707080)', marginTop: 12, lineHeight: 1.5 }}>
|
||||
{v.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Odds row — MONEYLINE + round total REAL; method/round/KO data-limited. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8, marginTop: 16 }}>
|
||||
<OddsCell label={`MONEYLINE · ${initials(a.name)}`} value={fmtOdds(ml?.home)} />
|
||||
<OddsCell label={`MONEYLINE · ${initials(b.name)}`} value={fmtOdds(ml?.away)} />
|
||||
<OddsCell
|
||||
label={rt?.line != null ? `ROUND TOTAL · O${rt.line}` : 'ROUND TOTAL'}
|
||||
value={rt ? `${fmtOdds(rt.over)} / ${fmtOdds(rt.under)}` : DASH}
|
||||
dataLimited={!rt}
|
||||
/>
|
||||
<OddsCell label="METHOD · KO / SUB / DEC" dataLimited />
|
||||
</div>
|
||||
<p className="mono" style={{ fontSize: 9, color: 'var(--text-2, #707080)', marginTop: 10, lineHeight: 1.5 }}>
|
||||
Method, round and fighter-prop grades are DATA-LIMITED on the free feed — shown as {DASH}, never fabricated.
|
||||
Odds are REAL book numbers; the style verdict is a MODEL read, not a settled grade.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export { default as TierRecord } from './TierRecord';
|
||||
|
||||
/* Player Intelligence (Session 42) */
|
||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||
export { default as FightCard } from './FightCard';
|
||||
export type { FightCardProps, FighterTape, FightCardOdds, FightVerdict, BlendEntry } from './FightCard';
|
||||
export { default as ArchetypeBlend } from './ArchetypeBlend';
|
||||
export type { BlendSegment } from './ArchetypeBlend';
|
||||
export { default as StatStrip } from './StatStrip';
|
||||
|
||||
@@ -22,7 +22,9 @@ export const SPORTS: Record<SportKey, SportConfig> = {
|
||||
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). Not in the graded-props pipeline yet → collectData false.
|
||||
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' },
|
||||
};
|
||||
|
||||
@@ -84,6 +84,21 @@ const ARCHETYPE_MAP = {
|
||||
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
|
||||
};
|
||||
|
||||
/* Combat archetype visual map (Wave 6 — MMA/UFC). SEPARATE from ARCHETYPE_MAP:
|
||||
FINISHER's combat name/color/glyph differ from the soccer FINISHER, and
|
||||
combat FINISHER's green is intentionally close to the pitch-green here. Keys/
|
||||
colors/glyph CHARS MUST match src/services/archetypeService.js
|
||||
COMBAT_ARCHETYPES — tests/unit/combatArchetypes.test.js asserts it.
|
||||
`char` is a unicode glyph (rendered as text, not an SVG glyph key). */
|
||||
const COMBAT_ARCHETYPE_MAP = {
|
||||
STRIKER: { c: '#E8703A', d: 'Wins on the feet — volume + power at range.', char: '✦', axis: 'range' },
|
||||
GRAPPLER: { c: '#2FA4E7', d: 'Fight hits the mat on his terms — control + subs.', char: '⊗', axis: 'range' },
|
||||
PRESSURE: { c: '#E4574C', d: 'Forward, relentless, breaks the pace.', char: '➤', axis: 'tempo' },
|
||||
COUNTER: { c: '#8E7BE0', d: 'Patient — punishes what you show him.', char: '◊', axis: 'tempo' },
|
||||
FINISHER: { c: '#12B886', d: 'Ends nights — high KO/SUB rate.', char: '▲', axis: 'outcome' },
|
||||
GRINDER: { c: '#B0883B', d: 'Goes the distance, wins the rounds.', char: '▦', axis: 'outcome' },
|
||||
};
|
||||
|
||||
const FALLBACK = { c: '#9499A8', d: '', g: '' };
|
||||
|
||||
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
|
||||
@@ -93,15 +108,26 @@ for (const [k, v] of Object.entries(ARCHETYPE_MAP)) {
|
||||
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
|
||||
}
|
||||
|
||||
function archetypeInfo(name) {
|
||||
function archetypeInfo(name, sport) {
|
||||
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||
// Combat archetypes live in their own namespace (FINISHER collides with the
|
||||
// soccer archetype) — resolve them ONLY when the sport is MMA.
|
||||
if (String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[key]) {
|
||||
return COMBAT_ARCHETYPE_MAP[key];
|
||||
}
|
||||
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
|
||||
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
function archetypeColor(name) {
|
||||
return archetypeInfo(name).c;
|
||||
function archetypeColor(name, sport) {
|
||||
return archetypeInfo(name, sport).c;
|
||||
}
|
||||
|
||||
/** Combat-only color lookup (unambiguous — no soccer FINISHER collision). */
|
||||
function combatArchetypeColor(name) {
|
||||
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||
return (COMBAT_ARCHETYPE_MAP[key] || FALLBACK).c;
|
||||
}
|
||||
|
||||
function glyphSvg(glyphKey) {
|
||||
@@ -114,8 +140,8 @@ function glyphSvg(glyphKey) {
|
||||
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
||||
* size: 'sm' | 'md'.
|
||||
*/
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
const info = archetypeInfo(name);
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm', sport) {
|
||||
const info = archetypeInfo(name, sport);
|
||||
const sm = size === 'sm';
|
||||
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
||||
if (variant === 'full' || variant === 'solid') {
|
||||
@@ -128,11 +154,14 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
}
|
||||
// Display the canonical VYNDR name even if a legacy name was passed.
|
||||
const upper = (name == null ? '' : String(name)).toUpperCase();
|
||||
const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
|
||||
const isCombat = String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[upper];
|
||||
const canonical = isCombat ? upper : ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
|
||||
return {
|
||||
name: canonical,
|
||||
desc: info.d,
|
||||
glyph: info.g,
|
||||
// Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys).
|
||||
glyphChar: isCombat ? info.char : null,
|
||||
color: info.c,
|
||||
textColor, bg, borderColor, glyphColor, textShadow,
|
||||
fontSize: sm ? '9.5px' : '12px',
|
||||
@@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
module.exports = {
|
||||
GLYPHS,
|
||||
ARCHETYPE_MAP,
|
||||
COMBAT_ARCHETYPE_MAP,
|
||||
archetypeInfo,
|
||||
archetypeColor,
|
||||
combatArchetypeColor,
|
||||
glyphSvg,
|
||||
badgeStyle,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ const SPORT = {
|
||||
mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' },
|
||||
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
|
||||
soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' },
|
||||
// Wave 6 — combat: the #D4AF37 championship-gold token (matches
|
||||
// src/services/shareCards/tokens.js + config/sports.js mma color).
|
||||
mma: { label: 'MMA', color: 'var(--s-mma, #d4af37)', hex: '#d4af37' },
|
||||
};
|
||||
|
||||
/* GradeBadge size variants — hero stays 80–120px (§5: grade letter is
|
||||
|
||||
Reference in New Issue
Block a user