Wave 6: Combat Intelligence Layer (honest free v1)

Net-new MMA/UFC vertical — fight-card discovery, tale-of-the-tape,
style-blend archetypes, ML + round-total odds, and a style-edge VERDICT
(a MODEL read, explicitly NOT a settled grade). Built to
specs/combat-intelligence.md.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-13 16:58:22 -04:00
parent 016758e014
commit 54fa5853f5
22 changed files with 1525 additions and 18 deletions
+37 -6
View File
@@ -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,
};
+3
View File
@@ -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 80120px (§5: grade letter is