Files
vyndr/tests/unit/combatArchetypes.test.js
builtbykev 54fa5853f5 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>
2026-07-13 16:58:22 -04:00

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/);
});
});