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>
82 lines
3.1 KiB
JavaScript
82 lines
3.1 KiB
JavaScript
/**
|
|
* /api/combat/:date + /api/fight/:id (Wave 6 — combat intelligence, honest v1).
|
|
*
|
|
* Fight-card discovery + tale-of-the-tape + best-effort moneyline / round-total
|
|
* odds. FREE ESPN MMA feed for the cards; the paid odds-api feed for ML/round
|
|
* totals is BEST-EFFORT + cached (never fails the card). Combat is NOT in the
|
|
* snapshot/settle loop → NO settled grades here; method/round/KO are surfaced by
|
|
* the frontend as honest "— data-limited", never fabricated.
|
|
*
|
|
* Response (cards):
|
|
* { date, events: [ { id, name, shortName, date, venue,
|
|
* bouts: [ { id, weightClass, rounds, status, fighters: [ …tale ],
|
|
* odds: { moneyline, roundTotal } | null } ] } ], source }
|
|
*/
|
|
|
|
const express = require('express');
|
|
const combat = require('../services/adapters/combatAdapter');
|
|
const { createRateLimit } = require('../middleware/rateLimit');
|
|
|
|
const router = express.Router();
|
|
// Public throttle (60/min; ESPN is free, odds are cached — be respectful).
|
|
router.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
|
|
|
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Styles make fights' };
|
|
|
|
// Attach cached/best-effort odds onto each bout of each event.
|
|
function attachOdds(events, oddsMap) {
|
|
for (const ev of events || []) {
|
|
for (const bout of ev.bouts || []) {
|
|
const o = combat.matchBoutOdds(bout, oddsMap);
|
|
bout.odds = o ? { moneyline: o.moneyline, roundTotal: o.roundTotal } : null;
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
// GET /api/combat/:date — fight cards for an ET date (defaults to today).
|
|
router.get('/:date', async (req, res) => {
|
|
const date = /^\d{4}-\d{2}-\d{2}$/.test(String(req.params.date || ''))
|
|
? req.params.date
|
|
: combat.todayET();
|
|
try {
|
|
const [cards, oddsMap] = await Promise.all([
|
|
combat.getFightCards(date),
|
|
combat.getCombatOdds().catch(() => ({})),
|
|
]);
|
|
attachOdds(cards.events, oddsMap);
|
|
res.set('Cache-Control', 'public, max-age=300');
|
|
return res.set(MISSION_HEADER).json(cards);
|
|
} catch (err) {
|
|
console.error('[combat/:date]', err && err.message);
|
|
// The board is never a crash — honest empty on failure.
|
|
return res.set(MISSION_HEADER).json({ date, events: [], source: 'espn' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|
|
|
|
// Separate router for /api/fight/:id (a single card by event id).
|
|
const fightRouter = express.Router();
|
|
fightRouter.use(createRateLimit({ windowMs: 60_000, max: 60 }));
|
|
|
|
fightRouter.get('/:id', async (req, res) => {
|
|
const id = String(req.params.id || '').replace(/[^0-9]/g, '');
|
|
if (!id) return res.status(404).set(MISSION_HEADER).json({ error: 'not found' });
|
|
try {
|
|
const [card, oddsMap] = await Promise.all([
|
|
combat.getFightCard(id),
|
|
combat.getCombatOdds().catch(() => ({})),
|
|
]);
|
|
if (!card) return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' });
|
|
attachOdds([card], oddsMap);
|
|
res.set('Cache-Control', 'public, max-age=300');
|
|
return res.set(MISSION_HEADER).json({ event: card, source: 'espn' });
|
|
} catch (err) {
|
|
console.error('[fight/:id]', err && err.message);
|
|
return res.status(404).set(MISSION_HEADER).json({ error: 'card not found' });
|
|
}
|
|
});
|
|
|
|
module.exports.fightRouter = fightRouter;
|