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:
@@ -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 && (
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import ArchetypeBadge from './ArchetypeBadge';
|
||||
import SportBadge from './SportBadge';
|
||||
import { combatArchetypeColor } from '@/lib/archetypes';
|
||||
|
||||
/* ============================================================
|
||||
FightCard (Wave 6 — combat intelligence, honest v1).
|
||||
The tale-of-the-tape head-to-head from the design mockup:
|
||||
FIGHTER A · CENTER VERDICT · FIGHTER B. Two fighters side-by-side
|
||||
(NOT the player-strip row grammar). All data is MONO and never
|
||||
glitches. Physicals/records are REAL sourced facts — absent fields
|
||||
render as "—", never fabricated. No fighter photos (likeness rule):
|
||||
an initials monogram only. Style blend + verdict are a MODEL read,
|
||||
explicitly labeled. Method / round / KO are shown as honest
|
||||
"— data-limited" placeholders (DEFERRED sub-wave), never invented.
|
||||
============================================================ */
|
||||
|
||||
export interface BlendEntry {
|
||||
archetype: string;
|
||||
weight: number; // 0-1
|
||||
}
|
||||
|
||||
export interface FighterTape {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null;
|
||||
stance?: string | null;
|
||||
reach?: string | number | null;
|
||||
/** Style blend (MODEL) — absent when the free feed is too thin to profile. */
|
||||
blend?: BlendEntry[] | null;
|
||||
/** Verifiable discipline credentials only — absent when unknown, never guessed. */
|
||||
pedigrees?: string[] | null;
|
||||
}
|
||||
|
||||
export interface FightCardOdds {
|
||||
moneyline?: { home?: number | null; away?: number | null } | null;
|
||||
roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null;
|
||||
}
|
||||
|
||||
export interface FightVerdict {
|
||||
verdict: string; // e.g. "GRAPPLER EDGE" | "STYLES EVEN" | "INSUFFICIENT READ"
|
||||
edgeSide?: 'a' | 'b' | null;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface FightCardProps {
|
||||
weightClass?: string | null;
|
||||
rounds?: number | null;
|
||||
status?: string | null;
|
||||
fighters: FighterTape[]; // [A, B]
|
||||
odds?: FightCardOdds | null;
|
||||
verdict?: FightVerdict | null;
|
||||
}
|
||||
|
||||
const DASH = '—';
|
||||
const fmtOdds = (v?: number | null) => (typeof v === 'number' && Number.isFinite(v) ? (v > 0 ? `+${v}` : `${v}`) : DASH);
|
||||
|
||||
function initials(name: string): string {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return '?';
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
/** The two range-axis bars the mockup renders (GRAPPLER% / STRIKER%). */
|
||||
function topBars(blend?: BlendEntry[] | null): BlendEntry[] {
|
||||
if (!Array.isArray(blend) || blend.length === 0) return [];
|
||||
return [...blend].sort((a, b) => b.weight - a.weight).slice(0, 3);
|
||||
}
|
||||
|
||||
function Monogram({ name }: { name: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 40, height: 40, borderRadius: 8, flex: 'none',
|
||||
background: 'var(--bg-2, #14141E)', border: '1px solid var(--border, #1E1E2A)',
|
||||
color: 'var(--text-1, #B8BCC8)', fontWeight: 800, fontSize: 14, letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Fighter({ f, align }: { f: FighterTape; align: 'left' | 'right' }) {
|
||||
const bars = topBars(f.blend);
|
||||
const meta: string[] = [];
|
||||
if (f.record?.display) meta.push(f.record.display);
|
||||
if (f.stance) meta.push(String(f.stance).toUpperCase());
|
||||
if (f.reach != null && f.reach !== '') meta.push(`${f.reach}" REACH`);
|
||||
const primary = bars[0]?.archetype || null;
|
||||
const rowDir = align === 'right' ? 'row-reverse' : 'row';
|
||||
const textAlign = align === 'right' ? 'right' : 'left';
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: rowDir, alignItems: 'center', gap: 10, marginBottom: 12 }}>
|
||||
<Monogram name={f.name} />
|
||||
<div style={{ minWidth: 0, textAlign }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-0, #F0F0F0)', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{f.name}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2, #707080)', marginTop: 2 }}>
|
||||
{meta.length ? meta.join(' · ') : `RECORD ${DASH}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style-blend bars (MODEL) — only when the fighter is profiled. */}
|
||||
{bars.length > 0 ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{bars.map((b) => {
|
||||
const c = combatArchetypeColor(b.archetype);
|
||||
const pct = Math.round((b.weight || 0) * 100);
|
||||
return (
|
||||
<div key={b.archetype} style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span className="mono" style={{ fontSize: 9.5, fontWeight: 700, letterSpacing: '0.06em', color: c }}>
|
||||
{b.archetype}
|
||||
</span>
|
||||
<span className="mono" style={{ fontSize: 10, color: 'var(--text-1, #B8BCC8)' }}>{pct}%</span>
|
||||
</div>
|
||||
<div style={{ height: 5, borderRadius: 3, background: 'var(--bg-2, #14141E)', overflow: 'hidden' }}>
|
||||
<div style={{ width: `${pct}%`, height: '100%', background: c }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--text-2, #707080)', marginBottom: 12, textAlign }}>
|
||||
STYLE PROFILE {DASH} DATA-LIMITED
|
||||
</div>
|
||||
)}
|
||||
|
||||
{primary && (
|
||||
<div style={{ display: 'flex', flexDirection: rowDir, marginBottom: 8 }}>
|
||||
<ArchetypeBadge archetype={primary} sport="mma" variant="tint" size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discipline pedigree tags — verifiable only, absent when unknown. */}
|
||||
{Array.isArray(f.pedigrees) && f.pedigrees.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, flexDirection: rowDir === 'row-reverse' ? 'row-reverse' : 'row' }}>
|
||||
{f.pedigrees.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
className="mono"
|
||||
style={{
|
||||
fontSize: 9, letterSpacing: '0.04em', padding: '2px 6px', borderRadius: 4,
|
||||
color: 'var(--text-1, #B8BCC8)', border: '1px solid var(--border, #1E1E2A)', background: 'var(--bg-1, #0A0A10)',
|
||||
}}
|
||||
>
|
||||
{p.toUpperCase()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OddsCell({ label, value, dataLimited }: { label: string; value?: string; dataLimited?: boolean }) {
|
||||
return (
|
||||
<div style={{ background: 'var(--bg-1, #0A0A10)', padding: '11px 12px', borderRadius: 6, border: '1px solid var(--border, #1E1E2A)' }}>
|
||||
<div className="mono" style={{ fontSize: 9, letterSpacing: '0.14em', color: 'var(--text-2, #707080)', marginBottom: 6 }}>
|
||||
{label}
|
||||
</div>
|
||||
{dataLimited ? (
|
||||
<div className="mono" style={{ fontSize: 10.5, color: 'var(--text-2, #707080)' }}>{DASH} data-limited</div>
|
||||
) : (
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-1, #B8BCC8)', fontWeight: 700 }}>{value}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FightCard({ weightClass, rounds, status, fighters, odds, verdict }: FightCardProps) {
|
||||
// Self-hide honestly if we don't have a two-fighter bout.
|
||||
if (!Array.isArray(fighters) || fighters.length < 2 || !fighters[0]?.name || !fighters[1]?.name) return null;
|
||||
const [a, b] = fighters;
|
||||
|
||||
const v = verdict && verdict.verdict ? verdict : { verdict: 'INSUFFICIENT READ', edgeSide: null as null, summary: 'Not enough style data to call this — a MODEL read needs both fighters profiled.' };
|
||||
const isCall = v.verdict !== 'INSUFFICIENT READ' && v.verdict !== 'STYLES EVEN';
|
||||
const edgeStyle = isCall ? v.verdict.replace(/\s+EDGE$/i, '') : null;
|
||||
const verdictColor = edgeStyle ? combatArchetypeColor(edgeStyle) : 'var(--text-2, #707080)';
|
||||
|
||||
const ml = odds?.moneyline || null;
|
||||
const rt = odds?.roundTotal || null;
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{
|
||||
border: '1px solid var(--border, #1E1E2A)', borderRadius: 12,
|
||||
background: 'var(--bg-1, #0A0A10)', padding: 16, maxWidth: 640,
|
||||
}}
|
||||
>
|
||||
{/* Card header — weight class + rounds (mono chrome). */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16, gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<SportBadge sport="mma" size="sm" />
|
||||
<span className="mono" style={{ fontSize: 10, letterSpacing: '0.08em', color: 'var(--text-2, #707080)' }}>
|
||||
{[weightClass, rounds ? `${rounds} RD` : null].filter(Boolean).join(' · ') || 'BOUT'}
|
||||
</span>
|
||||
</div>
|
||||
{status === 'post' && (
|
||||
<span className="mono" style={{ fontSize: 9, letterSpacing: '0.1em', color: 'var(--text-2, #707080)' }}>FINAL</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FIGHTER A · VERDICT · FIGHTER B */}
|
||||
<div className="fight-tape" style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
|
||||
<Fighter f={a} align="left" />
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, paddingTop: 8, flex: 'none', width: 96 }}>
|
||||
<div className="mono" style={{ fontSize: 9, letterSpacing: '0.24em', color: 'var(--text-2, #707080)' }}>VERDICT</div>
|
||||
<div className="mono" style={{ fontSize: 12, color: 'var(--text-2, #707080)' }}>VS</div>
|
||||
<div
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', textAlign: 'center', padding: '3px 8px', borderRadius: 6,
|
||||
background: `${verdictColor}22`, color: verdictColor, fontWeight: 700, fontSize: 9.5,
|
||||
border: `1px solid ${verdictColor}55`, lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{v.verdict}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 8, letterSpacing: '0.08em', color: 'var(--text-2, #707080)' }}>MODEL READ</div>
|
||||
</div>
|
||||
|
||||
<Fighter f={b} align="right" />
|
||||
</div>
|
||||
|
||||
{v.summary && (
|
||||
<p className="mono" style={{ fontSize: 10.5, color: 'var(--text-2, #707080)', marginTop: 12, lineHeight: 1.5 }}>
|
||||
{v.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Odds row — MONEYLINE + round total REAL; method/round/KO data-limited. */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 8, marginTop: 16 }}>
|
||||
<OddsCell label={`MONEYLINE · ${initials(a.name)}`} value={fmtOdds(ml?.home)} />
|
||||
<OddsCell label={`MONEYLINE · ${initials(b.name)}`} value={fmtOdds(ml?.away)} />
|
||||
<OddsCell
|
||||
label={rt?.line != null ? `ROUND TOTAL · O${rt.line}` : 'ROUND TOTAL'}
|
||||
value={rt ? `${fmtOdds(rt.over)} / ${fmtOdds(rt.under)}` : DASH}
|
||||
dataLimited={!rt}
|
||||
/>
|
||||
<OddsCell label="METHOD · KO / SUB / DEC" dataLimited />
|
||||
</div>
|
||||
<p className="mono" style={{ fontSize: 9, color: 'var(--text-2, #707080)', marginTop: 10, lineHeight: 1.5 }}>
|
||||
Method, round and fighter-prop grades are DATA-LIMITED on the free feed — shown as {DASH}, never fabricated.
|
||||
Odds are REAL book numbers; the style verdict is a MODEL read, not a settled grade.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export { default as TierRecord } from './TierRecord';
|
||||
|
||||
/* Player Intelligence (Session 42) */
|
||||
export { default as ArchetypeBadge } from './ArchetypeBadge';
|
||||
export { default as FightCard } from './FightCard';
|
||||
export type { FightCardProps, FighterTape, FightCardOdds, FightVerdict, BlendEntry } from './FightCard';
|
||||
export { default as ArchetypeBlend } from './ArchetypeBlend';
|
||||
export type { BlendSegment } from './ArchetypeBlend';
|
||||
export { default as StatStrip } from './StatStrip';
|
||||
|
||||
Reference in New Issue
Block a user