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
+16 -5
View File
@@ -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,
}}
>
<span
style={{ display: 'inline-flex', flex: 'none', width: s.glyphSize, height: s.glyphSize, color: s.glyphColor }}
dangerouslySetInnerHTML={{ __html: glyphSvg(s.glyph) }}
/>
{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 && (
+259
View File
@@ -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>
);
}
+2
View File
@@ -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';