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:
Kev
2026-06-18 11:12:24 -04:00
parent 32069863dc
commit 8bc79f3c38
33 changed files with 2655 additions and 22 deletions
+139
View File
@@ -0,0 +1,139 @@
/* ============================================================
VYNDR 2.0 — archetype visual map (Session 42, §Player Intelligence).
Ported verbatim from the design's ArchetypeBadge.dc.html MAP +
ArchetypeBlend.dc.html. Plain CommonJS so .tsx imports it AND the
Jest suite can require it directly (same pattern as vyndrTokens.js).
Colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES.
============================================================ */
/* Glyph SVG inner-markup keyed by name (drawn in a 0 0 16 16 viewBox). */
const GLYPHS = {
triangle: '<path d="M8 2 L14 14 L2 14 Z" fill="currentColor"/>',
node: '<circle cx="8" cy="8" r="2.3" fill="currentColor"/><path d="M8 5.7V1.5M5.4 9.3 2.4 13M10.6 9.3 13.6 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
shield: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" fill="currentColor"/>',
shieldCheck: '<path d="M8 1.6 L13.4 4 V8.2 C13.4 11.2 8 14.4 8 14.4 C8 14.4 2.6 11.2 2.6 8.2 V4 Z" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M5.6 8 L7.4 9.9 L10.4 6.2" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
target: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5" fill="none"/><circle cx="8" cy="8" r="2.2" fill="currentColor"/>',
bolt: '<path d="M9 1.6 L4 9 H7.6 L7 14.4 L12 6.6 H8.4 Z" fill="currentColor"/>',
twin: '<circle cx="6" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/><circle cx="10" cy="8" r="3.6" stroke="currentColor" stroke-width="1.6" fill="none"/>',
chain: '<rect x="2.4" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/><rect x="6.6" y="5.6" width="7" height="4.8" rx="2.4" stroke="currentColor" stroke-width="1.6" fill="none"/>',
batball: '<path d="M3 13.2 L11 5.2" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/><circle cx="12.4" cy="3.6" r="1.9" fill="currentColor"/>',
crosshair: '<circle cx="8" cy="8" r="5.6" stroke="currentColor" stroke-width="1.5" fill="none"/><path d="M8 1.4V4.4M8 11.6V14.6M1.4 8H4.4M11.6 8H14.6" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.5" fill="currentColor"/>',
diamond: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 5.4 L10.6 8 L8 10.6 L5.4 8 Z" fill="currentColor"/>',
diamondLine: '<path d="M8 1.6 L14.4 8 L8 14.4 L1.6 8 Z" stroke="currentColor" stroke-width="1.7" fill="none"/>',
star: '<path d="M8 1.4 L9.7 6 L14.6 6.3 L10.7 9.3 L12.1 14 L8 11.2 L3.9 14 L5.3 9.3 L1.4 6.3 L6.3 6 Z" fill="currentColor"/>',
clock: '<circle cx="8" cy="9.2" r="5" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 9.2 V5.6 M6.2 2.2 H9.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>',
chevrons: '<path d="M2.6 4 L6.6 8 L2.6 12 M7.8 4 L11.8 8 L7.8 12" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
slash: '<path d="M3 13 L13 3 M13 3 H8.6 M13 3 V7.4" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
arc: '<path d="M2 11.5 Q8 1.5 14 11.5" stroke="currentColor" stroke-width="1.7" fill="none" stroke-linecap="round"/><circle cx="13.4" cy="11.2" r="1.6" fill="currentColor"/>',
plus: '<path d="M8 2.4 V13.6 M2.4 8 H13.6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><circle cx="8" cy="8" r="1.9" fill="currentColor"/>',
lock: '<rect x="3.6" y="7" width="8.8" height="6.6" rx="1.4" fill="currentColor"/><path d="M5.4 7 V5 a2.6 2.6 0 0 1 5.2 0 V7" stroke="currentColor" stroke-width="1.5" fill="none"/>',
swap: '<path d="M3 5.6 H11.4 M9.4 3.6 L11.6 5.6 L9.4 7.6 M13 10.4 H4.6 M6.6 8.4 L4.4 10.4 L6.6 12.4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
half: '<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.6" fill="none"/><path d="M8 2.2 a5.8 5.8 0 0 1 0 11.6 Z" fill="currentColor"/>',
uparrow: '<path d="M8 13.4 V3.6 M4.4 7.2 L8 3.6 L11.6 7.2" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
postup: '<rect x="6.4" y="2.6" width="3.2" height="11" rx="1.6" fill="currentColor"/><circle cx="12" cy="5.2" r="1.7" fill="currentColor"/>',
};
/* name → { color, glyph, desc } — the design's MAP. */
const ARCHETYPE_MAP = {
// NBA
'VOLUME SCORER': { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle' },
'FLOOR GENERAL': { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node' },
'TWO-WAY ANCHOR': { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield' },
'STRETCH BIG': { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target' },
'USAGE SPONGE': { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow' },
'COMBO GUARD': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin' },
'ROLE GLUE': { c: '#9499A8', d: 'Low-usage specialist', g: 'chain' },
'TRANSITION ENGINE': { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons' },
'POST SCORER': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup' },
'DEFENSIVE SPECIALIST': { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck' },
'POINT FORWARD': { c: '#38BDF8', d: 'Oversized primary creator', g: 'half' },
SLASHER: { c: '#FB923C', d: 'Rim-attacking, foul-drawing driver', g: 'slash' },
'RIM RUNNER': { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc' },
'3-AND-D': { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair' },
'SIXTH MAN': { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt' },
// WNBA-unique
'POST FACILITATOR': { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node' },
'TWO-WAY WING': { c: '#A78BFA', d: 'Two-way perimeter wing', g: 'shieldCheck' },
'STRETCH FORWARD': { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target' },
'SLASHING GUARD': { c: '#FB923C', d: 'Downhill driving guard', g: 'slash' },
'INTERIOR ANCHOR': { c: '#6366F1', d: 'Paint defender and rebounder', g: 'shield' },
// MLB
'POWER PULL': { c: '#FF5C5C', d: 'HR-dependent, high strikeout power', g: 'batball' },
CONTACT: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair' },
'RUN PRODUCER': { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond' },
ACE: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star' },
'BULLPEN ARM': { c: '#FFB347', d: 'Short outings, high leverage', g: 'bolt' },
'SPEED THREAT': { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons' },
'TWO-WAY PLAYER': { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half' },
'UTILITY PLAYER': { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus' },
'INNINGS EATER': { c: '#818CF8', d: 'Durable, deep-start workhorse', g: 'clock' },
'POWER SLUGGER': { c: '#FF6B4A', d: 'All-fields power producer', g: 'triangle' },
'TABLE SETTER': { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine' },
'GAP HITTER': { c: '#34D399', d: 'Doubles and extra-base gaps', g: 'uparrow' },
CLOSER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock' },
SWINGMAN: { c: '#FBBF24', d: 'Spot starter and long relief', g: 'swap' },
'DEFENSIVE WIZARD': { c: '#6366F1', d: 'Glove-first defensive value', g: 'shieldCheck' },
// Soccer
POACHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair' },
CREATOR: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node' },
'TARGET MAN': { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle' },
'BOX-TO-BOX': { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons' },
'WING WIZARD': { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash' },
'SWEEPER KEEPER': { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield' },
};
const FALLBACK = { c: '#9499A8', d: '', g: '' };
function archetypeInfo(name) {
const key = (name == null ? '' : String(name)).toUpperCase();
return ARCHETYPE_MAP[key] || FALLBACK;
}
function archetypeColor(name) {
return archetypeInfo(name).c;
}
function glyphSvg(glyphKey) {
const inner = GLYPHS[glyphKey] || '';
return `<svg viewBox="0 0 16 16" width="100%" height="100%" style="display:block">${inner}</svg>`;
}
/**
* Resolve the full style bundle for a badge — mirrors the design's renderVals.
* variant: 'full' (solid) | 'ghost' (outline) | 'tint' (default).
* size: 'sm' | 'md'.
*/
function badgeStyle(name, variant = 'tint', size = 'sm') {
const info = archetypeInfo(name);
const sm = size === 'sm';
let textColor, bg, borderColor, glyphColor, textShadow = 'none';
if (variant === 'full' || variant === 'solid') {
textColor = '#FFFFFF'; bg = info.c; borderColor = info.c; glyphColor = '#FFFFFF';
textShadow = '0 1px 2px rgba(0,0,0,0.38)';
} else if (variant === 'ghost' || variant === 'outline') {
textColor = info.c; bg = 'transparent'; borderColor = info.c + 'CC'; glyphColor = info.c;
} else {
textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c;
}
return {
name: (name == null ? '' : String(name)).toUpperCase(),
desc: info.d,
glyph: info.g,
color: info.c,
textColor, bg, borderColor, glyphColor, textShadow,
fontSize: sm ? '9.5px' : '12px',
padding: sm ? '3px 7px 3px 6px' : '5px 11px 5px 9px',
radius: sm ? '4px' : '6px',
glyphSize: sm ? '11px' : '14px',
descSize: sm ? '11px' : '13px',
};
}
module.exports = {
GLYPHS,
ARCHETYPE_MAP,
archetypeInfo,
archetypeColor,
glyphSvg,
badgeStyle,
};
+29
View File
@@ -0,0 +1,29 @@
/* Sportsbook brand map (Session 42) — ported from the design's BookChip.dc.html
BOOKS table. CommonJS so it's testable + importable from the .tsx chip. */
const BOOKS = {
DK: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
DRAFTKINGS: { name: 'DraftKings', mono: 'DK', bg: '#0E1E12', fg: '#53D337', bd: '#53D33755' },
FD: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
FANDUEL: { name: 'FanDuel', mono: 'FD', bg: '#0A1830', fg: '#1493FF', bd: '#1493FF55' },
MGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
BETMGM: { name: 'BetMGM', mono: 'MGM', bg: '#1A1405', fg: '#C8A24B', bd: '#C8A24B55' },
CZR: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
CAESARS: { name: 'Caesars', mono: 'CZR', bg: '#0C1A14', fg: '#1A7F5A', bd: '#1A7F5A66' },
ESPN: { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
'ESPN BET': { name: 'ESPN BET', mono: 'EB', bg: '#2A0A0A', fg: '#FF4A4A', bd: '#FF4A4A55' },
BR: { name: 'BetRivers', mono: 'BR', bg: '#1A0E22', fg: '#B07CFF', bd: '#B07CFF55' },
PB: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
PRIZEPICKS: { name: 'PrizePicks', mono: 'PP', bg: '#0A1A1A', fg: '#19E0C8', bd: '#19E0C855' },
FAN: { name: 'Fanatics', mono: 'FAN', bg: '#1A0A0A', fg: '#E84855', bd: '#E8485555' },
B365: { name: 'bet365', mono: '365', bg: '#0A1A12', fg: '#2E8B57', bd: '#2E8B5766' },
HR: { name: 'Hard Rock', mono: 'HR', bg: '#1A1206', fg: '#D4A24B', bd: '#D4A24B55' },
UD: { name: 'Underdog', mono: 'UD', bg: '#15101F', fg: '#A07CFF', bd: '#A07CFF55' },
};
function bookInfo(book) {
const key = String(book == null ? '' : book).toUpperCase();
return BOOKS[key] || { name: key, mono: key.slice(0, 3) || '?', bg: '#14141E', fg: '#B8BCC8', bd: '#23232F' };
}
module.exports = { BOOKS, bookInfo };
+38 -1
View File
@@ -86,7 +86,44 @@ function mapScanToGradeResult(input = {}) {
altLadder: includeAlt && Array.isArray(input.alt_lines)
? input.alt_lines.map((a) => ({ line: a.line, grade: a.grade }))
: [],
// Session 42 — Player Intelligence additions. All OPTIONAL + self-hiding:
// they only render once the engine supplies them (Session 43 data pipeline).
...buildIntelFields(input),
};
}
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals };
/**
* Build the optional archetype / stat-context / vyndr-intelligence fields for
* the grade card from whatever the engine provided. Returns {} when nothing is
* present so the card sections stay hidden (no empty boxes).
*/
function buildIntelFields(input) {
const out = {};
if (Array.isArray(input.archetype_blend) && input.archetype_blend.length) {
out.archetypeBlend = input.archetype_blend;
} else if (input.archetype) {
out.archetypeBlend = [{ archetype: String(input.archetype), weight: 1 }];
}
if (input.prop_dna && (input.prop_dna.reliable || input.prop_dna.volatile)) {
out.propDNA = {
reliable: input.prop_dna.reliable || [],
volatile: input.prop_dna.volatile || [],
};
}
const sc = {};
if (input.season_avg != null) sc.season = String(input.season_avg);
if (input.last10_avg != null) sc.last10 = String(input.last10_avg);
if (input.vs_opp_avg != null) sc.vsOpp = String(input.vs_opp_avg);
if (Object.keys(sc).length) out.statContext = sc;
const vi = {};
if (input.form != null) vi.form = input.form;
if (input.usage != null) vi.usage = String(input.usage);
if (input.matchup_grade != null) vi.matchup = String(input.matchup_grade);
if (input.rest != null) vi.rest = String(input.rest);
if (Object.keys(vi).length) out.vyndrIntel = vi;
return out;
}
module.exports = { mapScanToGradeResult, statLabel, computeEdge, isPhosphorConfirmed, toSignals, buildIntelFields };
+10
View File
@@ -0,0 +1,10 @@
/* Canonical player-profile link builder (Session 42). Used everywhere a
player name should be tappable. CommonJS so it's testable + importable. */
function playerHref(name, sport) {
const n = encodeURIComponent(String(name == null ? '' : name).trim());
const sp = String(sport || 'nba').toLowerCase();
return `/player/${n}?sport=${encodeURIComponent(sp)}`;
}
module.exports = { playerHref };
+39
View File
@@ -0,0 +1,39 @@
/* Pure display transforms for the player profile page (Session 42).
CommonJS so it's unit-testable + importable from the .tsx page. */
/** Two-letter mono initials for the hero avatar. */
function initials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '??';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const SPORT_LABELS = { nba: 'NBA', mlb: 'MLB', wnba: 'WNBA', soccer: 'SOC' };
function sportLabel(sport) {
return SPORT_LABELS[String(sport || '').toLowerCase()] || String(sport || '').toUpperCase();
}
/** Prop DNA → flat list of { prop, state, color } for the grid (reliable first). */
function dnaRows(propDNA) {
const dna = propDNA || { reliable: [], volatile: [] };
const pretty = (s) => String(s).replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return [
...(dna.reliable || []).map((p) => ({ prop: pretty(p), state: 'RELIABLE', color: '#00D4A0' })),
...(dna.volatile || []).map((p) => ({ prop: pretty(p), state: 'VOLATILE', color: '#FFB347' })),
];
}
/** Short human readout under the DNA bar, e.g. "Primary lane: Power Pull". */
function blendReadout(archetype) {
if (!archetype || !archetype.primary) return '';
const prim = archetype.primary.name;
const sec = archetype.secondary ? `, supported by ${title(archetype.secondary.name)}` : '';
return `Primary lane: ${title(prim)}${sec}.`;
}
function title(s) {
return String(s || '').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
}
module.exports = { initials, sportLabel, dnaRows, blendReadout, title };