Files
vyndr/src/services/archetypeService.js
T
builtbykev 771d8b0ba4 Rev 3 glyphs: wire the 9 classifier-legacy marks (74→83), all backend archetypes now render real marks
Design Rev 3 drew the 9 legacy classifier marks (BRUSH #E0B84A, WHIFF #E86A6A,
CONNECTOR #9AB0C4, DISTRIBUTOR #7AB8D8, FASTBREAK #4AA0E8, FLEX #A08AC8,
HYBRID #C88AB0, SWITCH #C0B08A, SWITCHBOARD #90A0E8). Wired each to its own
Design mark + color (front lib/archetypes.js + backend archetypeService.js,
color-synced). These were the 9 NO-MARK backend keys — now none are on a
generic placeholder. They're classifier-side fallback renders (never
user-facing archetype names, per MANIFEST).

Re-imported Rev 3 package over specs/design-reference/ (83 glyph SVGs + MANIFEST
regenerated from the authoritative glyphDefs(); HANDOFF Rev 3 note).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:23:59 -04:00

595 lines
31 KiB
JavaScript

/**
* archetypeService — VYNDR player archetype classification (Session 42; renamed
* to VYNDR Originals in Session 44).
*
* 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 are VYNDR-proprietary (TORCH, BOMBER, ALPHA, …). Each
* carries `legacyName` (the old descriptive label) for reference — never shown.
* colors + glyph keys are the contract shared with the frontend `ArchetypeBadge`
* (web/src/components/vyndr/ArchetypeBadge.tsx) + `archetypes.js`. Keep all three
* in sync.
*
* No API calls, no I/O — feed it a stat object, get a classification.
*/
// ── Archetype registry ──────────────────────────────────────────────
const ARCHETYPES = {
// ───────── NBA (15) ─────────
TORCH: {
legacyName: 'VOLUME SCORER', tag: 'TORCH', sport: 'nba', color: '#FF6B4A', glyph: 'triangle',
description: 'High usage, shot-dependent scorer',
propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] },
education: 'Torches 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.',
},
CONDUCTOR: {
legacyName: 'FLOOR GENERAL', tag: 'CONDUCTOR', sport: 'nba', color: '#6C8CFF', glyph: 'node',
description: 'Assist-heavy playmaker',
propDNA: { reliable: ['assists'], volatile: ['points', 'threes'] },
education: 'Conductors create for others first. Assists are their most stable prop because the offense runs through them; their scoring fluctuates with shot selection and matchup.',
},
FORTRESS: {
legacyName: 'TWO-WAY ANCHOR', tag: 'FORTRESS', sport: 'nba', color: '#4C6FA5', glyph: 'shield',
description: 'Defense, rebounds, blocks',
propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] },
education: 'A Fortress generates 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.',
},
ARTILLERY: {
legacyName: 'STRETCH BIG', tag: 'ARTILLERY', sport: 'nba', color: '#C98A5A', glyph: 'target',
description: 'Floor-spacing shooting big',
propDNA: { reliable: ['threes', 'rebounds'], volatile: ['assists', 'blocks'] },
education: 'Artillery bigs space the floor and crash the glass. Threes and rebounds are their bread and butter; assists and blocks are situational.',
},
SURGE: {
legacyName: 'USAGE SPONGE', tag: 'SURGE', sport: 'nba', color: '#FFA24A', glyph: 'uparrow',
description: 'Usage spikes when stars sit',
propDNA: { reliable: ['points'], volatile: ['assists', 'rebounds'] },
education: 'A Surge soaks up shots when a star sits or is injured. Their points props spike on cascade nights — read the injury report before trusting the baseline.',
},
'DUAL THREAT': {
legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', sport: 'nba', color: '#1EA6D8', glyph: 'twin',
description: 'Scoring + playmaking hybrid',
propDNA: { reliable: ['points', 'assists'], volatile: ['rebounds'] },
education: 'Dual Threats score and create in equal measure, so points and assists both stay in play. Rebounds are the volatile leg for their size.',
},
CONNECTOR: {
legacyName: 'ROLE GLUE', tag: 'CONNECTOR', sport: 'nba', color: '#9AB0C4', glyph: 'chain',
description: 'Low-usage specialist',
propDNA: { reliable: [], volatile: ['points', 'assists', 'rebounds'] },
education: 'Connectors do the little things at low usage. Their counting props are thin and matchup-dependent — they reward unders more often than overs.',
},
FASTBREAK: {
legacyName: 'TRANSITION ENGINE', tag: 'FASTBREAK', sport: 'nba', color: '#4AA0E8', glyph: 'chevrons',
description: 'Pace-pushing fast-break threat',
propDNA: { reliable: ['points'], volatile: ['assists', 'threes'] },
education: 'Fastbreaks feast in the open floor. Their points correlate with game pace — target overs in projected up-tempo matchups.',
},
'PAINT BOSS': {
legacyName: 'POST SCORER', tag: 'PAINT BOSS', sport: 'nba', color: '#FF5C5C', glyph: 'postup',
description: 'Back-to-basket interior scorer',
propDNA: { reliable: ['points', 'rebounds'], volatile: ['threes', 'assists'] },
education: 'A Paint Boss operates in the paint. Points and rebounds are reliable against most fronts; perimeter props are noise.',
},
LOCKDOWN: {
legacyName: 'DEFENSIVE SPECIALIST', tag: 'LOCKDOWN', sport: 'nba', color: '#5A6474', glyph: 'shieldCheck',
description: 'Perimeter stopper, low usage',
propDNA: { reliable: ['steals'], volatile: ['points', 'assists'] },
education: 'Lockdowns earn minutes with their on-ball defense. Steals and blocks carry their card; offensive props are low-volume and streaky.',
},
SWITCHBOARD: {
legacyName: 'POINT FORWARD', tag: 'SWITCHBOARD', sport: 'nba', color: '#90A0E8', glyph: 'half',
description: 'Oversized primary creator',
propDNA: { reliable: ['points', 'assists', 'rebounds'], volatile: ['threes'] },
education: 'Switchboards 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.',
},
ARCHITECT: {
legacyName: 'SLASHER', tag: 'ARCHITECT', sport: 'nba', color: '#B07CFF', glyph: 'slash',
description: 'Self-created shot maker, rim-attacking',
propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] },
education: 'Architects manufacture their own shot at the rim and the line. Points are stable; their three-point props are volatile because they rarely settle for jumpers.',
},
PISTON: {
legacyName: 'RIM RUNNER', tag: 'PISTON', sport: 'nba', color: '#E87B4A', glyph: 'arc',
description: 'Lob and putback finisher',
propDNA: { reliable: ['rebounds'], volatile: ['points', 'assists'] },
education: 'Pistons finish lobs and putbacks. Rebounds are reliable; their scoring depends entirely on feeds from creators.',
},
SENTINEL: {
legacyName: '3-AND-D', tag: 'SENTINEL', sport: 'nba', color: '#4FA0C4', glyph: 'crosshair',
description: 'Catch-and-shoot plus defense',
propDNA: { reliable: ['threes'], volatile: ['points', 'assists'] },
education: 'Sentinels catch and shoot. Threes are their signature prop; total points swing with how many open looks the offense generates.',
},
IGNITER: {
legacyName: 'SIXTH MAN', tag: 'IGNITER', sport: 'nba', color: '#FF8A3D', glyph: 'bolt',
description: 'Bench scoring spark',
propDNA: { reliable: ['points'], volatile: ['rebounds', 'assists'] },
education: 'Igniters provide instant offense off the bench. Their points props depend on minutes — confirm the rotation before betting overs.',
},
// ───────── WNBA-unique (5) ─────────
DISTRIBUTOR: {
legacyName: 'POST FACILITATOR', tag: 'DISTRIBUTOR', sport: 'wnba', color: '#7AB8D8', glyph: 'node',
description: 'Playmaking hub from the post',
propDNA: { reliable: ['assists', 'rebounds'], volatile: ['threes'] },
education: 'Distributors orchestrate from the elbow and block. Assists and rebounds are reliable; perimeter shooting is the volatile leg.',
},
SHIELD: {
legacyName: 'TWO-WAY WING', tag: 'SHIELD', sport: 'wnba', color: '#5A8CA8', glyph: 'shieldCheck',
description: 'Two-way perimeter forward',
propDNA: { reliable: ['points', 'steals'], volatile: ['assists'] },
education: 'Shields contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.',
},
RANGE: {
legacyName: 'STRETCH FORWARD', tag: 'RANGE', sport: 'wnba', color: '#C77CE0', glyph: 'target',
description: 'Floor-spacing forward',
propDNA: { reliable: ['threes', 'points'], volatile: ['assists', 'blocks'] },
education: 'Range forwards space the floor from the four. Threes and points are reliable; interior props are matchup-dependent.',
},
SPARK: {
legacyName: 'SLASHING GUARD', tag: 'SPARK', sport: 'wnba', color: '#F0A93D', glyph: 'slash',
description: 'Downhill scoring guard',
propDNA: { reliable: ['points'], volatile: ['threes', 'assists'] },
education: 'Sparks attack downhill. Points are stable; three-point props are the volatile leg since they prioritize the rim.',
},
ANCHOR: {
legacyName: 'INTERIOR ANCHOR', tag: 'ANCHOR', sport: 'wnba', color: '#4C86C4', glyph: 'shield',
description: 'Dominant paint defender and rebounder',
propDNA: { reliable: ['rebounds', 'blocks'], volatile: ['points', 'assists'] },
education: 'Anchors own the paint. Rebounds and blocks are reliable; their scoring depends on post touches.',
},
// ───────── MLB (15) ─────────
BOMBER: {
legacyName: 'POWER SLUGGER', tag: 'BOMBER', sport: 'mlb', color: '#FF9F45', glyph: 'triangle',
description: 'Middle-of-the-order power producer',
propDNA: { reliable: ['total_bases', 'home_runs'], volatile: ['hits'] },
education: 'Bombers drive the ball over the fence. Total bases and home-run props carry their value; their batting-average-driven props (hits) are the volatile leg from strikeout risk.',
},
BRUSH: {
legacyName: 'CONTACT', tag: 'BRUSH', sport: 'mlb', color: '#E0B84A', glyph: 'crosshair',
description: 'High average, low strikeout',
propDNA: { reliable: ['hits'], volatile: ['home_runs', 'total_bases'] },
education: 'Brush hitters rarely strike out, so their hits props are among the most reliable in baseball. Power props (HR, TB) are the volatile leg.',
},
DRIVER: {
legacyName: 'RUN PRODUCER', tag: 'DRIVER', sport: 'mlb', color: '#5A7D9A', glyph: 'diamond',
description: 'RBI-dependent, lineup context',
propDNA: { reliable: ['rbi'], volatile: ['hits', 'home_runs'] },
education: 'Drivers hit in the heart of the order. RBI props track lineup context — strong with runners on base; their individual hit props are more variable.',
},
ALPHA: {
legacyName: 'ACE', tag: 'ALPHA', sport: 'mlb', color: '#7C5CFF', glyph: 'star',
description: 'High K/9, low WHIP, deep games',
propDNA: { reliable: ['strikeouts', 'innings_pitched'], volatile: ['earned_runs'] },
education: 'Alphas 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.',
},
WHIFF: {
legacyName: 'STRIKEOUT ARTIST', tag: 'WHIFF', sport: 'mlb', color: '#E86A6A', glyph: 'bolt',
description: 'Bat-missing arm, high K with traffic',
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
education: 'Whiffs rack up strikeouts but allow more baserunners than an Alpha. The strikeout prop is reliable; earned-run and innings props swing with their traffic.',
},
GHOST: {
legacyName: 'SPEED THREAT', tag: 'GHOST', sport: 'mlb', color: '#3FD8E0', glyph: 'chevrons',
description: 'Stolen bases, speed score',
propDNA: { reliable: ['stolen_bases', 'runs'], volatile: ['home_runs'] },
education: 'Ghosts turn singles into runs. Stolen-base and runs props are their lane; power props rarely clear.',
},
HYBRID: {
legacyName: 'TWO-WAY PLAYER', tag: 'HYBRID', sport: 'mlb', color: '#C88AB0', glyph: 'half',
description: 'Bats and pitches at elite level',
propDNA: { reliable: ['total_bases', 'strikeouts'], volatile: ['hits'] },
education: 'Hybrids produce on both sides of the ball. Read which role they fill that day — their batting and pitching props live on different lines.',
},
FLEX: {
legacyName: 'UTILITY PLAYER', tag: 'FLEX', sport: 'mlb', color: '#A08AC8', glyph: 'plus',
description: 'Multi-position lineup flex',
propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] },
education: 'Flex players move around the lineup and the diamond. Their props are matchup- and slot-dependent — confirm they are starting before betting.',
},
WORKHORSE: {
legacyName: 'INNINGS EATER', tag: 'WORKHORSE', sport: 'mlb', color: '#C89B5A', glyph: 'clock',
description: 'Durable, deep-start arm',
propDNA: { reliable: ['innings_pitched'], volatile: ['strikeouts', 'earned_runs'] },
education: 'Workhorses pitch deep without elite stuff. Innings props are reliable; strikeout and earned-run props are more variable since they pitch to contact.',
},
CATALYST: {
legacyName: 'TABLE SETTER', tag: 'CATALYST', sport: 'mlb', color: '#E88AC0', glyph: 'diamondLine',
description: 'On-base leadoff catalyst',
propDNA: { reliable: ['hits', 'runs'], volatile: ['rbi', 'home_runs'] },
education: 'Catalysts get on base and score. Hits and runs props are their lane; RBI and power props sit lower in their profile.',
},
MIRROR: {
legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#B0B6C8', glyph: 'uparrow',
description: 'Gap-to-gap line-drive bat',
propDNA: { reliable: ['total_bases', 'hits'], volatile: ['home_runs'] },
education: 'Mirror hitters spray doubles to both gaps. Total bases and hits are reliable; home-run props are the volatile leg of their extra-base profile.',
},
HAMMER: {
legacyName: 'CLOSER', tag: 'HAMMER', sport: 'mlb', color: '#FF4D6D', glyph: 'lock',
description: 'Ninth-inning save specialist',
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
education: 'Hammers throw one high-leverage inning. Strikeout props can hit in a clean save; everything else is a one-inning coin flip.',
},
SINKER: {
legacyName: 'SWINGMAN', tag: 'SINKER', sport: 'mlb', color: '#5A9AB0', glyph: 'swap',
description: 'Groundball spot-starter / long relief',
propDNA: { reliable: [], volatile: ['strikeouts', 'innings_pitched', 'earned_runs'] },
education: 'Sinkers bounce between starting and relief and pitch to contact. Their workload is unpredictable, so all of their props carry role risk — confirm the assignment.',
},
BRIDGE: {
legacyName: 'BULLPEN ARM', tag: 'BRIDGE', sport: 'mlb', color: '#6C9CB0', glyph: 'shieldCheck',
description: 'Setup / high-leverage middle relief',
propDNA: { reliable: ['strikeouts'], volatile: ['earned_runs', 'innings_pitched'] },
education: 'Bridges throw short, high-leverage outings. Strikeout props can hit in one inning; innings and earned-run props are too small a sample to trust.',
},
SWITCH: {
legacyName: 'DEFENSIVE WIZARD', tag: 'SWITCH', sport: 'mlb', color: '#C0B08A', glyph: 'batball',
description: 'Platoon-leveraged, glove-first bat',
propDNA: { reliable: [], volatile: ['hits', 'total_bases', 'rbi'] },
education: 'Switch bats earn their spot with the glove and favorable platoon splits. Their offensive props are thin and matchup-dependent — lean unders outside their platoon edge.',
},
// ───────── Soccer (6) ─────────
FINISHER: {
legacyName: 'POACHER', tag: 'FINISHER', sport: 'soccer', color: '#FF5C5C', glyph: 'crosshair',
description: 'Penalty-box finisher',
propDNA: { reliable: ['shots_on_target', 'goals'], volatile: ['assists'] },
education: 'Finishers score inside the box. Shots-on-target and goals are their props; they rarely create for others.',
},
MAESTRO: {
legacyName: 'CREATOR', tag: 'MAESTRO', sport: 'soccer', color: '#D9A441', glyph: 'node',
description: 'Chance-creating playmaker',
propDNA: { reliable: ['assists', 'passes'], volatile: ['goals'] },
education: 'Maestros set the table. Assists and passing props are reliable; their goal output is the volatile leg.',
},
TOWER: {
legacyName: 'TARGET MAN', tag: 'TOWER', sport: 'soccer', color: '#6C9CB0', glyph: 'triangle',
description: 'Hold-up aerial striker',
propDNA: { reliable: ['shots', 'shots_on_target'], volatile: ['goals', 'assists'] },
education: 'Towers win aerial duels and hold the ball up. Shot props are reliable; conversion to goals is variable.',
},
MOTOR: {
legacyName: 'BOX-TO-BOX', tag: 'MOTOR', sport: 'soccer', color: '#E0A03D', glyph: 'chevrons',
description: 'All-action central midfielder',
propDNA: { reliable: ['tackles', 'passes'], volatile: ['goals', 'shots'] },
education: 'Motors cover every blade of grass. Tackles and passing props are reliable; their attacking output swings by role.',
},
BLADE: {
legacyName: 'WING WIZARD', tag: 'BLADE', sport: 'soccer', color: '#B36CFF', glyph: 'slash',
description: 'Dribbling wide threat',
propDNA: { reliable: ['shots', 'assists'], volatile: ['goals'] },
education: 'Blades beat defenders wide. Shots and assists are their lane; goals come in streaks.',
},
WALL: {
legacyName: 'SWEEPER KEEPER', tag: 'WALL', sport: 'soccer', color: '#5A7D9A', glyph: 'shield',
description: 'Distributing goalkeeper',
propDNA: { reliable: ['saves', 'passes'], volatile: ['goals_conceded'] },
education: 'Walls distribute and defend space. Saves and passing props are reliable; goals-conceded depends on the team in front of them.',
},
};
// ── Combat archetype registry (Wave 6 — MMA/UFC) ────────────────────
// PINNED by specs/combat-intelligence.md. Kept in a SEPARATE registry
// (NOT merged into ARCHETYPES) for two reasons:
// 1. FINISHER collides with the soccer archetype name — combat FINISHER
// is a DIFFERENT color/glyph, and the global getArchetype()/ARCHETYPES
// lookup is keyed by uppercase name with no sport dimension.
// 2. Combat FINISHER's green (#12B886) sits close to the signal green,
// which the colorContract gate forbids for shared player archetypes.
// Isolating combat keeps that gate (edge-green purity) intact while
// honoring the pinned combat palette.
// The frontend mirror lives in web/src/lib/archetypes.js COMBAT_ARCHETYPE_MAP;
// tests/unit/combatArchetypes.test.js asserts the two agree (colors + glyphs),
// same discipline as the cross-sport color-match test.
const COMBAT_ARCHETYPES = {
STRIKER: { tag: 'STRIKER', sport: 'mma', color: '#E8703A', glyph: '✦', axis: 'range', description: 'Wins on the feet — volume + power at range.' },
GRAPPLER: { tag: 'GRAPPLER', sport: 'mma', color: '#2FA4E7', glyph: '⊗', axis: 'range', description: 'Fight hits the mat on his terms — control + subs.' },
PRESSURE: { tag: 'PRESSURE', sport: 'mma', color: '#E4574C', glyph: '➤', axis: 'tempo', description: 'Forward, relentless, breaks the pace.' },
COUNTER: { tag: 'COUNTER', sport: 'mma', color: '#8E7BE0', glyph: '◊', axis: 'tempo', description: 'Patient — punishes what you show him.' },
FINISHER: { tag: 'FINISHER', sport: 'mma', color: '#12B886', glyph: '▲', axis: 'outcome', description: 'Ends nights — high KO/SUB rate.' },
GRINDER: { tag: 'GRINDER', sport: 'mma', color: '#B0883B', glyph: '▦', axis: 'outcome', description: 'Goes the distance, wins the rounds.' },
};
// Discipline pedigree tags — VERIFIABLE credentials only (rendered separately
// from the archetype blend). Never inferred/guessed: absent when unknown.
const DISCIPLINE_PEDIGREES = [
'Combat Sambo', 'Dagestan Wrestling', 'BJJ', 'Wrestling Base',
'Kickboxing', 'Muay Thai', 'Boxing',
];
const num = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : 0);
// Strict presence check — an ABSENT stat must not score an axis (Number(null)
// === 0 would fabricate a "0 output" claim). Only a real finite number counts.
const has = (v) => typeof v === 'number' && Number.isFinite(v);
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
/** NBA scorers — VYNDR Original keys. */
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 {
TORCH: ppg >= 22 ? (ppg - 16) / 14 + (usg >= 28 ? 0.3 : 0) : 0,
CONDUCTOR: apg >= 6 ? (apg - 3) / 7 : apg >= 4 ? 0.2 : 0,
FORTRESS: (bpg >= 1.3 ? bpg / 3 : 0) + (rpg >= 8 ? (rpg - 6) / 8 : 0),
ARTILLERY: isBig && threes >= 1.2 ? 0.5 + threes / 6 : 0,
SURGE: s.bench && usg >= 24 ? 0.5 : 0,
'DUAL THREAT': ppg >= 15 && apg >= 3 && apg < 7 ? 0.4 + apg / 20 : 0,
CONNECTOR: usg > 0 && usg < 16 && ppg < 10 ? 0.5 : 0,
FASTBREAK: ppg >= 16 && spg >= 1.2 ? 0.3 : 0,
'PAINT BOSS': isBig && ppg >= 16 && threes < 1 ? 0.5 + ppg / 50 : 0,
LOCKDOWN: spg >= 1.4 && usg < 18 ? 0.5 + spg / 6 : 0,
SWITCHBOARD: apg >= 5 && (pos === 'F' || pos === 'G-F' || pos === 'F-G') && ppg >= 16 ? 0.6 + apg / 16 : 0,
ARCHITECT: ppg >= 16 && fg3a < 4 && !isBig ? 0.35 : 0,
PISTON: isBig && rpg >= 7 && ppg < 16 && threes < 0.5 ? 0.45 : 0,
SENTINEL: threes >= 1.6 && usg < 20 && spg >= 0.9 ? 0.5 + threes / 8 : 0,
IGNITER: s.bench && ppg >= 12 ? 0.4 + ppg / 40 : 0,
};
}
function scoreWNBA(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);
const pos = (s.pos || '').toUpperCase();
const isBig = pos === 'C' || pos === 'F' || pos === 'F-C';
return {
TORCH: ppg >= 18 ? (ppg - 12) / 12 + (usg >= 26 ? 0.25 : 0) : 0,
CONDUCTOR: apg >= 5 ? (apg - 2) / 6 : 0,
'DUAL THREAT': ppg >= 14 && apg >= 3 && apg < 6 ? 0.4 + apg / 18 : 0,
DISTRIBUTOR: isBig && apg >= 3 && rpg >= 7 ? 0.6 + apg / 12 : 0,
SHIELD: !isBig && ppg >= 12 && spg >= 1.2 ? 0.5 + spg / 6 : 0,
RANGE: (pos === 'F' || isBig) && threes >= 1.2 ? 0.5 + threes / 5 : 0,
SPARK: pos.startsWith('G') && ppg >= 14 && threes < 1.5 ? 0.45 : 0,
ANCHOR: isBig && (bpg >= 1 || rpg >= 8) ? 0.5 + rpg / 16 : 0,
LOCKDOWN: spg >= 1.5 && usg < 18 ? 0.45 : 0,
};
}
/**
* MLB scorers. Hitters and pitchers are disjoint.
* 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 {
ALPHA: k9 >= 9.5 && era <= 3.6 && ip >= 5.5 ? 0.6 + k9 / 30 : k9 >= 9 && era <= 3.6 ? 0.3 : 0,
WHIFF: k9 >= 10.5 && era > 3.6 ? 0.6 + k9 / 30 : k9 >= 9.5 && era > 3.8 ? 0.35 : 0,
WORKHORSE: ip >= 6 && k9 < 9 ? 0.55 + ip / 20 : 0,
HAMMER: role === 'CL' || saves >= 10 ? 0.7 + saves / 60 : 0,
BRIDGE: role === 'RP' && saves < 10 ? 0.55 : ip > 0 && ip < 3 ? 0.4 : 0,
SINKER: role === 'SP' && ip < 5 && ip > 0 ? 0.4 : 0,
HYBRID: 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 {
// BOMBER is the single power archetype — fires for any high-HR bat (incl.
// high-strikeout sluggers like Judge). Session 47: thresholds prorated for
// mid-season HR totals (a 15-HR June pace is full-season slugger territory),
// so power leads over DRIVER for a slugger who also drives in runs.
BOMBER: hr >= 15 ? 0.6 + hr / 60 : hr >= 10 ? 0.35 : 0,
BRUSH: avg >= 0.28 && kRate < 16 ? 0.6 + (avg - 0.25) * 2 : avg >= 0.29 ? 0.4 : 0,
DRIVER: rbi >= 50 && hr >= 12 ? 0.5 + rbi / 200 : 0,
GHOST: sb >= 15 ? 0.6 + sb / 60 : sb >= 10 ? 0.35 : 0,
CATALYST: runs >= 50 && sb >= 8 && hr < 15 ? 0.5 + runs / 200 : 0,
MIRROR: doubles >= 25 && hr < 20 ? 0.5 + doubles / 80 : 0,
FLEX: s.utility ? 0.5 : 0,
SWITCH: s.glove && avg < 0.25 ? 0.5 : 0,
HYBRID: s.twoWay ? 0.8 : 0,
};
}
/**
* MMA fighter scorer. Style is a BLEND across three axes:
* range → STRIKER (strikes at distance) ↔ GRAPPLER (mat control + subs)
* tempo → PRESSURE (forward volume) ↔ COUNTER (patient, high defense)
* outcome → FINISHER (KO/SUB rate) ↔ GRINDER (goes the distance)
*
* Best-effort from what the ESPN feed exposes; ESPN's striking/grappling
* granularity is THINNER than ufcstats. Every axis scores ONLY when its
* inputs are real finite numbers — thin data yields fewer style claims,
* never a fabricated one. Inputs (all optional):
* slpm/sapm sig strikes landed/absorbed per min
* strAcc/strDef striking accuracy / defense (0-1)
* tdAvg takedowns per 15
* subAvg sub attempts per 15
* koRate/subRate/decRate fraction of WINS by method (0-1)
* koWins/subWins/decWins method counts (rates derived if rates absent)
*/
function scoreMMA(s = {}) {
const out = {};
// ── range axis ──
if (has(s.slpm)) {
let v = clamp01((s.slpm - 2) / 4); // ~2/min floor, ~6/min elite
if (has(s.tdAvg) && s.tdAvg < 1) v += 0.15; // low takedown reliance = pure striker
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.4) * 1.2) * 0.15;
if (v > 0) out.STRIKER = clamp01(v);
}
if (has(s.tdAvg) || has(s.subAvg)) {
let v = has(s.tdAvg) ? clamp01(s.tdAvg / 4) : 0; // 4 TD/15 ≈ elite control
if (has(s.subAvg)) v += clamp01(s.subAvg / 3) * 0.5;
if (v > 0) out.GRAPPLER = clamp01(v);
}
// ── tempo axis ──
if (has(s.slpm) && has(s.sapm)) {
const vol = clamp01((s.slpm + s.sapm - 6) / 6); // heavy two-way volume = forward pressure
if (vol > 0) out.PRESSURE = vol;
}
if (has(s.strDef) || has(s.strAcc)) {
let v = has(s.strDef) ? clamp01((s.strDef - 0.55) * 2.2) * 0.6 : 0;
if (has(s.strAcc)) v += clamp01((s.strAcc - 0.45) * 2.2) * 0.4;
if (has(s.slpm) && s.slpm > 4.5) v -= 0.2; // a high-output striker isn't a patient counter
if (v > 0) out.COUNTER = clamp01(v);
}
// ── outcome axis ──
const koR = has(s.koRate) ? s.koRate : deriveRate(s.koWins, s);
const subR = has(s.subRate) ? s.subRate : deriveRate(s.subWins, s);
const decR = has(s.decRate) ? s.decRate : deriveRate(s.decWins, s);
if (koR != null || subR != null) {
const finish = (koR || 0) + (subR || 0);
if (finish > 0) out.FINISHER = clamp01(finish);
}
if (decR != null && decR > 0) out.GRINDER = clamp01(decR);
return out;
}
// Derive a method rate from a win count when explicit rates are absent.
// Returns null (not 0) when totals are unknown — absent, never fabricated.
function deriveRate(count, s) {
if (!has(count)) return null;
const total = has(s.totalWins)
? s.totalWins
: (has(s.koWins) ? s.koWins : 0) + (has(s.subWins) ? s.subWins : 0) + (has(s.decWins) ? s.decWins : 0);
if (!total || total <= 0) return null;
return clamp01(count / total);
}
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB, mma: scoreMMA };
/** Look up an archetype descriptor by VYNDR name OR legacy name (case-insensitive). */
function getArchetype(name) {
if (!name) return null;
const key = String(name).toUpperCase();
if (ARCHETYPES[key]) return { name: key, ...ARCHETYPES[key] };
// Legacy-name fallback so old references still resolve.
const byLegacy = Object.entries(ARCHETYPES).find(([, a]) => a.legacyName === key);
return byLegacy ? { name: byLegacy[0], ...byLegacy[1] } : null;
}
/** Look up a COMBAT archetype descriptor by name (case-insensitive). */
function getCombatArchetype(name) {
if (!name) return null;
const key = String(name).toUpperCase();
return COMBAT_ARCHETYPES[key] ? { name: key, ...COMBAT_ARCHETYPES[key] } : null;
}
/**
* Classify a player. Returns:
* { sport, primary, secondary|null, blend: [{archetype, weight}] }
*/
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]);
// Combat is HONEST-empty on thin data: no forced fallback archetype (the
// other sports fall back to a low-usage role, but inventing a fighter's
// style from no data would be a fabrication — spec §STYLE-MATCHUP).
const resolver = sp === 'mma' ? getCombatArchetype : getArchetype;
if (ranked.length === 0) {
if (sp === 'mma') return { sport: sp, primary: null, secondary: null, blend: [] };
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
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 = resolver(ranked[0][0]);
const secondary = ranked.length > 1 && ranked[1][1] >= ranked[0][1] * 0.4
? resolver(ranked[1][0])
: null;
return { sport: sp, primary, secondary, blend };
}
/** Weight of an archetype within a blend (0 when absent). */
function blendWeight(blend, name) {
const hit = (blend || []).find((b) => b.archetype === name);
return hit ? hit.weight : 0;
}
// Accept either a classify() result ({ blend }) or a raw blend array.
function asBlend(x) {
if (Array.isArray(x)) return x;
if (x && Array.isArray(x.blend)) return x.blend;
return [];
}
/**
* styleMatchup(a, b) — a DESCRIPTIVE MODEL style-edge read (the mockup's
* CENTER VERDICT). NOT a settled grade, NOT an edge %, NO fabricated
* confidence. Compares two style blends; when the data is too thin or the
* styles are too close to call, it says so honestly.
*
* a/b may be classify('mma', …) results or raw blend arrays.
* Returns { verdict, edgeSide: 'a'|'b'|null, summary }.
*/
const STYLE_AXES = ['GRAPPLER', 'STRIKER', 'PRESSURE', 'COUNTER', 'FINISHER', 'GRINDER'];
const MIN_EDGE = 0.2; // below this stylistic gap → too close to call
function styleMatchup(a, b) {
const A = asBlend(a);
const B = asBlend(b);
if (A.length === 0 || B.length === 0) {
return {
verdict: 'INSUFFICIENT READ',
edgeSide: null,
summary: 'Not enough style data to call this matchup — a MODEL read needs both fighters profiled.',
};
}
let best = null;
for (const ax of STYLE_AXES) {
const diff = blendWeight(A, ax) - blendWeight(B, ax);
if (!best || Math.abs(diff) > Math.abs(best.diff)) best = { ax, diff };
}
if (!best || Math.abs(best.diff) < MIN_EDGE) {
return {
verdict: 'STYLES EVEN',
edgeSide: null,
summary: 'Two closely matched styles — no clear stylistic edge. A MODEL read, not a graded pick.',
};
}
const edgeSide = best.diff > 0 ? 'a' : 'b';
return {
verdict: `${best.ax} EDGE`,
edgeSide,
summary: `${best.ax} advantage tilts this on style — a MODEL read, not a settled grade.`,
};
}
const classifyNBA = (stats) => classify('nba', stats);
const classifyWNBA = (stats) => classify('wnba', stats);
const classifyMLB = (stats) => classify('mlb', stats);
const classifyMMA = (stats) => classify('mma', stats);
module.exports = {
ARCHETYPES,
COMBAT_ARCHETYPES,
DISCIPLINE_PEDIGREES,
getArchetype,
getCombatArchetype,
classify,
classifyNBA,
classifyWNBA,
classifyMLB,
classifyMMA,
styleMatchup,
};