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 || '')} />;
|
||||
}
|
||||
Reference in New Issue
Block a user