/** * 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(); // An explicit null does NOT trigger the default parameter, and every scorer // dereferences its argument. Guard it here rather than in four scorers. const input = stats && typeof stats === 'object' ? stats : {}; const scorer = SCORERS[sp]; if (!scorer) return { sport: sp, primary: null, secondary: null, blend: [] }; const scores = scorer(input); 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) { // Session 69 — THE FALLBACK IS GONE. It used to return FLEX (mlb) / SHIELD // (wnba) / CONNECTOR (nba) at weight 1.0, i.e. "we could not classify this // player" rendered as a fully-confident classification of a real archetype, // complete with descriptive education copy. 8 of 18 MLB players carried it. // MMA already did the honest thing; every sport does now: unclassified is // ABSENT, and the multi-axis classifier (services/archetypeAxes.js) makes a // single fallback bucket meaningless anyway — a player is unremarkable on // an AXIS, not as a whole. return { sport: sp, primary: null, secondary: null, blend: [] }; } 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, };