54fa5853f5
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>
109 lines
4.9 KiB
JavaScript
109 lines
4.9 KiB
JavaScript
// Wave 6 — combat intelligence: archetype registry cross-file match,
|
|
// classify('mma') blends from fixtures (thin data → fewer claims, never
|
|
// fabricated), and styleMatchup honesty. NO network.
|
|
|
|
const svc = require('../../src/services/archetypeService');
|
|
const arch = require('../../web/src/lib/archetypes');
|
|
|
|
describe('combat archetype registry — pinned + cross-file agreement', () => {
|
|
const PINNED = {
|
|
STRIKER: { color: '#E8703A', glyph: '✦' },
|
|
GRAPPLER: { color: '#2FA4E7', glyph: '⊗' },
|
|
PRESSURE: { color: '#E4574C', glyph: '➤' },
|
|
COUNTER: { color: '#8E7BE0', glyph: '◊' },
|
|
FINISHER: { color: '#12B886', glyph: '▲' },
|
|
GRINDER: { color: '#B0883B', glyph: '▦' },
|
|
};
|
|
|
|
it('backend COMBAT_ARCHETYPES carries exactly the six pinned styles', () => {
|
|
expect(Object.keys(svc.COMBAT_ARCHETYPES).sort()).toEqual(Object.keys(PINNED).sort());
|
|
});
|
|
|
|
it('backend colors + glyphs match the pinned spec', () => {
|
|
for (const [name, p] of Object.entries(PINNED)) {
|
|
expect(svc.COMBAT_ARCHETYPES[name].color).toBe(p.color);
|
|
expect(svc.COMBAT_ARCHETYPES[name].glyph).toBe(p.glyph);
|
|
}
|
|
});
|
|
|
|
it('frontend COMBAT_ARCHETYPE_MAP colors + glyph chars MATCH the backend', () => {
|
|
for (const [name, a] of Object.entries(svc.COMBAT_ARCHETYPES)) {
|
|
const front = arch.COMBAT_ARCHETYPE_MAP[name];
|
|
expect(front).toBeDefined();
|
|
expect(front.c).toBe(a.color);
|
|
expect(front.char).toBe(a.glyph);
|
|
}
|
|
// No extra frontend combat archetypes beyond the pinned six.
|
|
expect(Object.keys(arch.COMBAT_ARCHETYPE_MAP).sort()).toEqual(Object.keys(svc.COMBAT_ARCHETYPES).sort());
|
|
});
|
|
|
|
it('combat FINISHER is namespaced — it does NOT collide with the soccer FINISHER', () => {
|
|
// Soccer FINISHER stays #FF5C5C in the shared map; combat FINISHER is #12B886.
|
|
expect(arch.ARCHETYPE_MAP.FINISHER.c).toBe('#FF5C5C');
|
|
expect(arch.combatArchetypeColor('FINISHER')).toBe('#12B886');
|
|
// sport-aware resolution keeps them apart:
|
|
expect(arch.archetypeColor('FINISHER')).toBe('#FF5C5C'); // no sport → soccer
|
|
expect(arch.archetypeColor('FINISHER', 'mma')).toBe('#12B886'); // mma → combat
|
|
});
|
|
});
|
|
|
|
describe("classify('mma', …) — blends from fixtures", () => {
|
|
it('a high-volume distance striker profiles STRIKER-primary', () => {
|
|
const r = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, strDef: 0.62, tdAvg: 0.2, koRate: 0.6, decRate: 0.3 });
|
|
expect(r.primary && r.primary.name).toBe('STRIKER');
|
|
expect(r.blend.length).toBeGreaterThan(0);
|
|
expect(r.blend.map((b) => b.archetype)).toContain('STRIKER');
|
|
});
|
|
|
|
it('a takedown-heavy submission threat profiles GRAPPLER-primary', () => {
|
|
const r = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, sapm: 2, strDef: 0.5, koRate: 0.1, subRate: 0.5, decRate: 0.4 });
|
|
expect(r.primary && r.primary.name).toBe('GRAPPLER');
|
|
});
|
|
|
|
it('a high-finish record profiles FINISHER via method rates', () => {
|
|
const r = svc.classify('mma', { koWins: 10, subWins: 5, decWins: 1, totalWins: 16 });
|
|
expect(r.blend.map((b) => b.archetype)).toContain('FINISHER');
|
|
});
|
|
|
|
it('THIN data yields NO fabricated style — empty blend, null primary (honest)', () => {
|
|
const r = svc.classify('mma', { record: '9-4-0' }); // no strike/td/method inputs
|
|
expect(r.blend).toEqual([]);
|
|
expect(r.primary).toBeNull();
|
|
expect(r.secondary).toBeNull();
|
|
});
|
|
|
|
it('an absent stat never scores an axis (Number(null) === 0 guard)', () => {
|
|
const r = svc.classify('mma', { slpm: null, tdAvg: undefined });
|
|
expect(r.blend).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('styleMatchup — honest MODEL read, never a fabricated grade', () => {
|
|
const striker = svc.classify('mma', { slpm: 6, sapm: 3, strAcc: 0.55, tdAvg: 0.2, koRate: 0.6 });
|
|
const grappler = svc.classify('mma', { tdAvg: 4.5, subAvg: 1.8, slpm: 2.5, subRate: 0.5, decRate: 0.4 });
|
|
|
|
it('divergent styles produce a clear edge to one side (no confidence %)', () => {
|
|
const v = svc.styleMatchup(striker, grappler);
|
|
expect(v.verdict).toMatch(/EDGE$/);
|
|
expect(['a', 'b']).toContain(v.edgeSide);
|
|
expect(v).not.toHaveProperty('edge'); // no fabricated edge %
|
|
expect(v).not.toHaveProperty('confidence'); // no fabricated confidence
|
|
});
|
|
|
|
it('thin data on either side → INSUFFICIENT READ', () => {
|
|
expect(svc.styleMatchup(striker, svc.classify('mma', {})).verdict).toBe('INSUFFICIENT READ');
|
|
expect(svc.styleMatchup([], grappler).verdict).toBe('INSUFFICIENT READ');
|
|
});
|
|
|
|
it('two near-identical style blends → STYLES EVEN, no edge side', () => {
|
|
const v = svc.styleMatchup(striker, striker);
|
|
expect(v.verdict).toBe('STYLES EVEN');
|
|
expect(v.edgeSide).toBeNull();
|
|
});
|
|
|
|
it('accepts raw blend arrays as well as classify() results', () => {
|
|
const v = svc.styleMatchup(striker.blend, grappler.blend);
|
|
expect(v.verdict).toMatch(/EDGE$|EVEN|INSUFFICIENT/);
|
|
});
|
|
});
|