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,185 @@
|
||||
// Wave 6 — combatAdapter defensive parse. Fixtures modeled on the REAL ESPN
|
||||
// MMA scoreboard shape (site.api.espn.com/.../mma/ufc/scoreboard) captured
|
||||
// live during the build. NO network — fetchImpl + cache are injected.
|
||||
|
||||
const combat = require('../../src/services/adapters/combatAdapter');
|
||||
|
||||
// A trimmed but structurally-faithful ESPN MMA scoreboard payload: one UFC
|
||||
// event ("card") with two bouts. Athlete ids live in the player-card link href.
|
||||
const ESPN_FIXTURE = {
|
||||
events: [
|
||||
{
|
||||
id: '600059599',
|
||||
name: 'UFC Fight Night: Du Plessis vs. Usman',
|
||||
shortName: 'UFC Fight Night',
|
||||
date: '2026-07-18T21:00Z',
|
||||
competitions: [
|
||||
{
|
||||
id: '1',
|
||||
type: { id: '1007', abbreviation: 'W Flyweight', text: "Women's Flyweight" },
|
||||
format: { regulation: { periods: 5 } },
|
||||
venue: { fullName: 'UFC APEX' },
|
||||
status: { type: { state: 'pre', completed: false } },
|
||||
competitors: [
|
||||
{
|
||||
id: '10', order: 0, winner: false,
|
||||
athlete: {
|
||||
fullName: 'Dricus du Plessis', displayName: 'Dricus du Plessis', shortName: 'D. du Plessis',
|
||||
links: [{ href: 'https://www.espn.com/mma/fighter/_/id/4801725/dricus-du-plessis' }],
|
||||
},
|
||||
records: [{ name: 'overall', type: 'total', summary: '22-2-0' }],
|
||||
},
|
||||
{
|
||||
id: '11', order: 1, winner: false,
|
||||
athlete: {
|
||||
fullName: 'Kamaru Usman', displayName: 'Kamaru Usman', shortName: 'K. Usman',
|
||||
links: [{ href: 'https://www.espn.com/mma/fighter/_/id/3088843/kamaru-usman' }],
|
||||
},
|
||||
records: [{ name: 'overall', type: 'total', summary: '20-4-0' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: { abbreviation: 'Lightweight' },
|
||||
format: { regulation: { periods: 3 } },
|
||||
competitors: [
|
||||
{ id: '20', order: 0, athlete: { displayName: 'Fighter A', links: [] }, records: [{ type: 'total', summary: '10-0' }] },
|
||||
{ id: '21', order: 1, athlete: { displayName: 'Fighter B', links: [] }, records: [{ type: 'total', summary: '8-3' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('combatAdapter.normalizeScoreboard — ESPN shape → fight cards', () => {
|
||||
it('normalizes a real-shaped payload into a card with bouts + tale-of-tape', () => {
|
||||
const { events } = combat.normalizeScoreboard(ESPN_FIXTURE);
|
||||
expect(events).toHaveLength(1);
|
||||
const card = events[0];
|
||||
expect(card.id).toBe('600059599');
|
||||
expect(card.name).toMatch(/Du Plessis/);
|
||||
expect(card.bouts).toHaveLength(2);
|
||||
|
||||
const bout = card.bouts[0];
|
||||
expect(bout.weightClass).toBe("Women's Flyweight");
|
||||
expect(bout.rounds).toBe(5);
|
||||
expect(bout.fighters).toHaveLength(2);
|
||||
|
||||
const a = bout.fighters[0];
|
||||
expect(a.name).toBe('Dricus du Plessis');
|
||||
expect(a.id).toBe('4801725'); // parsed from the link href
|
||||
expect(a.record.wins).toBe(22);
|
||||
expect(a.record.losses).toBe(2);
|
||||
expect(a.record.display).toBe('22–2'); // en-dash display form
|
||||
// Physicals absent from the free feed → null, NEVER fabricated 0.
|
||||
expect(a.stance).toBeNull();
|
||||
expect(a.reach).toBeNull();
|
||||
});
|
||||
|
||||
it('date-pins defensively — an off-date event is dropped', () => {
|
||||
const onDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-07-18' });
|
||||
expect(onDate.events).toHaveLength(1);
|
||||
const offDate = combat.normalizeScoreboard(ESPN_FIXTURE, { date: '2026-01-01' });
|
||||
expect(offDate.events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('DEFENSIVE: unrecognized/garbage shapes return empty, never throw', () => {
|
||||
expect(() => combat.normalizeScoreboard(null)).not.toThrow();
|
||||
expect(combat.normalizeScoreboard(null).events).toEqual([]);
|
||||
expect(combat.normalizeScoreboard({}).events).toEqual([]);
|
||||
expect(combat.normalizeScoreboard({ events: 'nope' }).events).toEqual([]);
|
||||
// An identifiable event with only unusable bouts is kept with empty bouts.
|
||||
expect(combat.normalizeScoreboard({ events: [{ id: 'x', competitions: [{ competitors: [{}] }] }] }).events[0].bouts).toEqual([]);
|
||||
// An event with no id AND no usable bouts is dropped entirely.
|
||||
expect(combat.normalizeScoreboard({ events: [{ competitions: [{ competitors: [{}] }] }] }).events).toEqual([]);
|
||||
});
|
||||
|
||||
it('numOrNull never coerces null/empty to 0 (the fabrication trap)', () => {
|
||||
expect(combat.numOrNull(null)).toBeNull();
|
||||
expect(combat.numOrNull('')).toBeNull();
|
||||
expect(combat.numOrNull(undefined)).toBeNull();
|
||||
expect(combat.numOrNull('5')).toBe(5);
|
||||
expect(combat.numOrNull(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('parseAthleteId is defensive on bad links', () => {
|
||||
expect(combat.parseAthleteId(null)).toBeNull();
|
||||
expect(combat.parseAthleteId([{ href: 'no-id-here' }])).toBeNull();
|
||||
expect(combat.parseAthleteId([{ href: '/mma/fighter/_/id/999/x' }])).toBe('999');
|
||||
});
|
||||
});
|
||||
|
||||
describe('combatAdapter.getFightCards — injectable, no network', () => {
|
||||
it('fetches via injected fetchImpl + normalizes (cache stubbed)', async () => {
|
||||
const calls = [];
|
||||
const res = await combat.getFightCards('2026-07-18', {
|
||||
fetchImpl: async (url) => { calls.push(url); return ESPN_FIXTURE; },
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => true,
|
||||
});
|
||||
expect(res.events).toHaveLength(1);
|
||||
expect(calls[0]).toMatch(/dates=20260718/);
|
||||
});
|
||||
|
||||
it('returns honest empty on a fetch error, never throws', async () => {
|
||||
const res = await combat.getFightCards('2026-07-18', {
|
||||
fetchImpl: async () => { throw new Error('network'); },
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => true,
|
||||
});
|
||||
expect(res.events).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combatAdapter.normalizeCombatOdds — odds-api MMA → ML + round total', () => {
|
||||
const ODDS_FIXTURE = [
|
||||
{
|
||||
id: 'evt1', commence_time: '2026-07-18T21:00Z',
|
||||
home_team: 'Dricus du Plessis', away_team: 'Kamaru Usman',
|
||||
bookmakers: [
|
||||
{
|
||||
key: 'draftkings',
|
||||
markets: [
|
||||
{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -230 }, { name: 'Kamaru Usman', price: 190 }] },
|
||||
{ key: 'totals', outcomes: [{ name: 'Over', price: -110, point: 2.5 }, { name: 'Under', price: -110, point: 2.5 }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'fanduel',
|
||||
markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -215 }, { name: 'Kamaru Usman', price: 200 }] }],
|
||||
},
|
||||
// A non-allow-listed book must be ignored.
|
||||
{ key: 'bovada', markets: [{ key: 'h2h', outcomes: [{ name: 'Dricus du Plessis', price: -999 }] }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it('maps h2h to per-fighter moneyline (best price) + totals to round total', () => {
|
||||
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
|
||||
const rec = map['dricus du plessis|kamaru usman'];
|
||||
expect(rec).toBeDefined();
|
||||
expect(rec.moneyline.home).toBe(-215); // best (higher) of -230 / -215
|
||||
expect(rec.moneyline.away).toBe(200); // best of 190 / 200
|
||||
expect(rec.roundTotal.line).toBe(2.5);
|
||||
expect(rec.roundTotal.over).toBe(-110);
|
||||
});
|
||||
|
||||
it('ignores non-allow-listed books (bovada never leaks a price)', () => {
|
||||
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
|
||||
expect(map['dricus du plessis|kamaru usman'].moneyline.home).not.toBe(-999);
|
||||
});
|
||||
|
||||
it('matchBoutOdds joins in either name orientation', () => {
|
||||
const map = combat.normalizeCombatOdds(ODDS_FIXTURE);
|
||||
const bout = { fighters: [{ name: 'Kamaru Usman' }, { name: 'Dricus du Plessis' }] };
|
||||
expect(combat.matchBoutOdds(bout, map)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('DEFENSIVE: garbage odds input returns {}, never throws', () => {
|
||||
expect(() => combat.normalizeCombatOdds(null)).not.toThrow();
|
||||
expect(combat.normalizeCombatOdds(null)).toEqual({});
|
||||
expect(combat.normalizeCombatOdds([{ bookmakers: 'x' }])).toEqual({});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user