From 54fa5853f5cf11c45b0bf4a7c331f86cff0aa0f9 Mon Sep 17 00:00:00 2001 From: Kev Date: Mon, 13 Jul 2026 16:58:22 -0400 Subject: [PATCH] Wave 6: Combat Intelligence Layer (honest free v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app.js | 6 + src/config/sports.js | 6 +- src/routes/combat.js | 81 +++++ src/services/adapters/combatAdapter.js | 358 ++++++++++++++++++++ src/services/archetypeService.js | 185 +++++++++- src/services/oddsService.js | 13 + src/utils/oddsNormalizer.js | 10 + tests/unit/combatAdapter.test.js | 185 ++++++++++ tests/unit/combatArchetypes.test.js | 108 ++++++ tests/unit/combatFightCard.test.js | 50 +++ tests/unit/oddsNormalizer.test.js | 10 + tests/unit/sportMarkets.test.js | 13 +- web/src/app/api/combat/[date]/route.ts | 27 ++ web/src/app/api/fight/[id]/route.ts | 25 ++ web/src/app/fight/[id]/FightCardClient.tsx | 114 +++++++ web/src/app/fight/[id]/page.tsx | 20 ++ web/src/components/vyndr/ArchetypeBadge.tsx | 21 +- web/src/components/vyndr/FightCard.tsx | 259 ++++++++++++++ web/src/components/vyndr/index.ts | 2 + web/src/config/sports.ts | 4 +- web/src/lib/archetypes.js | 43 ++- web/src/lib/vyndrTokens.js | 3 + 22 files changed, 1525 insertions(+), 18 deletions(-) create mode 100644 src/routes/combat.js create mode 100644 src/services/adapters/combatAdapter.js create mode 100644 tests/unit/combatAdapter.test.js create mode 100644 tests/unit/combatArchetypes.test.js create mode 100644 tests/unit/combatFightCard.test.js create mode 100644 web/src/app/api/combat/[date]/route.ts create mode 100644 web/src/app/api/fight/[id]/route.ts create mode 100644 web/src/app/fight/[id]/FightCardClient.tsx create mode 100644 web/src/app/fight/[id]/page.tsx create mode 100644 web/src/components/vyndr/FightCard.tsx diff --git a/src/app.js b/src/app.js index a56485c..b8e6f7f 100644 --- a/src/app.js +++ b/src/app.js @@ -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'); diff --git a/src/config/sports.js b/src/config/sports.js index 801ed4a..a98dd83 100644 --- a/src/config/sports.js +++ b/src/config/sports.js @@ -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' }, }); diff --git a/src/routes/combat.js b/src/routes/combat.js new file mode 100644 index 0000000..1a00ea3 --- /dev/null +++ b/src/routes/combat.js @@ -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; diff --git a/src/services/adapters/combatAdapter.js b/src/services/adapters/combatAdapter.js new file mode 100644 index 0000000..0845e7f --- /dev/null +++ b/src/services/adapters/combatAdapter.js @@ -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, +}; diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js index 3c88931..6a5c080 100644 --- a/src/services/archetypeService.js +++ b/src/services/archetypeService.js @@ -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, }; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index f2f350c..f2587b8 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -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, diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index 3d504fc..86bc1ef 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -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) { diff --git a/tests/unit/combatAdapter.test.js b/tests/unit/combatAdapter.test.js new file mode 100644 index 0000000..ee3a672 --- /dev/null +++ b/tests/unit/combatAdapter.test.js @@ -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({}); + }); +}); diff --git a/tests/unit/combatArchetypes.test.js b/tests/unit/combatArchetypes.test.js new file mode 100644 index 0000000..4bace21 --- /dev/null +++ b/tests/unit/combatArchetypes.test.js @@ -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/); + }); +}); diff --git a/tests/unit/combatFightCard.test.js b/tests/unit/combatFightCard.test.js new file mode 100644 index 0000000..bdfa4f0 --- /dev/null +++ b/tests/unit/combatFightCard.test.js @@ -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| { + 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/); + }); +}); diff --git a/tests/unit/oddsNormalizer.test.js b/tests/unit/oddsNormalizer.test.js index 165613e..7c3d6ff 100644 --- a/tests/unit/oddsNormalizer.test.js +++ b/tests/unit/oddsNormalizer.test.js @@ -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'); + }); + }); }); diff --git a/tests/unit/sportMarkets.test.js b/tests/unit/sportMarkets.test.js index 5bdadbb..7210b8b 100644 --- a/tests/unit/sportMarkets.test.js +++ b/tests/unit/sportMarkets.test.js @@ -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); }); diff --git a/web/src/app/api/combat/[date]/route.ts b/web/src/app/api/combat/[date]/route.ts new file mode 100644 index 0000000..3d38883 --- /dev/null +++ b/web/src/app/api/combat/[date]/route.ts @@ -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' }); + } +} diff --git a/web/src/app/api/fight/[id]/route.ts b/web/src/app/api/fight/[id]/route.ts new file mode 100644 index 0000000..711bbc4 --- /dev/null +++ b/web/src/app/api/fight/[id]/route.ts @@ -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 }); + } +} diff --git a/web/src/app/fight/[id]/FightCardClient.tsx b/web/src/app/fight/[id]/FightCardClient.tsx new file mode 100644 index 0000000..466fe03 --- /dev/null +++ b/web/src/app/fight/[id]/FightCardClient.tsx @@ -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(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 ( +
+ LOADING THE CARD… +
+ ); + } + + if (state === 'empty' || !event) { + return ( + + ); + } + + const dateStr = event.date ? new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : null; + + return ( +
+
+
+ {[event.shortName, dateStr, event.venue].filter(Boolean).join(' · ') || 'UFC'} +
+

+ {event.name || 'Fight Card'} +

+
+ +
+ {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 ( + + ); + })} +
+
+ ); +} diff --git a/web/src/app/fight/[id]/page.tsx b/web/src/app/fight/[id]/page.tsx new file mode 100644 index 0000000..125100d --- /dev/null +++ b/web/src/app/fight/[id]/page.tsx @@ -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 { + 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 ; +} diff --git a/web/src/components/vyndr/ArchetypeBadge.tsx b/web/src/components/vyndr/ArchetypeBadge.tsx index f70714e..5451c54 100644 --- a/web/src/components/vyndr/ArchetypeBadge.tsx +++ b/web/src/components/vyndr/ArchetypeBadge.tsx @@ -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 ( - + {s.glyphChar ? ( + // Combat glyphs are unicode chars (data never glitches — chrome label). + + {s.glyphChar} + + ) : ( + + )} {s.name} {showDesc && s.desc && ( diff --git a/web/src/components/vyndr/FightCard.tsx b/web/src/components/vyndr/FightCard.tsx new file mode 100644 index 0000000..e94aaa8 --- /dev/null +++ b/web/src/components/vyndr/FightCard.tsx @@ -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 ( + + {initials(name)} + + ); +} + +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 ( +
+
+ +
+
+ {f.name} +
+
+ {meta.length ? meta.join(' · ') : `RECORD ${DASH}`} +
+
+
+ + {/* Style-blend bars (MODEL) — only when the fighter is profiled. */} + {bars.length > 0 ? ( +
+ {bars.map((b) => { + const c = combatArchetypeColor(b.archetype); + const pct = Math.round((b.weight || 0) * 100); + return ( +
+
+ + {b.archetype} + + {pct}% +
+
+
+
+
+ ); + })} +
+ ) : ( +
+ STYLE PROFILE {DASH} DATA-LIMITED +
+ )} + + {primary && ( +
+ +
+ )} + + {/* Discipline pedigree tags — verifiable only, absent when unknown. */} + {Array.isArray(f.pedigrees) && f.pedigrees.length > 0 && ( +
+ {f.pedigrees.map((p) => ( + + {p.toUpperCase()} + + ))} +
+ )} +
+ ); +} + +function OddsCell({ label, value, dataLimited }: { label: string; value?: string; dataLimited?: boolean }) { + return ( +
+
+ {label} +
+ {dataLimited ? ( +
{DASH} data-limited
+ ) : ( +
{value}
+ )} +
+ ); +} + +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 ( +
+ {/* Card header — weight class + rounds (mono chrome). */} +
+
+ + + {[weightClass, rounds ? `${rounds} RD` : null].filter(Boolean).join(' · ') || 'BOUT'} + +
+ {status === 'post' && ( + FINAL + )} +
+ + {/* FIGHTER A · VERDICT · FIGHTER B */} +
+ + +
+
VERDICT
+
VS
+
+ {v.verdict} +
+
MODEL READ
+
+ + +
+ + {v.summary && ( +

+ {v.summary} +

+ )} + + {/* Odds row — MONEYLINE + round total REAL; method/round/KO data-limited. */} +
+ + + + +
+

+ 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. +

+
+ ); +} diff --git a/web/src/components/vyndr/index.ts b/web/src/components/vyndr/index.ts index f5975c2..656ecde 100644 --- a/web/src/components/vyndr/index.ts +++ b/web/src/components/vyndr/index.ts @@ -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'; diff --git a/web/src/config/sports.ts b/web/src/config/sports.ts index f03a5f5..0b2776a 100644 --- a/web/src/config/sports.ts +++ b/web/src/config/sports.ts @@ -22,7 +22,9 @@ export const SPORTS: Record = { 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' }, }; diff --git a/web/src/lib/archetypes.js b/web/src/lib/archetypes.js index 61bb7da..e615273 100644 --- a/web/src/lib/archetypes.js +++ b/web/src/lib/archetypes.js @@ -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, }; diff --git a/web/src/lib/vyndrTokens.js b/web/src/lib/vyndrTokens.js index a506bee..d4b8a29 100644 --- a/web/src/lib/vyndrTokens.js +++ b/web/src/lib/vyndrTokens.js @@ -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