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
+27
View File
@@ -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' });
}
}
+25
View File
@@ -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 });
}
}
+114
View File
@@ -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>
);
}
+20
View File
@@ -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 || '')} />;
}