diff --git a/BUILD-STATE.md b/BUILD-STATE.md index c93f3f3..06f03f8 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -1,11 +1,66 @@ # VYNDR — Build State ## Last Updated -2026-06-17 +2026-06-18 ## Current Phase -SHIP BUILD v41.0 — P0 audit fixes (MLB stat gate, broken routes, profile tier, -self-hosted fonts). Design conversion (33–39) remains COMPLETE. +SHIP BUILD v42.0 — Player Intelligence System (archetypes, stat strips, player +profile, enhanced cards, settings, stats explorer). Built from the Claude Design +"VYNDR Player Intelligence" bundle. + +## Session 42 (2026-06-18) — SHIPPED ✅ PLAYER INTELLIGENCE SYSTEM + +Built the full Player Intelligence design bundle (10 sections; the 6 spec items ++ extras). Backend 1940 → **2011 tests** (+71), 157 suites. Web build clean +(exit 0). Both gates green. + +### What shipped (by design section) +1. **Archetype system** — `src/services/archetypeService.js`: 41 archetypes + (15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer), each with tag/color/glyph/ + description/propDNA/education. `classify(sport, stats)` returns PRIMARY + + optional SECONDARY + a normalized `blend`. Visual map mirrored in + `web/src/lib/archetypes.js` (CommonJS, colors verified == backend). + Components: `ArchetypeBadge` (full/ghost/tint + glyphs), `ArchetypeBlend` + (the production-DNA bar). +2. **StatStrip** — `components/vyndr/StatStrip.tsx`, compact + expanded. The + player name appears ONCE; stats flow horizontally (JetBrains Mono); props + inline with GradeBadge; `onPlayerClick` → profile. +3. **Stats API + Player Profile** — extended `src/routes/stats.js` with + `/player/:name`, `/leaders`, `/game/:id` (rate-limited 60/min). Aggregation + in `src/services/playerIntelService.js` (sanitizes the name param; reads + grades:{sport} cache for props; graceful on cold caches). Page: + `app/player/[name]/page.tsx` — all 9 design sections (hero+DNA blend, injury, + prop DNA, VYNDR intelligence, active props, season, last 10, splits, grade + history). Next proxies under `app/api/stats/player|leaders`. +4. **Enhanced game cards + Grade Result** — `vyndr/GameCard` gained optional + MLB starting pitchers + player-grouped `playerStrips` (StatStrip, name once); + `GradeResultCard` gained archetype strip + STAT CONTEXT + VYNDR INTELLIGENCE + (all optional/self-hiding; populated by `gradeAdapter.buildIntelFields`). + Header player name now links to the profile. +5. **Settings page** — `app/settings/page.tsx` REPLACES the S41 redirect: + account (tier from useAuth), subscription, notifications, display (→ Prefs + modal via `window.__prefs`), responsible play, danger zone (delete button + gated on typing DELETE exactly). LINKS to `/settings/security` — does NOT + replace the real MFA page. + `BookChip` component (`lib/books.js`). +6. **Stats Explorer (bonus, design §07)** — `app/explore/page.tsx`: tonight's + league leaderboard from the real `/api/stats/leaders`, sport tabs, search, + rows → player profile. Added to the Nav MORE menu (Explore + Settings→/settings). + +### Player-name links wired +Game cards, Grade Result header, Stats Explorer rows, profile active props — all +route to `/player/:name?sport=` via `lib/playerHref.js`. + +### Deferred (design sections present but NOT built this session) +Team Hub (§09), Offseason Intel (§08), and the Slate redesign (§10), plus the +Stats Explorer sub-panels (hit-rate trends, head-to-head, market-vs-VYNDR, +matchup ratings). These need historical / projection / depth-chart data +pipelines that don't exist yet — they belong with the Session-43 snapshot +pipeline. The archetype + leaders + profile foundations they'd build on are now +in place. + +## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES + +The Chrome audit's P0 list only. No features. Backend 1907 → **1940 tests** ## Session 41 (2026-06-17) — SHIPPED ✅ P0 AUDIT FIXES diff --git a/CLAUDE.md b/CLAUDE.md index 086989f..eed1107 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -321,6 +321,40 @@ The 7-session design conversion (33–39) is done and parity-verified against § - **Profile tier** reads from `useAuth().tier` (the nav's source), not the `/api/user/profile` fetch, so the two can't disagree. +## Player Intelligence System (Session 42 — non-obvious) +Built from the Claude Design "VYNDR Player Intelligence" bundle. +- **Archetypes (41, NOT 45)** — 15 NBA + 5 WNBA-unique + 15 MLB + 6 soccer. The + canonical registry is `src/services/archetypeService.js` (`ARCHETYPES` keyed by + UPPERCASE name; classify/getArchetype). The frontend visual map (color + glyph + SVG + desc) is duplicated in `web/src/lib/archetypes.js` because the browser + can't import the backend; a test asserts the colors MATCH. Colors are unique + WITHIN a sport but REUSED across sports (a TWO-WAY archetype is purple in NBA + + WNBA) — don't "dedupe" them. `classify(sport, stats)` is a feature-scoring + classifier → `{ primary, secondary|null, blend: [{archetype, weight}] }`. +- **StatStrip rule** — player name appears ONCE; stats are horizontal mono runs. + Never stack the name per stat. `components/vyndr/StatStrip.tsx` (compact + + expanded). `vyndr/GameCard` prefers `playerStrips` (grouped) over legacy + per-prop rows; both kept for back-comat. +- **Stats API** — `src/routes/stats.js` ALREADY existed (filters/public/live); + Session 42 ADDED `/player/:name`, `/leaders`, `/game/:id` (don't recreate the + file). Aggregation logic is in `src/services/playerIntelService.js` (testable; + inject `cacheGet`). The name param is sanitized there (strip non-name chars, + cap 60). Reads `grades:{sport}` cache for a player's props. Frontend needs the + Next proxy (`app/api/stats/player|leaders/route.ts`) — Express isn't reachable + from the browser directly (same rule as S25). +- **Player links** — always via `web/src/lib/playerHref.js` → `/player/:name?sport=`. +- **GradeResultCard / gradeAdapter** — new card sections (archetypeBlend, propDNA, + statContext, vyndrIntel) are OPTIONAL + self-hiding; `gradeAdapter.buildIntelFields` + only populates them when the engine supplies `archetype`/`season_avg`/`form`/etc. + They light up once the Session-43 data pipeline feeds them — no empty boxes now. +- **Settings** — `/settings` is now a REAL page (replaced the S41 redirect). It + LINKS to `/settings/security` (the real MFA page) — never overwrite that. Danger + zone delete is gated on `deleteText === 'DELETE'`; there's NO backend deletion + endpoint yet, so the confirmed action surfaces an honest "email support" message + rather than faking success. +- **Components added to the barrel**: ArchetypeBadge, ArchetypeBlend, StatStrip, + BookChip (`@/components/vyndr`). Book brand map = `web/src/lib/books.js`. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/src/routes/stats.js b/src/routes/stats.js index d181e4c..c5c5e7b 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -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; diff --git a/src/services/archetypeService.js b/src/services/archetypeService.js new file mode 100644 index 0000000..73d81d6 --- /dev/null +++ b/src/services/archetypeService.js @@ -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, +}; diff --git a/src/services/playerIntelService.js b/src/services/playerIntelService.js new file mode 100644 index 0000000..7a9880a --- /dev/null +++ b/src/services/playerIntelService.js @@ -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 }, +}; diff --git a/tests/unit/archetypeBadge.test.js b/tests/unit/archetypeBadge.test.js new file mode 100644 index 0000000..367c3fe --- /dev/null +++ b/tests/unit/archetypeBadge.test.js @@ -0,0 +1,75 @@ +// Session 42 — ArchetypeBadge + ArchetypeBlend (frontend). Logic runs via the +// CommonJS lib; the .tsx is asserted as text (Phase-D–H pattern). + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const arch = require('../../web/src/lib/archetypes'); +const svc = require('../../src/services/archetypeService'); + +describe('archetypes lib — badge styling', () => { + it('full variant fills with the archetype color + white text', () => { + const s = arch.badgeStyle('POWER PULL', 'full', 'md'); + expect(s.bg).toBe('#FF5C5C'); + expect(s.textColor).toBe('#FFFFFF'); + expect(s.borderColor).toBe('#FF5C5C'); + }); + + it('ghost variant is transparent with a colored border', () => { + const s = arch.badgeStyle('FLOOR GENERAL', 'ghost', 'sm'); + expect(s.bg).toBe('transparent'); + expect(s.textColor).toBe('#4A9EFF'); + expect(s.borderColor).toBe('#4A9EFFCC'); + }); + + it('tint variant (default) uses a low-alpha tinted background', () => { + const s = arch.badgeStyle('ACE'); + expect(s.bg).toBe('#A78BFA1F'); + expect(s.borderColor).toBe('#A78BFA52'); + }); + + it('archetypeColor matches and is case-insensitive', () => { + expect(arch.archetypeColor('ace')).toBe('#A78BFA'); + expect(arch.archetypeColor('Two-Way Anchor')).toBe('#A78BFA'); + }); + + it('glyphSvg returns a 16x16 svg wrapper', () => { + const svg = arch.glyphSvg('star'); + expect(svg).toContain('viewBox="0 0 16 16"'); + expect(svg).toContain(' { + expect(arch.archetypeColor('NOT REAL')).toBe('#9499A8'); + }); +}); + +describe('archetypes lib — colors agree with the backend service', () => { + it('every backend archetype color equals the frontend map color', () => { + for (const [name, a] of Object.entries(svc.ARCHETYPES)) { + expect(arch.archetypeColor(name)).toBe(a.color); + } + }); +}); + +describe('ArchetypeBadge.tsx', () => { + const src = read('components/vyndr/ArchetypeBadge.tsx'); + it('renders the glyph via dangerouslySetInnerHTML and uses mono', () => { + expect(src).toContain('dangerouslySetInnerHTML'); + expect(src).toContain('badgeStyle'); + expect(src).toContain('className="mono"'); + }); + it('supports full / ghost / tint variants', () => { + expect(src).toContain("'full' | 'ghost' | 'tint'"); + }); +}); + +describe('ArchetypeBlend.tsx', () => { + const src = read('components/vyndr/ArchetypeBlend.tsx'); + it('colors segments via archetypeColor and renders a PRIMARY legend', () => { + expect(src).toContain('archetypeColor'); + expect(src).toContain('PRIMARY'); + expect(src).toContain('ArchetypeBadge'); + }); +}); diff --git a/tests/unit/archetypeService.test.js b/tests/unit/archetypeService.test.js new file mode 100644 index 0000000..d5e1809 --- /dev/null +++ b/tests/unit/archetypeService.test.js @@ -0,0 +1,111 @@ +// Session 42 — archetype classification service. + +const svc = require('../../src/services/archetypeService'); + +describe('archetypeService — registry', () => { + it('exposes the full design archetype set (41: 15 NBA + 5 WNBA + 15 MLB + 6 soccer)', () => { + expect(Object.keys(svc.ARCHETYPES).length).toBe(41); + const bySport = {}; + for (const a of Object.values(svc.ARCHETYPES)) bySport[a.sport] = (bySport[a.sport] || 0) + 1; + expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 }); + }); + + it('every archetype has name/tag/color/glyph/description/propDNA/education', () => { + for (const [name, a] of Object.entries(svc.ARCHETYPES)) { + expect(typeof a.tag).toBe('string'); + expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/); + expect(typeof a.glyph).toBe('string'); + expect(typeof a.description).toBe('string'); + expect(a.propDNA).toBeTruthy(); + expect(Array.isArray(a.propDNA.reliable)).toBe(true); + expect(Array.isArray(a.propDNA.volatile)).toBe(true); + expect(a.education.length).toBeGreaterThan(20); + expect(name).toBe(name.toUpperCase()); + } + }); + + it('colors are unique WITHIN each sport (reused across sports by design)', () => { + const bySport = {}; + for (const a of Object.values(svc.ARCHETYPES)) { + bySport[a.sport] = bySport[a.sport] || []; + bySport[a.sport].push(a.color); + } + for (const [sport, colors] of Object.entries(bySport)) { + expect(new Set(colors).size).toBe(colors.length); // no dup within sport + } + }); + + it('getArchetype is case-insensitive and returns null for unknown', () => { + expect(svc.getArchetype('ace').name).toBe('ACE'); + expect(svc.getArchetype('Two-Way Anchor').name).toBe('TWO-WAY ANCHOR'); + expect(svc.getArchetype('not a real one')).toBeNull(); + }); +}); + +describe('archetypeService — NBA classification', () => { + it('classifies a volume scorer', () => { + const r = svc.classifyNBA({ ppg: 30, rpg: 5, apg: 4, usg: 33, threes: 2.5, pos: 'G' }); + expect(r.primary.name).toBe('VOLUME SCORER'); + expect(r.sport).toBe('nba'); + }); + + it('returns primary + secondary for a hybrid (Point Forward / Floor General)', () => { + const r = svc.classifyNBA({ ppg: 22, rpg: 7, apg: 8, usg: 27, pos: 'F', threes: 1.5 }); + expect(r.primary).toBeTruthy(); + expect(r.secondary).toBeTruthy(); + expect(r.primary.name).not.toBe(r.secondary.name); + }); + + it('classifies a two-way anchor (Wembanyama-type)', () => { + const r = svc.classifyNBA({ ppg: 24, rpg: 11, apg: 3, bpg: 3.2, usg: 31, threes: 1.5, pos: 'C' }); + expect(['TWO-WAY ANCHOR', 'POST SCORER', 'STRETCH BIG']).toContain(r.primary.name); + }); + + it('produces a normalized blend that sums to ~1', () => { + const r = svc.classifyNBA({ ppg: 28, rpg: 8, apg: 7, usg: 30, pos: 'F' }); + const sum = r.blend.reduce((s, b) => s + b.weight, 0); + expect(sum).toBeGreaterThan(0.98); + expect(sum).toBeLessThan(1.02); + }); +}); + +describe('archetypeService — MLB classification', () => { + it('differentiates an Ace from an Innings Eater', () => { + const ace = svc.classifyMLB({ role: 'SP', era: 2.9, k9: 11.5, whip: 0.98, ip_per_start: 6.2 }); + const eater = svc.classifyMLB({ role: 'SP', era: 4.1, k9: 7.0, whip: 1.3, ip_per_start: 6.5 }); + expect(ace.primary.name).toBe('ACE'); + expect(eater.primary.name).toBe('INNINGS EATER'); + }); + + it('classifies a closer', () => { + const r = svc.classifyMLB({ role: 'CL', era: 2.2, k9: 12, saves: 24, ip_per_start: 1 }); + expect(r.primary.name).toBe('CLOSER'); + }); + + it('classifies a contact hitter and a power-pull hitter differently', () => { + const contact = svc.classifyMLB({ avg: 0.315, hr: 8, rbi: 40, k_rate: 12, ops: 0.82 }); + const power = svc.classifyMLB({ avg: 0.235, hr: 34, rbi: 88, k_rate: 30, ops: 0.86 }); + expect(contact.primary.name).toBe('CONTACT'); + expect(power.primary.name).toBe('POWER PULL'); + }); +}); + +describe('archetypeService — WNBA classification', () => { + it('classifies a dominant post / interior anchor (Wilson-type)', () => { + const r = svc.classifyWNBA({ ppg: 27, rpg: 12, apg: 2, bpg: 2.3, usg: 31, pos: 'F' }); + expect(['INTERIOR ANCHOR', 'VOLUME SCORER', 'STRETCH FORWARD']).toContain(r.primary.name); + }); + + it('classifies a floor general', () => { + const r = svc.classifyWNBA({ ppg: 16, rpg: 4, apg: 7, usg: 24, pos: 'G' }); + expect(r.primary.name).toBe('FLOOR GENERAL'); + }); +}); + +describe('archetypeService — graceful fallback', () => { + it('returns a fallback archetype for empty stats (never crashes)', () => { + expect(svc.classifyNBA({}).primary).toBeTruthy(); + expect(svc.classifyMLB({}).primary).toBeTruthy(); + expect(svc.classify('badsport', {}).primary).toBeNull(); + }); +}); diff --git a/tests/unit/explorePage.test.js b/tests/unit/explorePage.test.js new file mode 100644 index 0000000..e180e79 --- /dev/null +++ b/tests/unit/explorePage.test.js @@ -0,0 +1,26 @@ +// Session 42 — Stats Explorer (/explore) page (bonus design section 07). + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const src = fs.readFileSync(path.join(WEB, 'app', 'explore', 'page.tsx'), 'utf8'); + +describe('Stats Explorer page', () => { + it('consumes the real /api/stats/leaders endpoint', () => { + expect(src).toContain('/api/stats/leaders'); + }); + it('has NBA/MLB/WNBA sport tabs + a player search filter', () => { + expect(src).toContain('NBA'); + expect(src).toContain('MLB'); + expect(src).toContain('WNBA'); + expect(src).toContain('Search players'); + }); + it('rows link to the player profile', () => { + expect(src).toContain('playerHref'); + }); + it('handles loading / error / empty states', () => { + expect(src).toContain("'loading'"); + expect(src).toContain("'error'"); + expect(src).toContain('No graded props'); + }); +}); diff --git a/tests/unit/playerIntelCards.test.js b/tests/unit/playerIntelCards.test.js new file mode 100644 index 0000000..2a0ea0d --- /dev/null +++ b/tests/unit/playerIntelCards.test.js @@ -0,0 +1,63 @@ +// Session 42 — Phase 4: grade-adapter intel fields + enhanced game card. + +const fs = require('fs'); +const path = require('path'); +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); +const { mapScanToGradeResult, buildIntelFields } = require('../../web/src/lib/gradeAdapter'); + +describe('gradeAdapter — Player Intelligence fields', () => { + it('omits all new sections when the engine supplies nothing (self-hide)', () => { + const r = mapScanToGradeResult({ player: 'X', stat: 'points', line: 26.5, grade: 'A' }); + expect(r.archetypeBlend).toBeUndefined(); + expect(r.statContext).toBeUndefined(); + expect(r.vyndrIntel).toBeUndefined(); + expect(r.propDNA).toBeUndefined(); + }); + + it('lifts a single archetype name into a one-segment blend', () => { + const f = buildIntelFields({ archetype: 'VOLUME SCORER' }); + expect(f.archetypeBlend).toEqual([{ archetype: 'VOLUME SCORER', weight: 1 }]); + }); + + it('passes through a full blend + propDNA + statContext + vyndrIntel', () => { + const r = mapScanToGradeResult({ + player: 'Wemby', stat: 'points', line: 26.5, grade: 'A', + archetype_blend: [{ archetype: 'TWO-WAY ANCHOR', weight: 0.6 }, { archetype: 'STRETCH BIG', weight: 0.4 }], + prop_dna: { reliable: ['points'], volatile: ['rebounds'] }, + season_avg: 26.9, last10_avg: 28.4, vs_opp_avg: 30.1, + form: 92, usage: '31.2%', matchup_grade: 'A', rest: '+2.4%', + }); + expect(r.archetypeBlend).toHaveLength(2); + expect(r.propDNA.reliable).toContain('points'); + expect(r.statContext).toEqual({ season: '26.9', last10: '28.4', vsOpp: '30.1' }); + expect(r.vyndrIntel).toEqual({ form: 92, usage: '31.2%', matchup: 'A', rest: '+2.4%' }); + }); +}); + +describe('GradeResultCard — new sections', () => { + const src = read('components/vyndr/GradeResultCard.tsx'); + it('renders the archetype strip + STAT CONTEXT + VYNDR INTELLIGENCE', () => { + expect(src).toContain('ARCHETYPE STRIP'); + expect(src).toContain('STAT CONTEXT'); + expect(src).toContain('VYNDR INTELLIGENCE'); + expect(src).toContain('ArchetypeBlend'); + }); +}); + +describe('Enhanced GameCard (Session 42)', () => { + const src = read('components/vyndr/GameCard.tsx'); + it('renders MLB starting pitchers when provided', () => { + expect(src).toContain('g.pitchers'); + expect(src).toContain('STARTING'); + expect(src).toContain('ERA'); + }); + it('prefers the player-grouped StatStrip (name once) over per-prop rows', () => { + expect(src).toContain('g.playerStrips'); + expect(src).toContain('StatStrip'); + expect(src).toContain('variant="compact"'); + }); + it('links player names to their profile', () => { + expect(src).toContain('playerHref'); + }); +}); diff --git a/tests/unit/playerIntelService.test.js b/tests/unit/playerIntelService.test.js new file mode 100644 index 0000000..cc75554 --- /dev/null +++ b/tests/unit/playerIntelService.test.js @@ -0,0 +1,91 @@ +// Session 42 — player intelligence aggregation. cacheGet is injected so these +// run pure (no Redis, no HTTP, no rate limiter). + +const svc = require('../../src/services/playerIntelService'); + +const cacheWith = (envelope) => async (key) => (key.startsWith('grades:') ? envelope : null); + +describe('sanitizePlayerName', () => { + it('decodes URL encoding and keeps name punctuation', () => { + expect(svc.sanitizePlayerName('Luka%20Doncic')).toBe('Luka Doncic'); + expect(svc.sanitizePlayerName("De'Aaron Fox")).toBe("De'Aaron Fox"); + expect(svc.sanitizePlayerName('Ronald Acuna Jr.')).toBe('Ronald Acuna Jr.'); + }); + + it('strips injection / control characters', () => { + expect(svc.sanitizePlayerName('Luka