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:
@@ -0,0 +1,27 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Combat (MMA/UFC) fight-cards proxy (Wave 6, S25 rule — Express isn't
|
||||
* reachable from the browser directly). Forwards to /api/combat/:date.
|
||||
* Off-card windows return an empty-but-valid card list so the UI degrades
|
||||
* to the honest empty state, never a crash.
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ date: string }> }) {
|
||||
const { date } = await params;
|
||||
const d = String(date || '').toLowerCase();
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/combat/${encodeURIComponent(d)}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
if (!upstream.ok) return NextResponse.json(data, { status: upstream.status });
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ date: d, events: [], source: 'espn' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* Single fight-card proxy (Wave 6, S25 rule). Forwards to /api/fight/:id.
|
||||
* Unknown/unavailable card → 404 (honest, no fabricated card).
|
||||
*/
|
||||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const fid = String(id || '').replace(/[^0-9]/g, '');
|
||||
if (!fid) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||
try {
|
||||
const upstream = await fetch(`${BACKEND_URL}/api/fight/${encodeURIComponent(fid)}`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await upstream.json().catch(() => ({}));
|
||||
return NextResponse.json(data, { status: upstream.status });
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'card not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FightCard, EmptyState } from '@/components/vyndr';
|
||||
import type { FighterTape } from '@/components/vyndr';
|
||||
|
||||
interface RawFighter {
|
||||
id?: string | null;
|
||||
name: string;
|
||||
record?: { display?: string | null; wins?: number | null; losses?: number | null; draws?: number | null } | null;
|
||||
stance?: string | null;
|
||||
reach?: string | number | null;
|
||||
blend?: { archetype: string; weight: number }[] | null;
|
||||
pedigrees?: string[] | null;
|
||||
}
|
||||
interface RawBout {
|
||||
id?: string | null;
|
||||
weightClass?: string | null;
|
||||
rounds?: number | null;
|
||||
status?: string | null;
|
||||
fighters: RawFighter[];
|
||||
odds?: { moneyline?: { home?: number | null; away?: number | null } | null; roundTotal?: { line?: number | null; over?: number | null; under?: number | null } | null } | null;
|
||||
verdict?: { verdict: string; edgeSide?: 'a' | 'b' | null; summary?: string } | null;
|
||||
}
|
||||
interface RawEvent {
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
shortName?: string | null;
|
||||
date?: string | null;
|
||||
venue?: string | null;
|
||||
bouts: RawBout[];
|
||||
}
|
||||
|
||||
export default function FightCardClient({ id }: { id: string }) {
|
||||
const [event, setEvent] = useState<RawEvent | null>(null);
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'empty'>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch(`/api/fight/${encodeURIComponent(id)}`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((d) => {
|
||||
if (!active) return;
|
||||
const ev: RawEvent | null = d && d.event ? d.event : null;
|
||||
if (ev && Array.isArray(ev.bouts) && ev.bouts.length > 0) {
|
||||
setEvent(ev);
|
||||
setState('ready');
|
||||
} else {
|
||||
setState('empty');
|
||||
}
|
||||
})
|
||||
.catch(() => { if (active) setState('empty'); });
|
||||
return () => { active = false; };
|
||||
}, [id]);
|
||||
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="mono" style={{ padding: '80px 24px', textAlign: 'center', color: 'var(--text-2)', letterSpacing: '0.2em', fontSize: 12 }}>
|
||||
LOADING THE CARD…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'empty' || !event) {
|
||||
return (
|
||||
<EmptyState
|
||||
code="NO CARD SCHEDULED"
|
||||
title="No fight card here"
|
||||
message="The octagon is dark right now. Combat cards post the week of a UFC event — check back closer to fight night."
|
||||
actions={[{ label: 'BACK TO THE SLATE', href: '/dashboard', primary: true }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dateStr = event.date ? new Date(event.date).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' }) : null;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 680, margin: '0 auto', padding: '24px 16px 80px' }}>
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<div className="mono" style={{ fontSize: 10, letterSpacing: '0.18em', color: 'var(--text-2)', marginBottom: 6 }}>
|
||||
{[event.shortName, dateStr, event.venue].filter(Boolean).join(' · ') || 'UFC'}
|
||||
</div>
|
||||
<h1 style={{ fontSize: 24, fontWeight: 800, letterSpacing: '-0.02em', margin: 0, color: 'var(--text-0)' }}>
|
||||
{event.name || 'Fight Card'}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{event.bouts.map((bout, i) => {
|
||||
const fighters: FighterTape[] = (bout.fighters || []).slice(0, 2).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
record: f.record,
|
||||
stance: f.stance,
|
||||
reach: f.reach,
|
||||
blend: f.blend,
|
||||
pedigrees: f.pedigrees,
|
||||
}));
|
||||
return (
|
||||
<FightCard
|
||||
key={bout.id || `${i}`}
|
||||
weightClass={bout.weightClass}
|
||||
rounds={bout.rounds}
|
||||
status={bout.status}
|
||||
fighters={fighters}
|
||||
odds={bout.odds}
|
||||
verdict={bout.verdict}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import FightCardClient from './FightCardClient';
|
||||
|
||||
/**
|
||||
* /fight/[id] (Wave 6 — combat intelligence). Thin server wrapper for page
|
||||
* metadata; the interactive tale-of-the-tape cards live in the client
|
||||
* component. Off-card windows self-hide to the shared EmptyState.
|
||||
*/
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
await params;
|
||||
return {
|
||||
title: 'Fight Card — VYNDR Combat',
|
||||
description: 'Tale-of-the-tape, style-blend archetypes, and moneyline / round-total lines for the UFC card. A MODEL style read — not a settled grade.',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FightPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <FightCardClient id={String(id || '')} />;
|
||||
}
|
||||
@@ -18,11 +18,12 @@ interface ArchetypeBadgeProps {
|
||||
*/
|
||||
export default function ArchetypeBadge({
|
||||
archetype,
|
||||
sport,
|
||||
variant = 'tint',
|
||||
size = 'sm',
|
||||
showDesc = false,
|
||||
}: ArchetypeBadgeProps) {
|
||||
const s = badgeStyle(archetype, variant, size);
|
||||
const s = badgeStyle(archetype, variant, size, sport);
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, verticalAlign: 'middle' }}>
|
||||
<span
|
||||
@@ -46,10 +47,20 @@ export default function ArchetypeBadge({
|
||||
textShadow: s.textShadow,
|
||||
}}
|
||||
>
|
||||
<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';
|
||||
|
||||
@@ -22,7 +22,9 @@ export const SPORTS: Record<SportKey, SportConfig> = {
|
||||
nfl: { key: 'nfl', label: 'NFL', color: '#013369', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
nhl: { key: 'nhl', label: 'NHL', color: '#A0A0B0', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
tennis: { key: 'tennis', label: 'Tennis', color: '#C5B358', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
// Wave 6 — combat intelligence: MMA is live as a READ surface (fight cards +
|
||||
// tale-of-the-tape). Not in the graded-props pipeline yet → collectData false.
|
||||
mma: { key: 'mma', label: 'MMA', color: '#D4AF37', active: true, collectData: false },
|
||||
boxing: { key: 'boxing', label: 'Boxing', color: '#8B0000', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
golf: { key: 'golf', label: 'Golf', color: '#2E7D32', active: false, collectData: false, comingSoon: 'Coming this summer' },
|
||||
};
|
||||
|
||||
@@ -84,6 +84,21 @@ const ARCHETYPE_MAP = {
|
||||
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
|
||||
};
|
||||
|
||||
/* Combat archetype visual map (Wave 6 — MMA/UFC). SEPARATE from ARCHETYPE_MAP:
|
||||
FINISHER's combat name/color/glyph differ from the soccer FINISHER, and
|
||||
combat FINISHER's green is intentionally close to the pitch-green here. Keys/
|
||||
colors/glyph CHARS MUST match src/services/archetypeService.js
|
||||
COMBAT_ARCHETYPES — tests/unit/combatArchetypes.test.js asserts it.
|
||||
`char` is a unicode glyph (rendered as text, not an SVG glyph key). */
|
||||
const COMBAT_ARCHETYPE_MAP = {
|
||||
STRIKER: { c: '#E8703A', d: 'Wins on the feet — volume + power at range.', char: '✦', axis: 'range' },
|
||||
GRAPPLER: { c: '#2FA4E7', d: 'Fight hits the mat on his terms — control + subs.', char: '⊗', axis: 'range' },
|
||||
PRESSURE: { c: '#E4574C', d: 'Forward, relentless, breaks the pace.', char: '➤', axis: 'tempo' },
|
||||
COUNTER: { c: '#8E7BE0', d: 'Patient — punishes what you show him.', char: '◊', axis: 'tempo' },
|
||||
FINISHER: { c: '#12B886', d: 'Ends nights — high KO/SUB rate.', char: '▲', axis: 'outcome' },
|
||||
GRINDER: { c: '#B0883B', d: 'Goes the distance, wins the rounds.', char: '▦', axis: 'outcome' },
|
||||
};
|
||||
|
||||
const FALLBACK = { c: '#9499A8', d: '', g: '' };
|
||||
|
||||
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
|
||||
@@ -93,15 +108,26 @@ for (const [k, v] of Object.entries(ARCHETYPE_MAP)) {
|
||||
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
|
||||
}
|
||||
|
||||
function archetypeInfo(name) {
|
||||
function archetypeInfo(name, sport) {
|
||||
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||
// Combat archetypes live in their own namespace (FINISHER collides with the
|
||||
// soccer archetype) — resolve them ONLY when the sport is MMA.
|
||||
if (String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[key]) {
|
||||
return COMBAT_ARCHETYPE_MAP[key];
|
||||
}
|
||||
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
|
||||
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
|
||||
return FALLBACK;
|
||||
}
|
||||
|
||||
function archetypeColor(name) {
|
||||
return archetypeInfo(name).c;
|
||||
function archetypeColor(name, sport) {
|
||||
return archetypeInfo(name, sport).c;
|
||||
}
|
||||
|
||||
/** Combat-only color lookup (unambiguous — no soccer FINISHER collision). */
|
||||
function combatArchetypeColor(name) {
|
||||
const key = (name == null ? '' : String(name)).toUpperCase();
|
||||
return (COMBAT_ARCHETYPE_MAP[key] || FALLBACK).c;
|
||||
}
|
||||
|
||||
function glyphSvg(glyphKey) {
|
||||
@@ -114,8 +140,8 @@ function glyphSvg(glyphKey) {
|
||||
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
|
||||
* size: 'sm' | 'md'.
|
||||
*/
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
const info = archetypeInfo(name);
|
||||
function badgeStyle(name, variant = 'tint', size = 'sm', sport) {
|
||||
const info = archetypeInfo(name, sport);
|
||||
const sm = size === 'sm';
|
||||
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
|
||||
if (variant === 'full' || variant === 'solid') {
|
||||
@@ -128,11 +154,14 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
}
|
||||
// Display the canonical VYNDR name even if a legacy name was passed.
|
||||
const upper = (name == null ? '' : String(name)).toUpperCase();
|
||||
const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
|
||||
const isCombat = String(sport || '').toLowerCase() === 'mma' && COMBAT_ARCHETYPE_MAP[upper];
|
||||
const canonical = isCombat ? upper : ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
|
||||
return {
|
||||
name: canonical,
|
||||
desc: info.d,
|
||||
glyph: info.g,
|
||||
// Combat glyphs are unicode chars rendered as TEXT (not SVG glyph keys).
|
||||
glyphChar: isCombat ? info.char : null,
|
||||
color: info.c,
|
||||
textColor, bg, borderColor, glyphColor, textShadow,
|
||||
fontSize: sm ? '9.5px' : '12px',
|
||||
@@ -146,8 +175,10 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
|
||||
module.exports = {
|
||||
GLYPHS,
|
||||
ARCHETYPE_MAP,
|
||||
COMBAT_ARCHETYPE_MAP,
|
||||
archetypeInfo,
|
||||
archetypeColor,
|
||||
combatArchetypeColor,
|
||||
glyphSvg,
|
||||
badgeStyle,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,9 @@ const SPORT = {
|
||||
mlb: { label: 'MLB', color: 'var(--s-mlb)', hex: '#1e90ff' },
|
||||
wnba: { label: 'WNBA', color: 'var(--s-wnba)', hex: '#f7944a' },
|
||||
soccer: { label: 'SOC', color: 'var(--s-soccer)', hex: '#3ddc84' },
|
||||
// Wave 6 — combat: the #D4AF37 championship-gold token (matches
|
||||
// src/services/shareCards/tokens.js + config/sports.js mma color).
|
||||
mma: { label: 'MMA', color: 'var(--s-mma, #d4af37)', hex: '#d4af37' },
|
||||
};
|
||||
|
||||
/* GradeBadge size variants — hero stays 80–120px (§5: grade letter is
|
||||
|
||||
Reference in New Issue
Block a user