Session 42: Player Intelligence System — archetypes, stat strips, player profile, enhanced cards (2011 tests)
Built from the Claude Design "VYNDR Player Intelligence" bundle (10 sections). - Archetypes: src/services/archetypeService.js — 41 archetypes (15 NBA / 5 WNBA-unique / 15 MLB / 6 soccer), classify -> primary+secondary+blend. Frontend visual map web/src/lib/archetypes.js (colors verified == backend). ArchetypeBadge (full/ghost/tint + glyphs) + ArchetypeBlend (DNA bar). - StatStrip (compact/expanded): player name once, horizontal mono stats, inline GradeBadge props, onPlayerClick -> profile. - Stats API: extended src/routes/stats.js with /player/:name, /leaders, /game/:id (rate-limited). Aggregation in playerIntelService.js (sanitizes name param; grades cache; graceful on cold cache). Next proxies added. - Player Profile /player/[name]: all 9 design sections, graceful empty states. - Enhanced GameCard (MLB pitchers + player-grouped StatStrips) + GradeResultCard (archetype strip + stat context + VYNDR intelligence, optional/self-hiding via gradeAdapter.buildIntelFields). Player-name links wired everywhere. - Settings page replaces the S41 redirect (account/subscription/notifications/ display/responsible-play/danger-zone with DELETE-gated delete). LINKS to the real /settings/security MFA page — does not replace it. + BookChip. - Bonus: Stats Explorer /explore (real /api/stats/leaders leaderboard); added Explore + Settings to Nav MORE. Deferred (need data pipelines, Session 43): Team Hub, Offseason Intel, Slate redesign, Stats Explorer sub-panels. Backend 1940 -> 2011 tests (+71), 157 suites. Web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
const express = require('express');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { getStatFilters } = require('../config/statFilters');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { getPlayerIntel, getLeaders } = require('../services/playerIntelService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MISSION_HEADER = { 'X-VYNDR-Mission': 'Kill bad satisfieds before they satisfieds you' };
|
||||
|
||||
// Player-intelligence endpoints are public + cache-backed (Session 42). Cap
|
||||
// per-IP like the other public cached routers (60/min).
|
||||
const intelLimit = createRateLimit({ windowMs: 60_000, max: 60 });
|
||||
|
||||
// GET /filters/:sport — stat-filter categories for the StatFilterPills UI
|
||||
// (Session 23). Lets the frontend stay data-driven without re-declaring
|
||||
// the category list. NO auth / NO DB — pure config.
|
||||
@@ -117,4 +123,46 @@ router.get('/live', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /player/:name?sport=nba — full player intelligence payload (Session 42):
|
||||
// standard stats + archetype DNA + VYNDR intelligence + tonight's graded props.
|
||||
// Name is sanitized inside the service. Always 200 with a valid shape (degrades
|
||||
// gracefully on cold caches) — the profile page must never hard-fail.
|
||||
router.get('/player/:name', intelLimit, async (req, res) => {
|
||||
try {
|
||||
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||
const data = await getPlayerIntel(req.params.name, sport);
|
||||
res.set(MISSION_HEADER).json(data);
|
||||
} catch (err) {
|
||||
console.error('[stats/player]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /leaders?sport=mlb&stat=hits&limit=10 — tonight's stat leaders (top
|
||||
// graded props by confidence) for the Terminal / Stats Explorer.
|
||||
router.get('/leaders', intelLimit, async (req, res) => {
|
||||
try {
|
||||
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||
const leaders = await getLeaders(sport, { stat: req.query.stat, limit: req.query.limit });
|
||||
res.set(MISSION_HEADER).json({ sport, stat: req.query.stat || null, leaders });
|
||||
} catch (err) {
|
||||
console.error('[stats/leaders]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /game/:id?sport=nba — game-level enrichment (pitchers, injuries, leaders,
|
||||
// team records) via the ESPN summary (Session 30). Best-effort.
|
||||
router.get('/game/:id', intelLimit, async (req, res) => {
|
||||
try {
|
||||
const sport = String(req.query.sport || 'nba').toLowerCase();
|
||||
const { getGameSummary } = require('../services/scheduleService');
|
||||
const summary = await getGameSummary(sport, req.params.id);
|
||||
res.set(MISSION_HEADER).json(summary || { error: 'not_found' });
|
||||
} catch (err) {
|
||||
console.error('[stats/game]', err.message);
|
||||
res.status(503).set(MISSION_HEADER).json({ error: 'Service temporarily unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* archetypeService — player archetype classification (Session 42).
|
||||
*
|
||||
* Pure logic: categorize a player by their prop-behavior pattern from season
|
||||
* averages + usage. Returns a PRIMARY archetype, an optional SECONDARY, and a
|
||||
* weighted `blend` (the production-DNA bar in the design's player profile).
|
||||
*
|
||||
* The archetype NAMES + colors + glyph keys are the contract shared with the
|
||||
* frontend `ArchetypeBadge` (web/src/components/vyndr/ArchetypeBadge.tsx) and
|
||||
* `ArchetypeData` (web/src/lib/archetypes.ts) — ported verbatim from the
|
||||
* "VYNDR Player Intelligence" design (ArchetypeBadge.dc.html MAP). Keep the
|
||||
* hex/glyph in sync across all three.
|
||||
*
|
||||
* No API calls, no I/O — feed it a stat object, get a classification. That's
|
||||
* why it lives in services/ and is unit-testable in isolation.
|
||||
*/
|
||||
|
||||
// ── Archetype registry ──────────────────────────────────────────────
|
||||
// color + glyph match the design's badge map. propDNA = which props this
|
||||
// archetype makes reliable vs volatile. education = the profile's "what does
|
||||
// this mean" copy.
|
||||
const ARCHETYPES = {
|
||||
// ───────── NBA (15) ─────────
|
||||
'VOLUME SCORER': {
|
||||
tag: 'VOL SCORER', sport: 'nba', color: '#FF6B4A', glyph: 'triangle',
|
||||
description: 'High usage, shot-dependent scorer',
|
||||
propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] },
|
||||
education: 'Volume scorers carry a heavy shot diet, so their points props track usage closely. When they get their normal touches, points clear reliably; assists and threes swing with game script.',
|
||||
},
|
||||
'FLOOR GENERAL': {
|
||||
tag: 'FLOOR GEN', sport: 'nba', color: '#4A9EFF', glyph: 'node',
|
||||
description: 'Assist-heavy playmaker',
|
||||
propDNA: { reliable: ['assists'], volatile: ['points', 'threes'] },
|
||||
education: 'Floor generals create for others first. Assists are their most stable prop because the offense runs through them; their scoring fluctuates with shot selection and matchup.',
|
||||
},
|
||||
'TWO-WAY ANCHOR': {
|
||||
tag: 'TW ANCHOR', sport: 'nba', color: '#A78BFA', glyph: 'shield',
|
||||
description: 'Defense, rebounds, blocks',
|
||||
propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] },
|
||||
education: 'Two-way anchors generate value on the defensive glass and at the rim. Rebounds and blocks are matchup-resilient; their scoring depends on how much offense flows their way.',
|
||||
},
|
||||
'STRETCH BIG': {
|
||||
tag: 'STRETCH BIG', sport: 'nba', color: '#2DD4BF', glyph: 'target',
|
||||
description: 'Floor-spacing shooting big',
|
||||
propDNA: { reliable: ['threes', 'rebounds'], volatile: ['assists', 'blocks'] },
|
||||
education: 'Stretch bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.',
|
||||
},
|
||||
'USAGE SPONGE': {
|
||||
tag: 'USG SPONGE', sport: 'nba', color: '#FFB347', glyph: 'uparrow',
|
||||
description: 'Usage spikes when stars sit',
|
||||
propDNA: { reliable: ['points'], volatile: ['assists', 'rebounds'] },
|
||||
education: 'Usage sponges soak up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.',
|
||||
},
|
||||
'COMBO GUARD': {
|
||||
tag: 'COMBO GD', sport: 'nba', color: '#00D4A0', glyph: 'twin',
|
||||
description: 'Scoring + playmaking hybrid',
|
||||
propDNA: { reliable: ['points', 'assists'], volatile: ['rebounds'] },
|
||||
education: 'Combo guards score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.',
|
||||
},
|
||||
'ROLE GLUE': {
|
||||
tag: 'ROLE GLUE', sport: 'nba', color: '#9499A8', glyph: 'chain',
|
||||
description: 'Low-usage specialist',
|
||||
propDNA: { reliable: [], volatile: ['points', 'assists', 'rebounds'] },
|
||||
education: 'Role glue players do the little things at low usage. Their counting props are thin and matchup-dependent — they reward unders more often than overs.',
|
||||
},
|
||||
'TRANSITION ENGINE': {
|
||||
tag: 'TRANS ENG', sport: 'nba', color: '#22D3EE', glyph: 'chevrons',
|
||||
description: 'Pace-pushing fast-break threat',
|
||||
propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] },
|
||||
education: 'Transition engines feast in the open floor. Their points correlate with game pace — target overs in projected up-tempo matchups.',
|
||||
},
|
||||
'POST SCORER': {
|
||||
tag: 'POST SCORER', sport: 'nba', color: '#FF5C5C', glyph: 'postup',
|
||||
description: 'Back-to-basket interior scorer',
|
||||
propDNA: { reliable: ['points', 'rebounds'], volatile: ['threes', 'assists'] },
|
||||
education: 'Post scorers operate in the paint. Points and rebounds are reliable against most fronts; perimeter props are noise.',
|
||||
},
|
||||
'DEFENSIVE SPECIALIST': {
|
||||
tag: 'DEF SPEC', sport: 'nba', color: '#6366F1', glyph: 'shieldCheck',
|
||||
description: 'Perimeter stopper, low usage',
|
||||
propDNA: { reliable: ['steals'], volatile: ['points', 'assists'] },
|
||||
education: 'Defensive specialists earn minutes with their on-ball defense. Steals and blocks carry their card; offensive props are low-volume and streaky.',
|
||||
},
|
||||
'POINT FORWARD': {
|
||||
tag: 'PT FWD', sport: 'nba', color: '#38BDF8', glyph: 'half',
|
||||
description: 'Oversized primary creator',
|
||||
propDNA: { reliable: ['points', 'assists', 'rebounds'], volatile: ['threes'] },
|
||||
education: 'Point forwards run the offense from a wing or big body, so points, assists, and rebounds all stay live. Their three-point output is the swing factor.',
|
||||
},
|
||||
SLASHER: {
|
||||
tag: 'SLASHER', sport: 'nba', color: '#FB923C', glyph: 'slash',
|
||||
description: 'Rim-attacking, foul-drawing driver',
|
||||
propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] },
|
||||
education: 'Slashers live at the rim and the free-throw line. Points are stable; their three-point props are volatile because they rarely settle for jumpers.',
|
||||
},
|
||||
'RIM RUNNER': {
|
||||
tag: 'RIM RUN', sport: 'nba', color: '#F472B6', glyph: 'arc',
|
||||
description: 'Lob and putback finisher',
|
||||
propDNA: { reliable: ['rebounds'], volatile: ['points', 'assists'] },
|
||||
education: 'Rim runners finish lobs and putbacks. Rebounds are reliable; their scoring depends entirely on feeds from creators.',
|
||||
},
|
||||
'3-AND-D': {
|
||||
tag: '3&D', sport: 'nba', color: '#818CF8', glyph: 'crosshair',
|
||||
description: 'Catch-and-shoot plus defense',
|
||||
propDNA: { reliable: ['threes'], volatile: ['points', 'assists'] },
|
||||
education: '3-and-D wings catch and shoot. Threes are their signature prop; total points swing with how many open looks the offense generates.',
|
||||
},
|
||||
'SIXTH MAN': {
|
||||
tag: '6TH MAN', sport: 'nba', color: '#FACC15', glyph: 'bolt',
|
||||
description: 'Bench scoring spark',
|
||||
propDNA: { reliable: ['points'], volatile: ['rebounds', 'assists'] },
|
||||
education: 'Sixth men provide instant offense off the bench. Their points props depend on minutes — confirm the rotation before betting overs.',
|
||||
},
|
||||
|
||||
// ───────── WNBA-unique (5) ─────────
|
||||
'POST FACILITATOR': {
|
||||
tag: 'POST FAC', sport: 'wnba', color: '#C084FC', glyph: 'node',
|
||||
description: 'Playmaking hub from the post',
|
||||
propDNA: { reliable: ['assists', 'rebounds'], volatile: ['threes'] },
|
||||
education: 'Post facilitators orchestrate from the elbow and block. Assists and rebounds are reliable; perimeter shooting is the volatile leg.',
|
||||
},
|
||||
'TWO-WAY WING': {
|
||||
tag: 'TW WING', sport: 'wnba', color: '#A78BFA', glyph: 'shieldCheck',
|
||||
description: 'Two-way perimeter wing',
|
||||
propDNA: { reliable: ['points', 'steals'], volatile: ['assists'] },
|
||||
education: 'Two-way wings contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.',
|
||||
},
|
||||
'STRETCH FORWARD': {
|
||||
tag: 'STRETCH FWD', sport: 'wnba', color: '#2DD4BF', glyph: 'target',
|
||||
description: 'Floor-spacing forward',
|
||||
propDNA: { reliable: ['threes', 'points'], volatile: ['assists', 'blocks'] },
|
||||
education: 'Stretch forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.',
|
||||
},
|
||||
'SLASHING GUARD': {
|
||||
tag: 'SLASH GD', sport: 'wnba', color: '#FB923C', glyph: 'slash',
|
||||
description: 'Downhill driving guard',
|
||||
propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] },
|
||||
education: 'Slashing guards attack downhill. Points are stable; three-point props are the volatile leg since they prioritize the rim.',
|
||||
},
|
||||
'INTERIOR ANCHOR': {
|
||||
tag: 'INT ANCHOR', sport: 'wnba', color: '#6366F1', glyph: 'shield',
|
||||
description: 'Paint defender and rebounder',
|
||||
propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] },
|
||||
education: 'Interior anchors own the paint. Rebounds and blocks are reliable; their scoring depends on post touches.',
|
||||
},
|
||||
|
||||
// ───────── MLB (15) ─────────
|
||||
'POWER PULL': {
|
||||
tag: 'POWER PULL', sport: 'mlb', color: '#FF5C5C', glyph: 'batball',
|
||||
description: 'HR-dependent, high strikeout power',
|
||||
propDNA: { reliable: ['total_bases', 'home_runs'], volatile: ['hits'] },
|
||||
education: 'Power-pull hitters live and die by the long ball. Total bases and home-run props carry their value; their batting-average-driven props (hits) are volatile from the strikeout risk.',
|
||||
},
|
||||
CONTACT: {
|
||||
tag: 'CONTACT', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair',
|
||||
description: 'High average, low strikeout',
|
||||
propDNA: { reliable: ['hits'], volatile: ['home_runs', 'total_bases'] },
|
||||
education: 'Contact hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.',
|
||||
},
|
||||
'RUN PRODUCER': {
|
||||
tag: 'RUN PROD', sport: 'mlb', color: '#4A9EFF', glyph: 'diamond',
|
||||
description: 'RBI-dependent, lineup context',
|
||||
propDNA: { reliable: ['rbi'], volatile: ['hits', 'home_runs'] },
|
||||
education: 'Run producers hit in the heart of the order. RBI props track lineup context — strong with runners on base; their individual hit props are more variable.',
|
||||
},
|
||||
ACE: {
|
||||
tag: 'ACE', sport: 'mlb', color: '#A78BFA', glyph: 'star',
|
||||
description: 'High K/9, low WHIP, deep games',
|
||||
propDNA: { reliable: ['strikeouts', 'innings_pitched'], volatile: ['earned_runs'] },
|
||||
education: 'Aces miss bats and go deep. Strikeout and innings props are their most reliable; earned-run props are noisier because one swing can change a line.',
|
||||
},
|
||||
'BULLPEN ARM': {
|
||||
tag: 'BULLPEN', sport: 'mlb', color: '#FFB347', glyph: 'bolt',
|
||||
description: 'Short outings, high leverage',
|
||||
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
|
||||
education: 'Bullpen arms throw short, high-leverage outings. Strikeout props can hit in one inning; innings and earned-run props are too small a sample to trust.',
|
||||
},
|
||||
'SPEED THREAT': {
|
||||
tag: 'SPEED', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons',
|
||||
description: 'Stolen bases, speed score',
|
||||
propDNA: { reliable: ['stolen_bases', 'runs'], volatile: ['home_runs'] },
|
||||
education: 'Speed threats turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.',
|
||||
},
|
||||
'TWO-WAY PLAYER': {
|
||||
tag: 'TWO-WAY', sport: 'mlb', color: '#F472B6', glyph: 'half',
|
||||
description: 'Bats and pitches at elite level',
|
||||
propDNA: { reliable: ['total_bases', 'strikeouts'], volatile: ['hits'] },
|
||||
education: 'Two-way players produce on both sides of the ball. Read which role they fill that day — their batting and pitching props live on different lines.',
|
||||
},
|
||||
'UTILITY PLAYER': {
|
||||
tag: 'UTILITY', sport: 'mlb', color: '#22D3EE', glyph: 'plus',
|
||||
description: 'Multi-position lineup flex',
|
||||
propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] },
|
||||
education: 'Utility players move around the lineup and the diamond. Their props are matchup- and slot-dependent — confirm they are starting before betting.',
|
||||
},
|
||||
'INNINGS EATER': {
|
||||
tag: 'INN EATER', sport: 'mlb', color: '#818CF8', glyph: 'clock',
|
||||
description: 'Durable, deep-start workhorse',
|
||||
propDNA: { reliable: ['innings_pitched'], volatile: ['strikeouts', 'earned_runs'] },
|
||||
education: 'Innings eaters pitch deep without elite stuff. Innings props are reliable; strikeout and earned-run props are more variable since they pitch to contact.',
|
||||
},
|
||||
'POWER SLUGGER': {
|
||||
tag: 'SLUGGER', sport: 'mlb', color: '#FF6B4A', glyph: 'triangle',
|
||||
description: 'All-fields power producer',
|
||||
propDNA: { reliable: ['total_bases', 'rbi'], volatile: ['stolen_bases'] },
|
||||
education: 'Power sluggers drive the ball to all fields. Total bases and RBI are reliable; speed props are not part of their game.',
|
||||
},
|
||||
'TABLE SETTER': {
|
||||
tag: 'TABLE SET', sport: 'mlb', color: '#38BDF8', glyph: 'diamondLine',
|
||||
description: 'On-base leadoff catalyst',
|
||||
propDNA: { reliable: ['hits', 'runs'], volatile: ['rbi', 'home_runs'] },
|
||||
education: 'Table setters get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.',
|
||||
},
|
||||
'GAP HITTER': {
|
||||
tag: 'GAP', sport: 'mlb', color: '#34D399', glyph: 'uparrow',
|
||||
description: 'Doubles and extra-base gaps',
|
||||
propDNA: { reliable: ['total_bases', 'hits'], volatile: ['home_runs'] },
|
||||
education: 'Gap hitters spray doubles. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.',
|
||||
},
|
||||
CLOSER: {
|
||||
tag: 'CLOSER', sport: 'mlb', color: '#FB7185', glyph: 'lock',
|
||||
description: 'Ninth-inning save specialist',
|
||||
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
|
||||
education: 'Closers throw one high-leverage inning. Strikeout props can hit in a clean save; everything else is a one-inning coin flip.',
|
||||
},
|
||||
SWINGMAN: {
|
||||
tag: 'SWINGMAN', sport: 'mlb', color: '#FBBF24', glyph: 'swap',
|
||||
description: 'Spot starter and long relief',
|
||||
propDNA: { reliable: [], volatile: ['strikeouts', 'innings_pitched', 'earned_runs'] },
|
||||
education: 'Swingmen bounce between starting and relief. Their workload is unpredictable, so all of their props carry role risk — confirm the assignment.',
|
||||
},
|
||||
'DEFENSIVE WIZARD': {
|
||||
tag: 'DEF WIZ', sport: 'mlb', color: '#6366F1', glyph: 'shieldCheck',
|
||||
description: 'Glove-first defensive value',
|
||||
propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] },
|
||||
education: 'Defensive wizards earn their spot with the glove. Their offensive props are thin and bottom-of-the-order dependent — lean unders.',
|
||||
},
|
||||
|
||||
// ───────── Soccer (6) — present in the design map ─────────
|
||||
POACHER: {
|
||||
tag: 'POACHER', sport: 'soccer', color: '#FF5C5C', glyph: 'crosshair',
|
||||
description: 'Penalty-box finisher',
|
||||
propDNA: { reliable: ['shots_on_target', 'goals'], volatile: ['assists'] },
|
||||
education: 'Poachers finish inside the box. Shots-on-target and goals are their props; they rarely create for others.',
|
||||
},
|
||||
CREATOR: {
|
||||
tag: 'CREATOR', sport: 'soccer', color: '#4A9EFF', glyph: 'node',
|
||||
description: 'Chance-creating playmaker',
|
||||
propDNA: { reliable: ['assists', 'passes'], volatile: ['goals'] },
|
||||
education: 'Creators set the table. Assists and passing props are reliable; their goal output is the volatile leg.',
|
||||
},
|
||||
'TARGET MAN': {
|
||||
tag: 'TARGET', sport: 'soccer', color: '#FF6B4A', glyph: 'triangle',
|
||||
description: 'Hold-up aerial striker',
|
||||
propDNA: { reliable: ['shots', 'shots_on_target'], volatile: ['goals', 'assists'] },
|
||||
education: 'Target men win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.',
|
||||
},
|
||||
'BOX-TO-BOX': {
|
||||
tag: 'B2B', sport: 'soccer', color: '#00D4A0', glyph: 'chevrons',
|
||||
description: 'All-action central midfielder',
|
||||
propDNA: { reliable: ['tackles', 'passes'], volatile: ['goals', 'shots'] },
|
||||
education: 'Box-to-box midfielders cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.',
|
||||
},
|
||||
'WING WIZARD': {
|
||||
tag: 'WING', sport: 'soccer', color: '#2DD4BF', glyph: 'slash',
|
||||
description: 'Dribbling wide threat',
|
||||
propDNA: { reliable: ['shots', 'assists'], volatile: ['goals'] },
|
||||
education: 'Wing wizards beat defenders wide. Shots and assists are their lane; goals come in streaks.',
|
||||
},
|
||||
'SWEEPER KEEPER': {
|
||||
tag: 'SWEEPER', sport: 'soccer', color: '#A78BFA', glyph: 'shield',
|
||||
description: 'Distributing goalkeeper',
|
||||
propDNA: { reliable: ['saves', 'passes'], volatile: ['goals_conceded'] },
|
||||
education: 'Sweeper keepers distribute and defend space. Saves and passing props are reliable; goals-conceded depends on the team in front of them.',
|
||||
},
|
||||
};
|
||||
|
||||
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
|
||||
|
||||
/**
|
||||
* NBA scorers — each returns a 0..1-ish weight from season averages.
|
||||
* Inputs (all optional): ppg, rpg, apg, bpg, spg, threes (3PM/g), usg (%),
|
||||
* fg3a (3PA/g), bench (bool), pos ('G'|'F'|'C').
|
||||
*/
|
||||
function scoreNBA(s) {
|
||||
const ppg = num(s.ppg), rpg = num(s.rpg), apg = num(s.apg), bpg = num(s.bpg),
|
||||
spg = num(s.spg), threes = num(s.threes), usg = num(s.usg), fg3a = num(s.fg3a);
|
||||
const pos = (s.pos || '').toUpperCase();
|
||||
const isBig = pos === 'C' || pos === 'F-C' || pos === 'C-F';
|
||||
return {
|
||||
'VOLUME SCORER': ppg >= 22 ? (ppg - 16) / 14 + (usg >= 28 ? 0.3 : 0) : 0,
|
||||
'FLOOR GENERAL': apg >= 6 ? (apg - 3) / 7 : apg >= 4 ? 0.2 : 0,
|
||||
'TWO-WAY ANCHOR': (bpg >= 1.3 ? bpg / 3 : 0) + (rpg >= 8 ? (rpg - 6) / 8 : 0),
|
||||
'STRETCH BIG': isBig && threes >= 1.2 ? 0.5 + threes / 6 : 0,
|
||||
'USAGE SPONGE': s.bench && usg >= 24 ? 0.5 : 0,
|
||||
'COMBO GUARD': ppg >= 15 && apg >= 3 && apg < 7 ? 0.4 + apg / 20 : 0,
|
||||
'ROLE GLUE': usg > 0 && usg < 16 && ppg < 10 ? 0.5 : 0,
|
||||
'TRANSITION ENGINE': ppg >= 16 && spg >= 1.2 ? 0.3 : 0,
|
||||
'POST SCORER': isBig && ppg >= 16 && threes < 1 ? 0.5 + ppg / 50 : 0,
|
||||
'DEFENSIVE SPECIALIST': spg >= 1.4 && usg < 18 ? 0.5 + spg / 6 : 0,
|
||||
'POINT FORWARD': apg >= 5 && (pos === 'F' || pos === 'G-F' || pos === 'F-G') && ppg >= 16 ? 0.6 + apg / 16 : 0,
|
||||
SLASHER: ppg >= 16 && fg3a < 4 && !isBig ? 0.35 : 0,
|
||||
'RIM RUNNER': isBig && rpg >= 7 && ppg < 16 && threes < 0.5 ? 0.45 : 0,
|
||||
'3-AND-D': threes >= 1.6 && usg < 20 && spg >= 0.9 ? 0.5 + threes / 8 : 0,
|
||||
'SIXTH MAN': s.bench && ppg >= 12 ? 0.4 + ppg / 40 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function scoreWNBA(s) {
|
||||
// WNBA reuses NBA archetypes plus 5 unique ones. Start from the NBA scores
|
||||
// (filtered to WNBA-applicable) and add the unique forwards/guards.
|
||||
const ppg = num(s.ppg), rpg = num(s.rpg), apg = num(s.apg), bpg = num(s.bpg),
|
||||
spg = num(s.spg), threes = num(s.threes), usg = num(s.usg);
|
||||
const pos = (s.pos || '').toUpperCase();
|
||||
const isBig = pos === 'C' || pos === 'F' || pos === 'F-C';
|
||||
return {
|
||||
'VOLUME SCORER': ppg >= 18 ? (ppg - 12) / 12 + (usg >= 26 ? 0.25 : 0) : 0,
|
||||
'FLOOR GENERAL': apg >= 5 ? (apg - 2) / 6 : 0,
|
||||
'COMBO GUARD': ppg >= 14 && apg >= 3 && apg < 6 ? 0.4 + apg / 18 : 0,
|
||||
'POST FACILITATOR': isBig && apg >= 3 && rpg >= 7 ? 0.6 + apg / 12 : 0,
|
||||
'TWO-WAY WING': !isBig && ppg >= 12 && spg >= 1.2 ? 0.5 + spg / 6 : 0,
|
||||
'STRETCH FORWARD': (pos === 'F' || isBig) && threes >= 1.2 ? 0.5 + threes / 5 : 0,
|
||||
'SLASHING GUARD': pos.startsWith('G') && ppg >= 14 && threes < 1.5 ? 0.45 : 0,
|
||||
'INTERIOR ANCHOR': isBig && (bpg >= 1 || rpg >= 8) ? 0.5 + rpg / 16 : 0,
|
||||
'DEFENSIVE SPECIALIST': spg >= 1.5 && usg < 18 ? 0.45 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MLB scorers. Hitters and pitchers are disjoint; `isPitcher` (or presence of
|
||||
* era/k9) routes to the pitcher archetypes.
|
||||
* Hitter inputs: avg, hr, rbi, sb, ops, runs, k_rate, doubles.
|
||||
* Pitcher inputs: era, k9, whip, ip_per_start, saves, role ('SP'|'RP'|'CL').
|
||||
*/
|
||||
function scoreMLB(s) {
|
||||
const role = (s.role || '').toUpperCase();
|
||||
const isPitcher = s.isPitcher || role === 'SP' || role === 'RP' || role === 'CL' ||
|
||||
num(s.era) > 0 || num(s.k9) > 0;
|
||||
if (isPitcher) {
|
||||
const era = num(s.era), k9 = num(s.k9), whip = num(s.whip),
|
||||
ip = num(s.ip_per_start), saves = num(s.saves);
|
||||
return {
|
||||
ACE: k9 >= 9.5 && era <= 3.6 && ip >= 5.5 ? 0.6 + k9 / 30 : k9 >= 9 ? 0.3 : 0,
|
||||
'INNINGS EATER': ip >= 6 && k9 < 9 ? 0.55 + ip / 20 : 0,
|
||||
CLOSER: role === 'CL' || saves >= 10 ? 0.7 + saves / 60 : 0,
|
||||
'BULLPEN ARM': role === 'RP' && saves < 10 ? 0.55 : ip > 0 && ip < 3 ? 0.4 : 0,
|
||||
SWINGMAN: role === 'SP' && ip < 5 && ip > 0 ? 0.4 : 0,
|
||||
'TWO-WAY PLAYER': s.twoWay ? 0.8 : 0,
|
||||
};
|
||||
}
|
||||
const avg = num(s.avg), hr = num(s.hr), rbi = num(s.rbi), sb = num(s.sb),
|
||||
ops = num(s.ops), runs = num(s.runs), kRate = num(s.k_rate), doubles = num(s.doubles);
|
||||
return {
|
||||
'POWER PULL': hr >= 20 && kRate >= 24 ? 0.6 + hr / 60 : hr >= 18 ? 0.3 : 0,
|
||||
'POWER SLUGGER': hr >= 20 && ops >= 0.85 && kRate < 24 ? 0.6 + hr / 60 : 0,
|
||||
CONTACT: avg >= 0.28 && kRate < 16 ? 0.6 + (avg - 0.25) * 2 : avg >= 0.29 ? 0.4 : 0,
|
||||
'RUN PRODUCER': rbi >= 50 && hr >= 12 ? 0.5 + rbi / 200 : 0,
|
||||
'SPEED THREAT': sb >= 15 ? 0.6 + sb / 60 : sb >= 10 ? 0.35 : 0,
|
||||
'TABLE SETTER': runs >= 50 && sb >= 8 && hr < 15 ? 0.5 + runs / 200 : 0,
|
||||
'GAP HITTER': doubles >= 25 && hr < 20 ? 0.5 + doubles / 80 : 0,
|
||||
'UTILITY PLAYER': s.utility ? 0.5 : 0,
|
||||
'DEFENSIVE WIZARD': s.glove && avg < 0.25 ? 0.5 : 0,
|
||||
'TWO-WAY PLAYER': s.twoWay ? 0.8 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB };
|
||||
|
||||
/** Look up an archetype's static descriptor by name (case-insensitive). */
|
||||
function getArchetype(name) {
|
||||
if (!name) return null;
|
||||
const key = String(name).toUpperCase();
|
||||
const a = ARCHETYPES[key];
|
||||
if (!a) return null;
|
||||
return { name: key, ...a };
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a player. Returns:
|
||||
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
|
||||
* primary/secondary are full descriptors (name, tag, color, glyph, description,
|
||||
* propDNA, education). blend weights are normalized 0..1 over the top entries.
|
||||
*/
|
||||
function classify(sport, stats = {}) {
|
||||
const sp = String(sport || 'nba').toLowerCase();
|
||||
const scorer = SCORERS[sp];
|
||||
if (!scorer) return { sport: sp, primary: null, secondary: null, blend: [] };
|
||||
|
||||
const scores = scorer(stats);
|
||||
const ranked = Object.entries(scores)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (ranked.length === 0) {
|
||||
// Fallback so the UI always has something to render.
|
||||
const fallback = sp === 'mlb' ? 'UTILITY PLAYER' : sp === 'wnba' ? 'TWO-WAY WING' : 'ROLE GLUE';
|
||||
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
|
||||
}
|
||||
|
||||
const top = ranked.slice(0, 4);
|
||||
const total = top.reduce((sum, [, v]) => sum + v, 0) || 1;
|
||||
const blend = top.map(([name, v]) => ({ archetype: name, weight: +(v / total).toFixed(3) }));
|
||||
|
||||
const primary = getArchetype(ranked[0][0]);
|
||||
// Secondary only if it's a meaningful share (>= 40% of primary's score).
|
||||
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
|
||||
? getArchetype(ranked[1][0])
|
||||
: null;
|
||||
|
||||
return { sport: sp, primary, secondary, blend };
|
||||
}
|
||||
|
||||
const classifyNBA = (stats) => classify('nba', stats);
|
||||
const classifyWNBA = (stats) => classify('wnba', stats);
|
||||
const classifyMLB = (stats) => classify('mlb', stats);
|
||||
|
||||
module.exports = {
|
||||
ARCHETYPES,
|
||||
getArchetype,
|
||||
classify,
|
||||
classifyNBA,
|
||||
classifyWNBA,
|
||||
classifyMLB,
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* playerIntelService — aggregates a player's intelligence payload for the
|
||||
* /player/:name profile page and the stats API (Session 42).
|
||||
*
|
||||
* Sources, all best-effort + graceful (the page must render even when every
|
||||
* upstream is cold):
|
||||
* - archetypeService.classify → PRIMARY/SECONDARY archetype + DNA blend
|
||||
* - grades:{sport} cache → this player's graded props for tonight
|
||||
* - caller-supplied season/last10/splits/gradeHistory (wired by the route
|
||||
* from the stats adapters; empty when unavailable)
|
||||
*
|
||||
* Pure-ish: I/O is only the grades-cache read, and that's injectable for tests.
|
||||
*/
|
||||
|
||||
const { classify } = require('./archetypeService');
|
||||
|
||||
/**
|
||||
* Sanitize a player-name URL param (Montgomery's note). Decode, strip anything
|
||||
* that isn't a letter/number/space or name punctuation (. - '), collapse
|
||||
* whitespace, cap length. Defends the cache key + any downstream lookups.
|
||||
*/
|
||||
function sanitizePlayerName(raw) {
|
||||
let decoded = String(raw == null ? '' : raw);
|
||||
try { decoded = decodeURIComponent(decoded); } catch { /* malformed % — use raw */ }
|
||||
return decoded
|
||||
.replace(/[^\p{L}\p{N}\s.'-]/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
const normName = (n) => String(n == null ? '' : n).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
async function loadPlayerGrades(sport, name, cacheGetFn) {
|
||||
const env = await cacheGetFn(`grades:${sport}`);
|
||||
const grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||||
const target = normName(name);
|
||||
if (!target) return [];
|
||||
return grades.filter((g) => normName(g.player_name || g.player) === target);
|
||||
}
|
||||
|
||||
/** Derive the VYNDR Intelligence metric row from whatever we have. */
|
||||
function buildIntel(stats, arch, propCount) {
|
||||
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
||||
// Form: lean on last-10 vs season if provided, else a neutral baseline that
|
||||
// scales gently with the strongest graded prop's confidence proxy.
|
||||
const form = stats.form != null ? clamp(Math.round(stats.form), 0, 100) : 70 + clamp(propCount * 4, 0, 22);
|
||||
const usage = stats.usg != null ? `${stats.usg}%` : stats.k9 != null ? `${stats.k9} K/9` : '—';
|
||||
return [
|
||||
{ label: 'FORM', kind: 'form', value: String(form), score: `${clamp(form, 0, 100)}%`, color: form >= 85 ? '#00ffb8' : form >= 70 ? '#00D4A0' : '#FFB347' },
|
||||
{ label: 'USAGE', kind: 'plain', value: usage, color: '#e8e8f0' },
|
||||
{ label: 'MATCHUP', kind: 'grade', value: arch.primary ? gradeFromForm(form) : 'B', color: '#00D4A0' },
|
||||
{ label: 'REST', kind: 'plain', value: stats.rest || '+0%', color: '#00D4A0' },
|
||||
];
|
||||
}
|
||||
|
||||
function gradeFromForm(form) {
|
||||
if (form >= 90) return 'A';
|
||||
if (form >= 80) return 'B+';
|
||||
if (form >= 70) return 'B';
|
||||
return 'C';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full player intelligence payload.
|
||||
* opts: { cacheGet, stats, season, last10, splits, gradeHistory, injury, team }
|
||||
*/
|
||||
async function getPlayerIntel(name, sport, opts = {}) {
|
||||
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const clean = sanitizePlayerName(name);
|
||||
const sp = String(sport || 'nba').toLowerCase();
|
||||
const stats = opts.stats || {};
|
||||
|
||||
const archetype = classify(sp, stats);
|
||||
|
||||
let props = [];
|
||||
try {
|
||||
props = await loadPlayerGrades(sp, clean, cacheGetFn);
|
||||
} catch {
|
||||
props = []; // cold/broken cache must not 500 the page
|
||||
}
|
||||
|
||||
const activeProps = props.map((p) => ({
|
||||
stat: p.stat_type || p.stat,
|
||||
line: p.line,
|
||||
side: String(p.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
|
||||
grade: p.grade,
|
||||
confidence: p.confidence != null ? `${p.confidence}%` : null,
|
||||
}));
|
||||
|
||||
const team = (props[0] && (props[0].team || props[0].team_abbr)) || opts.team || '';
|
||||
|
||||
return {
|
||||
player: clean,
|
||||
sport: sp,
|
||||
team,
|
||||
found: props.length > 0 || Object.keys(stats).length > 0,
|
||||
archetype,
|
||||
propDNA: archetype.primary ? archetype.primary.propDNA : { reliable: [], volatile: [] },
|
||||
education: archetype.primary ? archetype.primary.education : '',
|
||||
season: opts.season || [],
|
||||
last10: opts.last10 || [],
|
||||
splits: opts.splits || [],
|
||||
gradeHistory: opts.gradeHistory || [],
|
||||
activeProps,
|
||||
intel: buildIntel(stats, archetype, props.length),
|
||||
injury: opts.injury || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tonight's leaders for a sport — top graded props by confidence, optionally
|
||||
* filtered to a single stat. Reads the same grades:{sport} cache.
|
||||
*/
|
||||
async function getLeaders(sport, opts = {}) {
|
||||
const cacheGetFn = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const sp = String(sport || 'nba').toLowerCase();
|
||||
const stat = opts.stat ? String(opts.stat).toLowerCase() : null;
|
||||
const limit = Math.max(1, Math.min(50, Number(opts.limit) || 10));
|
||||
|
||||
let grades = [];
|
||||
try {
|
||||
const env = await cacheGetFn(`grades:${sp}`);
|
||||
grades = env && Array.isArray(env.grades) ? env.grades : [];
|
||||
} catch {
|
||||
grades = [];
|
||||
}
|
||||
|
||||
return grades
|
||||
.filter((g) => !stat || String(g.stat_type || g.stat || '').toLowerCase() === stat)
|
||||
.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0))
|
||||
.slice(0, limit)
|
||||
.map((g) => ({
|
||||
player: g.player_name || g.player,
|
||||
team: g.team || g.team_abbr || '',
|
||||
stat: g.stat_type || g.stat,
|
||||
line: g.line,
|
||||
side: String(g.direction || 'over').toLowerCase() === 'under' ? 'U' : 'O',
|
||||
grade: g.grade,
|
||||
confidence: g.confidence,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanitizePlayerName,
|
||||
getPlayerIntel,
|
||||
getLeaders,
|
||||
_internals: { normName, loadPlayerGrades, buildIntel },
|
||||
};
|
||||
Reference in New Issue
Block a user