Session 44: Make it visible — VYNDR archetype names, grade intel, schedule fix, landing page (2061 tests)

Frontend + wiring only. Wires existing backend into the pages users see.

- VYNDR Original archetype rename (41) across archetypeService.js + lib/
  archetypes.js + ArchetypeBadge, each keeping legacyName (resolves stale data).
  Judge -> BOMBER. Old POWER PULL slot -> WHIFF strikeout-artist pitcher.
- BACKEND_HANDOFF.md: canonical frontend<->backend data contract.
- Grade card intel: scan/page.tsx now forwards the engine's intel fields
  (season_avg/form/usage/matchup_grade/archetype/...) into mapScanToGradeResult
  -> STAT CONTEXT + VYNDR INTELLIGENCE sections populate. The chain already
  preserved them (tierGating + /api/scan spread); the page was dropping them.
- Schedule freshness: slateAdapter.isRelevantGame drops completed games >24h
  old; Slate.filteredGames applies it. (TTL already 60s.)
- Landing: Features.tsx rewritten to user-facing copy (no Point-biserial/Zone
  14/ABS/Phi-coefficient).
- Depth chart Next proxies added (/api/stats/lineup|depth|cascade) - were 404.
- GameCard swap DEFERRED (Kev): legacy on-demand card stays as a bridge until
  the snapshot pipeline populates the grades cache; vyndr/GameCard swaps in then.

Backend 2045 -> 2061 tests (+16), 167 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 20:15:38 -04:00
parent 80683e71b4
commit 7969a4971a
26 changed files with 766 additions and 302 deletions
+140
View File
@@ -0,0 +1,140 @@
# VYNDR — Backend / Frontend Data Contract (BACKEND_HANDOFF.md)
**Canonical frontend↔backend data contract for the Player Intelligence System.**
Every future session references this. Authored Session 44 from the shipped
implementation (Sessions 4244). When an endpoint or component shape changes,
update this file in the same commit.
> Naming: archetypes are **VYNDR Originals** (TORCH, BOMBER, ALPHA, …). The old
> descriptive labels live on each archetype as `legacyName` and are NEVER shown.
---
## 1. Archetypes (`src/services/archetypeService.js`)
`classify(sport, stats) → { sport, primary, secondary|null, blend }`
```
primary | secondary : {
name, legacyName, tag, sport, color (#hex), glyph (key),
description, propDNA: { reliable: string[], volatile: string[] }, education
}
blend : [{ archetype: <NAME>, weight: 0..1 }] // normalized, top 4, sums ~1
```
**Roster (41):** see `ARCHETYPES` registry. Colors are unique *within* a sport,
reused *across* sports. Frontend visual map (color/glyph/desc) is mirrored in
`web/src/lib/archetypes.js` — keep colors identical (a test enforces it).
- **NBA (15):** TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT,
CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON,
SENTINEL, IGNITER
- **WNBA-unique (5):** DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR (plus reused NBA)
- **MLB (15):** BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX,
WORKHORSE, CATALYST, MIRROR, HAMMER, SINKER, BRIDGE, SWITCH
- **Soccer (6):** FINISHER, MAESTRO, TOWER, MOTOR, BLADE, WALL
Classifier input shapes:
- NBA/WNBA: `{ ppg, rpg, apg, bpg, spg, threes, usg, fg3a, pos, bench }`
- MLB hitter: `{ avg, hr, rbi, sb, ops, runs, k_rate, doubles }`
- MLB pitcher: `{ era, k9, whip, ip_per_start, saves, role: 'SP'|'RP'|'CL' }`
---
## 2. Stats API (`src/routes/stats.js`, mounted at `/api/stats`)
All player-intelligence endpoints are public, rate-limited 60/min, and ALWAYS
return 200 with a valid (possibly empty) shape — the UI must never hard-fail.
Browser must hit the **Next proxy** under `web/src/app/api/stats/...`, never
Express directly.
### `GET /api/stats/player/:name?sport=nba`
```
{
player, sport, team, found: boolean,
archetype: { primary, secondary, blend }, // §1
propDNA: { reliable, volatile },
education: string,
season: [{ k, v, lg? }], // display rows (mono)
last10: [{ d, opp, res?, stat }],
splits: [{ k, a, b }],
gradeHistory: [{ grade, prop, hit?, miss? }],
activeProps: [{ stat, line, side: 'O'|'U', grade, confidence }],
intel: [{ label, kind: 'form'|'grade'|'plain', value, score?, color }],
injury: { label, note, cascade } | null
}
```
`found` is true when real season stats OR tonight's graded props exist. MLB stats
come from `mlbStatsAdapter.getPlayerStats(name)`; NBA/WNBA from `nbaStatsClient`
(degrades to `found:false` when the Python service is offline).
### `GET /api/stats/leaders?sport=mlb&stat=hits&limit=10`
```
{ sport, stat|null, leaders: [{ player, team, stat, line, side, grade, confidence }] }
```
Source: the `grades:{sport}` cache (tonight's graded slate, by confidence desc).
### `GET /api/stats/game/:id?sport=nba`
ESPN game summary (injuries, leaders, ESPN Bet odds, box score) or `{error}`.
### `GET /api/stats/lineup/:team?sport=mlb` (Session 43)
`{ sport, team, lineup: [{ player, position, battingOrder, projectedMinutes }] }`
MLB returns the probable starting pitcher from the schedule.
### `GET /api/stats/depth/:team?sport=nba`
`{ sport, team, positions: [{ position, starter, backup, thirdString }] }`
### `GET /api/stats/cascade/:player?sport=nba&team=SA`
`{ sport, player, cascade: [{ player, stat, delta: '+x%', reason }] }`
Usage redistribution weighted by archetype (SURGE benefits most).
---
## 3. Grade Result Card (`web/src/components/vyndr/GradeResultCard.tsx`)
`GradeResultData` (built by `web/src/lib/gradeAdapter.js` `mapScanToGradeResult`):
core fields (player, team, sport, stat, line, side, grade, confidence, edge,
projection, signals[], killConditions[], books[], altLadder[]) **plus** optional
Player-Intelligence fields that self-hide when absent:
```
archetypeBlend?: [{ archetype, weight }]
propDNA?: { reliable: string[], volatile: string[] }
statContext?: { season?, last10?, vsOpp? }
vyndrIntel?: { form?, usage?, matchup?, rest? }
```
The engine (`analyzeViaEngine1`) attaches snake_case fields
(`season_avg`/`last10_avg`/`form`/`usage`/`matchup_grade`/`rest`, and when a
season line is available, `archetype`/`archetype_blend`/`prop_dna`); the adapter
maps them in. Archetype at grade time requires a multi-stat season line — until
the snapshot pipeline supplies it, the archetype strip stays hidden.
---
## 4. Game Card (`web/src/components/vyndr/GameCard.tsx`)
`GameCardData` core (id, sport, away/home, time, venue, lines[], …) **plus**:
```
playerStrips?: [{ player, team, archetype?: {primary, secondary?}, stats: [{label,value}], props: [{stat,line,side,grade}] }]
pitchers?: { away: {name, era, archetype?}, home: {name, era, archetype?} }
```
Built by `slateAdapter.mapScheduleToGameCards` (`groupPropsByPlayer` +
`mapPitchers`). The card prefers `playerStrips` (name once, horizontal) over
legacy per-prop rows. Book chips use `web/src/lib/books.js` brand colors.
---
## 5. Stat Strip (`web/src/components/vyndr/StatStrip.tsx`)
`compact` (game cards) | `expanded` (profile hero / grade result). HARD RULE: the
player name appears ONCE; stats flow horizontally in JetBrains Mono. Props render
inline with `GradeBadge`. `onPlayerClick``/player/:name?sport=` (`lib/playerHref.js`).
---
## 6. Freshness & caching
- Schedule cache TTL ≤ 30 min (`scheduleService`). Frontend filters completed
games older than 24h out of the slate (`slateAdapter.isRelevantGame`).
- Player props: PropLine primary (3-key rotation), The-Odds-API backup.
- `grades:{sport}` cache (TTL 2h) is written by `gradeSlateService` on a fresh
odds fetch; it feeds `/leaders` + the player card's `activeProps`.
+56 -2
View File
@@ -4,8 +4,62 @@
2026-06-18
## Current Phase
SHIP BUILD v43.0 — Data pipeline wiring + P0 audit fixes + depth chart
foundation. The Session-42 Player Intelligence architecture now flows REAL data.
SHIP BUILD v44.0 — Make it visible: VYNDR Original archetype names, grade-card
intel wiring, stale-game filtering, landing copy, depth proxies. Frontend +
wiring only (no new backend services).
## Session 44 (2026-06-18) — SHIPPED ✅ MAKE IT VISIBLE
Wired existing backend work into the pages users see + renamed archetypes to
VYNDR Originals. Backend 2045 → **2061 tests** (+16), 167 suites. Web build clean
(exit 0). `BACKEND_HANDOFF.md` added as the canonical data contract.
### VYNDR Original archetype rename (proprietary names)
All 41 archetypes renamed in `archetypeService.js` + `lib/archetypes.js` +
`ArchetypeBadge`, each keeping `legacyName`/`legacy` (never displayed; resolves
for stale data). NBA: TORCH, CONDUCTOR, FORTRESS, ARTILLERY, SURGE, DUAL THREAT,
CONNECTOR, FASTBREAK, PAINT BOSS, LOCKDOWN, SWITCHBOARD, ARCHITECT, PISTON,
SENTINEL, IGNITER. WNBA-unique: DISTRIBUTOR, SHIELD, RANGE, SPARK, ANCHOR. MLB:
BOMBER, BRUSH, DRIVER, ALPHA, WHIFF, GHOST, HYBRID, FLEX, WORKHORSE, CATALYST,
MIRROR, HAMMER, SINKER, BRIDGE, SWITCH. Soccer: FINISHER, MAESTRO, TOWER, MOTOR,
BLADE, WALL. **MLB note:** the old taxonomy had two power hitters (POWER
PULL/POWER SLUGGER) but the new set has one power name (BOMBER), so BOMBER now
fires for any high-HR bat (Judge → BOMBER per the audit checklist) and the freed
slot became a real WHIFF strikeout-artist pitcher (improves pitcher coverage).
`getArchetype`/`archetypeInfo` resolve legacy names → VYNDR Originals.
### Phase 2 — grade-card intel now populates (the real bug)
The chain engine→tierGating→/api/scan proxy already PRESERVED the intel fields
(all spread `...result`/`...data`). The ONE broken link: `scan/page.tsx` called
`mapScanToGradeResult` with a hardcoded field subset and DROPPED season_avg/
form/usage/matchup_grade/etc. Now forwards them (+ extended `ScanResponse`), so
STAT CONTEXT + VYNDR INTELLIGENCE sections light up on a real MLB grade.
### Phase 3 — stale-game filtering
`slateAdapter.isRelevantGame(game, now)`: upcoming/live always show; a COMPLETED
game is dropped once >24h old (no more 5-day-old FINALs). Applied in `Slate`'s
`filteredGames`. Schedule TTL was already 60s (≤30min) — no change.
### Phase 4 — landing copy
`Features.tsx` rewritten: no more "Point-biserial", "Zone 14", "ABS
intelligence", "Auto-calibrating", "Phi-coefficient". User-facing benefit copy
(Player DNA archetypes / Self-improving model / Lineup intel before tip-off /
Deep pitcher-batter matchups).
### Phase 5 — depth chart proxies (404 fix)
Added the missing Next proxies `/api/stats/lineup/[team]`, `/depth/[team]`,
`/cascade/[player]` → return JSON, not 404.
### Phase 1 — GameCard swap DEFERRED (decision: Kev)
The live Slate keeps the legacy "Read"/on-demand grade card as a **temporary
bridge**. The vyndr/GameCard swap lands WITH the snapshot pipeline (next
session): the on-demand "Read" model is being retired for a pre-graded snapshot
model (full slate graded at scheduled intervals, grades locked to the line), and
the new card is designed for that. Swapping now would remove grading + show blank
cards (grades cache unpopulated). BookChip brand colors already render in the
legacy card (S43).
## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES
## Session 43 (2026-06-18) — SHIPPED ✅ DATA PIPELINE + AUDIT FIXES
+27
View File
@@ -386,6 +386,33 @@ Built from the Claude Design "VYNDR Player Intelligence" bundle.
`matchesTeam` must guard empty names (`t.includes('')` is always true) — and
the schedule `find` checks `g.home`/`g.away`, not the game object.
## VYNDR Original Archetypes + Make-It-Visible (Session 44 — non-obvious)
- **Archetype names are VYNDR Originals** (TORCH/BOMBER/ALPHA/…), NOT the old
descriptive labels. The registry keys in `archetypeService.js` + the map keys
in `lib/archetypes.js` are the new names; each carries `legacyName`/`legacy`
(the old label, NEVER displayed). `getArchetype()` + `archetypeInfo()` +
`badgeStyle()` resolve legacy names → the canonical VYNDR name (defends stale
cached data). The `classify()` scorer objects use the new keys too — if you
edit a threshold, use the new key. Full mapping is in `BACKEND_HANDOFF.md`.
- **MLB power merge:** there's ONE power archetype (BOMBER) — it fires for any
high-HR bat (incl. high-K sluggers like Judge → BOMBER). The old POWER PULL
slot became WHIFF (strikeout-artist pitcher). Don't re-split power.
- **`BACKEND_HANDOFF.md`** (repo root) is the canonical frontend↔backend data
contract — update it in the same commit when an endpoint/component shape changes.
- **Grade-card intel gotcha:** the engine→tierGating→/api/scan chain already
preserves the intel fields (everything spreads `...result`/`...data`). The bug
was `scan/page.tsx` passing a hardcoded field subset to `mapScanToGradeResult`
— it must forward season_avg/last10_avg/form/usage/matchup_grade/rest/archetype
/archetype_blend/prop_dna (and `ScanResponse` must type them) or the card
sections stay hidden.
- **Stale games:** `slateAdapter.isRelevantGame(game, now)` drops completed games
>24h old; `Slate.filteredGames` applies it. Schedule TTL is 60s.
- **GameCard swap is DEFERRED** (Kev's call): the live Slate keeps the legacy
on-demand "Read" card as a bridge until the snapshot pipeline lands. The
on-demand grade model is being retired for a pre-graded snapshot model; the
vyndr/GameCard (playerStrips/pitchers) is built for that and swaps in then.
Don't swap it before the grades cache is populated (cards would be blank).
## Active Skills
- vyndr-voice (all user-facing output)
- prop-analysis (grading methodology)
+195 -206
View File
@@ -1,334 +1,324 @@
/**
* archetypeService player archetype classification (Session 42).
* 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 + 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.
* 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. That's
* why it lives in services/ and is unit-testable in isolation.
* No API calls, no I/O feed it a stat object, get a classification.
*/
// ── 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',
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: '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.',
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.',
},
'FLOOR GENERAL': {
tag: 'FLOOR GEN', sport: 'nba', color: '#4A9EFF', glyph: 'node',
CONDUCTOR: {
legacyName: 'FLOOR GENERAL', tag: 'CONDUCTOR', 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.',
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.',
},
'TWO-WAY ANCHOR': {
tag: 'TW ANCHOR', sport: 'nba', color: '#A78BFA', glyph: 'shield',
FORTRESS: {
legacyName: 'TWO-WAY ANCHOR', tag: 'FORTRESS', 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.',
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.',
},
'STRETCH BIG': {
tag: 'STRETCH BIG', sport: 'nba', color: '#2DD4BF', glyph: 'target',
ARTILLERY: {
legacyName: 'STRETCH BIG', tag: 'ARTILLERY', 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.',
education: 'Artillery 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',
SURGE: {
legacyName: 'USAGE SPONGE', tag: 'SURGE', 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.',
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.',
},
'COMBO GUARD': {
tag: 'COMBO GD', sport: 'nba', color: '#00D4A0', glyph: 'twin',
'DUAL THREAT': {
legacyName: 'COMBO GUARD', tag: 'DUAL THREAT', 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.',
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.',
},
'ROLE GLUE': {
tag: 'ROLE GLUE', sport: 'nba', color: '#9499A8', glyph: 'chain',
CONNECTOR: {
legacyName: 'ROLE GLUE', tag: 'CONNECTOR', 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.',
education: 'Connectors 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',
FASTBREAK: {
legacyName: 'TRANSITION ENGINE', tag: 'FASTBREAK', 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.',
education: 'Fastbreaks 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',
'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: 'Post scorers operate in the paint. Points and rebounds are reliable against most fronts; perimeter props are noise.',
education: 'A Paint Boss operates 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',
LOCKDOWN: {
legacyName: 'DEFENSIVE SPECIALIST', tag: 'LOCKDOWN', 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.',
education: 'Lockdowns 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',
SWITCHBOARD: {
legacyName: 'POINT FORWARD', tag: 'SWITCHBOARD', 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.',
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.',
},
SLASHER: {
tag: 'SLASHER', sport: 'nba', color: '#FB923C', glyph: 'slash',
description: 'Rim-attacking, foul-drawing driver',
ARCHITECT: {
legacyName: 'SLASHER', tag: 'ARCHITECT', sport: 'nba', color: '#FB923C', glyph: 'slash',
description: 'Self-created shot maker, rim-attacking',
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.',
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.',
},
'RIM RUNNER': {
tag: 'RIM RUN', sport: 'nba', color: '#F472B6', glyph: 'arc',
PISTON: {
legacyName: 'RIM RUNNER', tag: 'PISTON', 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.',
education: 'Pistons 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',
SENTINEL: {
legacyName: '3-AND-D', tag: 'SENTINEL', 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.',
education: 'Sentinels 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',
IGNITER: {
legacyName: 'SIXTH MAN', tag: 'IGNITER', 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.',
education: 'Igniters 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',
DISTRIBUTOR: {
legacyName: 'POST FACILITATOR', tag: 'DISTRIBUTOR', 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.',
education: 'Distributors 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',
SHIELD: {
legacyName: 'TWO-WAY WING', tag: 'SHIELD', sport: 'wnba', color: '#A78BFA', glyph: 'shieldCheck',
description: 'Two-way perimeter forward',
propDNA: { reliable: ['points', 'steals'], volatile: ['assists'] },
education: 'Two-way wings contribute on both ends. Points and defensive stats stay live; their playmaking is secondary.',
education: 'Shields 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',
RANGE: {
legacyName: 'STRETCH FORWARD', tag: 'RANGE', 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.',
education: 'Range 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',
SPARK: {
legacyName: 'SLASHING GUARD', tag: 'SPARK', sport: 'wnba', color: '#FB923C', glyph: 'slash',
description: 'Downhill scoring 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.',
education: 'Sparks 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',
ANCHOR: {
legacyName: 'INTERIOR ANCHOR', tag: 'ANCHOR', sport: 'wnba', color: '#6366F1', glyph: 'shield',
description: 'Dominant 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.',
education: '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',
BOMBER: {
legacyName: 'POWER SLUGGER', tag: 'BOMBER', sport: 'mlb', color: '#FF6B4A', glyph: 'triangle',
description: 'Middle-of-the-order power producer',
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.',
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.',
},
CONTACT: {
tag: 'CONTACT', sport: 'mlb', color: '#3DDC84', glyph: 'crosshair',
BRUSH: {
legacyName: 'CONTACT', tag: 'BRUSH', 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.',
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.',
},
'RUN PRODUCER': {
tag: 'RUN PROD', sport: 'mlb', color: '#4A9EFF', glyph: 'diamond',
DRIVER: {
legacyName: 'RUN PRODUCER', tag: 'DRIVER', 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.',
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.',
},
ACE: {
tag: 'ACE', sport: 'mlb', color: '#A78BFA', glyph: 'star',
ALPHA: {
legacyName: 'ACE', tag: 'ALPHA', 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.',
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.',
},
'BULLPEN ARM': {
tag: 'BULLPEN', sport: 'mlb', color: '#FFB347', glyph: 'bolt',
description: 'Short outings, high leverage',
WHIFF: {
legacyName: 'STRIKEOUT ARTIST', tag: 'WHIFF', sport: 'mlb', color: '#FFB347', glyph: 'bolt',
description: 'Bat-missing arm, high K with traffic',
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.',
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.',
},
'SPEED THREAT': {
tag: 'SPEED', sport: 'mlb', color: '#2DD4BF', glyph: 'chevrons',
GHOST: {
legacyName: 'SPEED THREAT', tag: 'GHOST', 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.',
education: 'Ghosts 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',
HYBRID: {
legacyName: 'TWO-WAY PLAYER', tag: 'HYBRID', 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.',
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.',
},
'UTILITY PLAYER': {
tag: 'UTILITY', sport: 'mlb', color: '#22D3EE', glyph: 'plus',
FLEX: {
legacyName: 'UTILITY PLAYER', tag: 'FLEX', 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.',
education: 'Flex 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',
WORKHORSE: {
legacyName: 'INNINGS EATER', tag: 'WORKHORSE', sport: 'mlb', color: '#818CF8', glyph: 'clock',
description: 'Durable, deep-start arm',
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.',
education: 'Workhorses 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',
CATALYST: {
legacyName: 'TABLE SETTER', tag: 'CATALYST', 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.',
education: 'Catalysts 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',
MIRROR: {
legacyName: 'GAP HITTER', tag: 'MIRROR', sport: 'mlb', color: '#34D399', glyph: 'uparrow',
description: 'Gap-to-gap line-drive bat',
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.',
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.',
},
CLOSER: {
tag: 'CLOSER', sport: 'mlb', color: '#FB7185', glyph: 'lock',
HAMMER: {
legacyName: 'CLOSER', tag: 'HAMMER', 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.',
education: 'Hammers 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',
SINKER: {
legacyName: 'SWINGMAN', tag: 'SINKER', sport: 'mlb', color: '#FBBF24', glyph: 'swap',
description: 'Groundball spot-starter / 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.',
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.',
},
'DEFENSIVE WIZARD': {
tag: 'DEF WIZ', sport: 'mlb', color: '#6366F1', glyph: 'shieldCheck',
description: 'Glove-first defensive value',
BRIDGE: {
legacyName: 'BULLPEN ARM', tag: 'BRIDGE', sport: 'mlb', color: '#6366F1', 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: '#FF5C5C', glyph: 'batball',
description: 'Platoon-leveraged, glove-first bat',
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.',
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) — present in the design map ─────────
POACHER: {
tag: 'POACHER', sport: 'soccer', color: '#FF5C5C', glyph: 'crosshair',
// ───────── 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: 'Poachers finish inside the box. Shots-on-target and goals are their props; they rarely create for others.',
education: 'Finishers score 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',
MAESTRO: {
legacyName: 'CREATOR', tag: 'MAESTRO', 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.',
education: 'Maestros 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',
TOWER: {
legacyName: 'TARGET MAN', tag: 'TOWER', 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.',
education: 'Towers 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',
MOTOR: {
legacyName: 'BOX-TO-BOX', tag: 'MOTOR', 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.',
education: 'Motors 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',
BLADE: {
legacyName: 'WING WIZARD', tag: 'BLADE', 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.',
education: 'Blades beat defenders wide. Shots and assists are their lane; goals come in streaks.',
},
'SWEEPER KEEPER': {
tag: 'SWEEPER', sport: 'soccer', color: '#A78BFA', glyph: 'shield',
WALL: {
legacyName: 'SWEEPER KEEPER', tag: 'WALL', 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.',
education: 'Walls 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').
*/
/** 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 {
'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,
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) {
// 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,
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; `isPitcher` (or presence of
* era/k9) routes to the pitcher archetypes.
* 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').
*/
@@ -340,46 +330,47 @@ function scoreMLB(s) {
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,
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 {
'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,
// BOMBER is the single power archetype — fires for any high-HR bat (incl.
// high-strikeout sluggers like Judge), so power hitters classify as BOMBER.
BOMBER: hr >= 20 ? 0.6 + hr / 60 : hr >= 15 ? 0.3 : 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,
};
}
const SCORERS = { nba: scoreNBA, wnba: scoreWNBA, mlb: scoreMLB };
/** Look up an archetype's static descriptor by name (case-insensitive). */
/** 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();
const a = ARCHETYPES[key];
if (!a) return null;
return { name: key, ...a };
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;
}
/**
* 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();
@@ -392,8 +383,7 @@ function classify(sport, stats = {}) {
.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';
const fallback = sp === 'mlb' ? 'FLEX' : sp === 'wnba' ? 'SHIELD' : 'CONNECTOR';
return { sport: sp, primary: getArchetype(fallback), secondary: null, blend: [{ archetype: fallback, weight: 1 }] };
}
@@ -402,7 +392,6 @@ function classify(sport, stats = {}) {
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;
+5 -3
View File
@@ -101,11 +101,13 @@ async function getCascadeProjection(sport, player, team, opts = {}) {
// Distribute a fixed usage pool across teammates, weighting usage sponges and
// high-usage creators heavier (mirrors the design's cascade framing).
const POOL = Number.isFinite(opts.usagePool) ? opts.usagePool : 12; // %
// VYNDR Originals (Session 44): SURGE (usage sponge) benefits most; primary
// creators (TORCH/DUAL THREAT/SWITCHBOARD) next; low-usage glue least.
const weightFor = (a) => {
const k = norm(a);
if (k.includes('USAGE SPONGE')) return 3;
if (k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2;
if (k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5;
if (k === 'SURGE' || k.includes('USAGE SPONGE')) return 3;
if (['TORCH', 'DUAL THREAT', 'SWITCHBOARD', 'IGNITER'].includes(k) || k.includes('VOLUME') || k.includes('COMBO') || k.includes('POINT FORWARD')) return 2;
if (k === 'CONNECTOR' || k === 'LOCKDOWN' || k.includes('ROLE GLUE') || k.includes('SPECIALIST')) return 0.5;
return 1;
};
const weights = teammates.map((m) => weightFor(m.archetype));
+5 -5
View File
@@ -10,21 +10,21 @@ 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');
const s = arch.badgeStyle('BOMBER', 'full', 'md');
expect(s.bg).toBe('#FF6B4A');
expect(s.textColor).toBe('#FFFFFF');
expect(s.borderColor).toBe('#FF5C5C');
expect(s.borderColor).toBe('#FF6B4A');
});
it('ghost variant is transparent with a colored border', () => {
const s = arch.badgeStyle('FLOOR GENERAL', 'ghost', 'sm');
const s = arch.badgeStyle('CONDUCTOR', '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');
const s = arch.badgeStyle('ALPHA');
expect(s.bg).toBe('#A78BFA1F');
expect(s.borderColor).toBe('#A78BFA52');
});
+28 -14
View File
@@ -10,7 +10,7 @@ describe('archetypeService — registry', () => {
expect(bySport).toEqual({ nba: 15, wnba: 5, mlb: 15, soccer: 6 });
});
it('every archetype has name/tag/color/glyph/description/propDNA/education', () => {
it('every archetype has name/tag/color/glyph/description/propDNA/education/legacyName', () => {
for (const [name, a] of Object.entries(svc.ARCHETYPES)) {
expect(typeof a.tag).toBe('string');
expect(a.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
@@ -21,9 +21,20 @@ describe('archetypeService — registry', () => {
expect(Array.isArray(a.propDNA.volatile)).toBe(true);
expect(a.education.length).toBeGreaterThan(20);
expect(name).toBe(name.toUpperCase());
expect(typeof a.legacyName).toBe('string'); // VYNDR Originals keep the old label
}
});
it('uses VYNDR Original names, not the old descriptive labels', () => {
const keys = Object.keys(svc.ARCHETYPES);
expect(keys).toContain('TORCH');
expect(keys).toContain('BOMBER');
expect(keys).toContain('ALPHA');
expect(keys).not.toContain('VOLUME SCORER');
expect(keys).not.toContain('POWER SLUGGER');
expect(keys).not.toContain('ACE');
});
it('colors are unique WITHIN each sport (reused across sports by design)', () => {
const bySport = {};
for (const a of Object.values(svc.ARCHETYPES)) {
@@ -35,9 +46,12 @@ describe('archetypeService — registry', () => {
}
});
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');
it('getArchetype is case-insensitive, resolves legacy names, returns null for unknown', () => {
expect(svc.getArchetype('alpha').name).toBe('ALPHA');
expect(svc.getArchetype('Fortress').name).toBe('FORTRESS');
// legacy names still resolve to their VYNDR Original
expect(svc.getArchetype('ACE').name).toBe('ALPHA');
expect(svc.getArchetype('POWER SLUGGER').name).toBe('BOMBER');
expect(svc.getArchetype('not a real one')).toBeNull();
});
});
@@ -45,7 +59,7 @@ describe('archetypeService — registry', () => {
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.primary.name).toBe('TORCH');
expect(r.sport).toBe('nba');
});
@@ -58,7 +72,7 @@ describe('archetypeService — NBA classification', () => {
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);
expect(['FORTRESS', 'PAINT BOSS', 'ARTILLERY']).toContain(r.primary.name);
});
it('produces a normalized blend that sums to ~1', () => {
@@ -73,32 +87,32 @@ 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');
expect(ace.primary.name).toBe('ALPHA');
expect(eater.primary.name).toBe('WORKHORSE');
});
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');
expect(r.primary.name).toBe('HAMMER');
});
it('classifies a contact hitter and a power-pull hitter differently', () => {
it('classifies a contact hitter (BRUSH) and a power hitter (BOMBER) 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');
expect(contact.primary.name).toBe('BRUSH');
expect(power.primary.name).toBe('BOMBER');
});
});
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);
expect(['ANCHOR', 'TORCH', 'RANGE']).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');
expect(r.primary.name).toBe('CONDUCTOR');
});
});
+3 -3
View File
@@ -53,9 +53,9 @@ describe('getCascadeProjection', () => {
it('distributes usage to teammates, weighting usage sponges heaviest', async () => {
const r = await svc.getCascadeProjection('nba', 'Keldon Murray', 'SA', {
teammates: [
{ player: 'Wembanyama', archetype: 'VOLUME SCORER' },
{ player: 'Bench Spark', archetype: 'USAGE SPONGE' },
{ player: 'Glue Guy', archetype: 'ROLE GLUE' },
{ player: 'Wembanyama', archetype: 'TORCH' },
{ player: 'Bench Spark', archetype: 'SURGE' },
{ player: 'Glue Guy', archetype: 'CONNECTOR' },
],
});
expect(r).toHaveLength(3);
+19
View File
@@ -0,0 +1,19 @@
// Session 44 — Phase 5: depth chart Next proxies exist (were 404ing).
const fs = require('fs');
const path = require('path');
const API = path.join(__dirname, '..', '..', 'web', 'src', 'app', 'api', 'stats');
describe('Depth chart Next proxy routes', () => {
it.each([
['lineup/[team]/route.ts', '/api/stats/lineup/'],
['depth/[team]/route.ts', '/api/stats/depth/'],
['cascade/[player]/route.ts', '/api/stats/cascade/'],
])('%s exists and forwards to the Express backend', (rel, fwd) => {
const p = path.join(API, rel);
expect(fs.existsSync(p)).toBe(true);
const src = fs.readFileSync(p, 'utf8');
expect(src).toContain('BACKEND_URL');
expect(src).toContain(fwd);
});
});
+19
View File
@@ -0,0 +1,19 @@
// Session 44 — Phase 4: landing feature cards use user-facing language.
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'components', 'Features.tsx'), 'utf8');
describe('Landing feature cards', () => {
it('contains NO engineer-speak', () => {
for (const jargon of ['Point-biserial', 'Zone 14', 'ABS intelligence', 'Auto-calibrating', 'framing loss', 'redistribution-aware', 'Phi-coefficient', 'blind-spot detection']) {
expect(src).not.toContain(jargon);
}
});
it('uses the rewritten user-facing titles', () => {
expect(src).toContain('Player DNA archetypes');
expect(src).toContain('Self-improving model');
expect(src).toContain('Lineup intel before tip-off');
expect(src).toContain('Deep pitcher-batter matchups');
});
});
+3 -3
View File
@@ -16,14 +16,14 @@ describe('gradeAdapter — Player Intelligence fields', () => {
});
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 }]);
const f = buildIntelFields({ archetype: 'TORCH' });
expect(f.archetypeBlend).toEqual([{ archetype: 'TORCH', 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 }],
archetype_blend: [{ archetype: 'FORTRESS', weight: 0.6 }, { archetype: 'ARTILLERY', 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%',
+3 -3
View File
@@ -64,8 +64,8 @@ describe('getPlayerIntel with real stats (Session 43)', () => {
expect(r.team).toBe('New York Yankees');
expect(r.season.length).toBeGreaterThan(0);
// 34 HR + high K-rate → POWER PULL, not the empty-stats fallback.
expect(['POWER PULL', 'POWER SLUGGER', 'RUN PRODUCER']).toContain(r.archetype.primary.name);
expect(r.archetype.primary.name).not.toBe('UTILITY PLAYER');
expect(['BOMBER', 'BOMBER', 'DRIVER']).toContain(r.archetype.primary.name);
expect(r.archetype.primary.name).not.toBe('FLEX');
});
it('classifies an ace pitcher from real stats', async () => {
@@ -73,7 +73,7 @@ describe('getPlayerIntel with real stats (Session 43)', () => {
cacheGet: async () => null,
resolveStats: (name, sport) => svc.resolvePlayerStats(name, sport, { mlbAdapter: aceAdapter }),
});
expect(r.archetype.primary.name).toBe('ACE');
expect(r.archetype.primary.name).toBe('ALPHA');
expect(r.found).toBe(true);
});
+3 -3
View File
@@ -23,9 +23,9 @@ describe('playerProfileAdapter', () => {
expect(rows[1]).toMatchObject({ prop: 'Hits', state: 'VOLATILE', color: '#FFB347' });
});
it('writes a blend readout naming primary + secondary', () => {
const txt = adapter.blendReadout({ primary: { name: 'POWER PULL' }, secondary: { name: 'RUN PRODUCER' } });
expect(txt).toContain('Power Pull');
expect(txt).toContain('Run Producer');
const txt = adapter.blendReadout({ primary: { name: 'BOMBER' }, secondary: { name: 'DRIVER' } });
expect(txt).toContain('Bomber');
expect(txt).toContain('Driver');
});
});
+43
View File
@@ -0,0 +1,43 @@
// Session 44 — Phase 2: the grade card's intel sections actually populate.
// The full chain: engine (Object.assign intel) → analyze route (tierGating
// spread) → /api/scan proxy (spread) → scan page → gradeAdapter → card.
// The broken link was the scan page dropping the fields; this locks it.
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
const { applyTierGating } = require('../../src/utils/tierGating');
describe('tierGating preserves engine intel fields', () => {
it('free tier keeps season_avg/form/etc. (spreads ...result)', () => {
const gated = applyTierGating({ grade: 'A', season_avg: 26.9, form: 92, matchup_grade: 'A', usage: '31%' }, 'free');
expect(gated.season_avg).toBe(26.9);
expect(gated.form).toBe(92);
expect(gated.matchup_grade).toBe('A');
});
it('paid tier passes through unchanged', () => {
const out = applyTierGating({ grade: 'A', season_avg: 26.9, form: 92 }, 'desk');
expect(out.season_avg).toBe(26.9);
});
});
describe('scan page forwards intel fields to the grade card', () => {
const src = read('web/src/app/scan/page.tsx');
it('passes the engine intel fields into mapScanToGradeResult', () => {
for (const f of ['season_avg: result.season_avg', 'last10_avg: result.last10_avg', 'form: result.form', 'usage: result.usage', 'matchup_grade: result.matchup_grade', 'rest: result.rest', 'archetype: result.archetype', 'archetype_blend: result.archetype_blend', 'prop_dna: result.prop_dna']) {
expect(src).toContain(f);
}
});
it('ScanResponse type declares the intel fields', () => {
expect(src).toContain('season_avg?: number');
expect(src).toContain('matchup_grade?: string');
});
});
describe('/api/scan proxy preserves engine fields', () => {
it('spreads ...data (does not whitelist)', () => {
const proxy = read('web/src/app/api/scan/route.ts');
expect(proxy).toContain('...data');
});
});
+2 -2
View File
@@ -22,9 +22,9 @@ describe('groupPropsByPlayer', () => {
it('attaches an archetype when a lookup is provided', () => {
const out = adapter.groupPropsByPlayer(
[{ player: 'Riley', stat: 'Hits', line: 1.5, side: 'Over', grade: 'A' }],
() => ({ primary: 'POWER PULL' }),
() => ({ primary: 'BOMBER' }),
);
expect(out[0].archetype).toEqual({ primary: 'POWER PULL' });
expect(out[0].archetype).toEqual({ primary: 'BOMBER' });
});
it('returns [] for empty / non-array input', () => {
+34
View File
@@ -0,0 +1,34 @@
// Session 44 — Phase 3: stale-game filtering.
const fs = require('fs');
const path = require('path');
const adapter = require('../../web/src/lib/slateAdapter');
const NOW = new Date('2026-06-18T20:00:00Z').getTime();
const hoursAgo = (h) => new Date(NOW - h * 3_600_000).toISOString();
describe('isRelevantGame', () => {
it('keeps upcoming and live games regardless of date', () => {
expect(adapter.isRelevantGame({ status: 'pre', gameTime: hoursAgo(-2) }, NOW)).toBe(true);
expect(adapter.isRelevantGame({ status: 'in', gameTime: hoursAgo(1) }, NOW)).toBe(true);
});
it('drops a completed game older than 24h', () => {
expect(adapter.isRelevantGame({ status: 'post', gameTime: hoursAgo(120) }, NOW)).toBe(false); // 5 days
expect(adapter.isRelevantGame({ state: 'final', date: hoursAgo(30) }, NOW)).toBe(false);
});
it('keeps a recently completed game (<24h)', () => {
expect(adapter.isRelevantGame({ status: 'post', gameTime: hoursAgo(3) }, NOW)).toBe(true);
});
it('keeps games with an unparseable/missing date (degrade open)', () => {
expect(adapter.isRelevantGame({ status: 'post' }, NOW)).toBe(true);
expect(adapter.isRelevantGame({ status: 'post', gameTime: 'nonsense' }, NOW)).toBe(true);
});
});
describe('Slate applies the freshness filter', () => {
it('filters games through isRelevantGame', () => {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'web', 'src', 'components', 'Slate.tsx'), 'utf8');
expect(src).toContain('isRelevantGame');
expect(src).toContain('games.filter((g) => isRelevantGame(g))');
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Cascade proxy (Session 44) — forwards GET /api/stats/cascade/:player to Express. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ player: string }> }) {
const { player } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/cascade/${encodeURIComponent(player)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Cascade service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Depth chart proxy (Session 44) — forwards GET /api/stats/depth/:team to Express. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ team: string }> }) {
const { team } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/depth/${encodeURIComponent(team)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Depth chart service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
/** Lineup proxy (Session 44) — forwards GET /api/stats/lineup/:team to Express. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ team: string }> }) {
const { team } = await ctx.params;
const qs = req.nextUrl.search;
try {
const upstream = await fetch(`${BACKEND_URL}/api/stats/lineup/${encodeURIComponent(team)}${qs}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const data = await upstream.json().catch(() => ({}));
return NextResponse.json(data, { status: upstream.ok ? 200 : upstream.status });
} catch {
return NextResponse.json({ error: 'Lineup service is unreachable. Try again in a moment.' }, { status: 502 });
}
}
+24
View File
@@ -47,6 +47,18 @@ interface ScanResponse {
tier: 'free' | 'analyst' | 'desk';
error?: string;
upgrade?: { tier: string; price: number };
// Session 43/44 — Player-Intelligence fields the engine attaches; the grade
// card's STAT CONTEXT + VYNDR INTELLIGENCE sections read these.
season_avg?: number;
last10_avg?: number;
vs_opp_avg?: number;
form?: number;
usage?: string;
matchup_grade?: string;
rest?: string;
archetype?: string;
archetype_blend?: { archetype: string; weight: number }[];
prop_dna?: { reliable: string[]; volatile: string[] };
}
const NBA_STATS = [
@@ -689,6 +701,18 @@ export default function ScanPage() {
alt_lines: result.alt_lines,
kill_conditions: result.kill_conditions,
tier,
// Session 44 — forward the engine's intel fields so the grade
// card's STAT CONTEXT + VYNDR INTELLIGENCE sections populate.
season_avg: result.season_avg,
last10_avg: result.last10_avg,
vs_opp_avg: result.vs_opp_avg,
form: result.form,
usage: result.usage,
matchup_grade: result.matchup_grade,
rest: result.rest,
archetype: result.archetype,
archetype_blend: result.archetype_blend,
prop_dna: result.prop_dna,
}) as GradeResultData}
onAddToParlay={() => {
addLeg({
+10 -10
View File
@@ -1,33 +1,33 @@
const FEATURES = [
{
icon: '◆',
title: 'Multi-dimensional player archetypes',
body: 'Players aren\'t one thing. Our model scores every dimension — pitcher discipline, batter approach, NBA usage shape — and blends them per matchup.',
title: 'Player DNA archetypes',
body: 'Every player has a prop fingerprint. We classify it, show you which props are reliable vs volatile, and grade accordingly.',
},
{
icon: '↻',
title: 'Auto-calibrating engine',
body: 'Every resolved grade trains the next one. Point-biserial weight tuning, per-stat calibration, blind-spot detection. The model improves itself.',
title: 'Self-improving model',
body: 'Every resolved grade makes the next one sharper. The engine learns what works and corrects what doesn\'t.',
},
{
icon: '⚡',
title: 'Beat reporter intelligence',
body: 'Lineup intel from the people closest to the team — 30 minutes before tip. Trust-tiered, redistribution-aware, line-correlated.',
title: 'Lineup intel before tip-off',
body: 'Real-time lineup and injury data from trusted sources, giving you the edge before the books adjust.',
},
{
icon: '⊘',
title: 'Kill conditions',
body: 'We don\'t just grade the prop. We tell you what kills it. Six hard checks per read: minutes, sample, fatigue, blowout risk, splits, line conflict.',
body: 'Six hard checks on every prop. If minutes, fatigue, blowout risk, or splits say no, we flag it — even on an A grade.',
},
{
icon: '∿',
title: 'Parlay correlation math',
body: 'Phi-coefficient analysis catches the legs that secretly fight each other. The books love correlated unders. We surface them.',
body: 'We catch the legs that secretly fight each other. The books love correlated unders — we surface them before you tap.',
},
{
icon: '⌧',
title: 'ABS intelligence (MLB)',
body: 'The automated strike zone changes everything. Per-pitcher, per-batter discipline scoring. Zone 14 framing loss. Challenge math.',
title: 'Deep pitcher-batter matchups',
body: 'We score discipline, contact quality, and zone tendencies for every MLB matchup. Not just ERA.',
},
{
icon: '◯',
+6 -2
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import GameCard, { SlateSport, GameLines, GameStreak } from '@/components/GameCard';
import { PropRowProp, PropRowResult, propRowKey, Tier } from '@/components/PropRow';
import { isRelevantGame } from '@/lib/slateAdapter';
import { useAuth } from '@/contexts/AuthContext';
// Session 23 — all-day intelligence layer. The stat filter is the
// navigation system; streaks + hot lists layer ON TOP of the odds the
@@ -507,9 +508,12 @@ export default function Slate({ initialTab = 'all', tier = 'free' }: SlateProps)
// Filter pipeline — searchQuery applied to games + props.
const filteredGames = useMemo(() => {
if (!searchQuery.trim()) return games;
// Session 44 — drop completed games >24h old so a 5-day-old FINAL never
// lingers on the dashboard. Upcoming + live always show.
const fresh = games.filter((g) => isRelevantGame(g));
if (!searchQuery.trim()) return fresh;
const q = searchQuery.toLowerCase();
return games
return fresh
.map((g) => {
const homeMatch = g.homeTeam.toLowerCase().includes(q);
const awayMatch = g.awayTeam.toLowerCase().includes(q);
+1 -1
View File
@@ -1,7 +1,7 @@
import { badgeStyle, glyphSvg } from '@/lib/archetypes';
interface ArchetypeBadgeProps {
archetype: string; // archetype name, e.g. "POWER PULL" (case-insensitive)
archetype: string; // VYNDR archetype name, e.g. "BOMBER" (case-insensitive; legacy names resolve too)
sport?: string; // nba/mlb/wnba/soccer — informational; styling is per-archetype
variant?: 'full' | 'ghost' | 'tint';
size?: 'sm' | 'md';
+58 -44
View File
@@ -33,60 +33,71 @@ const GLYPHS = {
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. */
/* name { color, glyph, desc, legacy } VYNDR Originals (Session 44).
Keys/colors/glyphs MUST match src/services/archetypeService.js ARCHETYPES.
`legacy` is the old descriptive label, kept for reference (never displayed). */
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' },
TORCH: { c: '#FF6B4A', d: 'High usage, shot-dependent scorer', g: 'triangle', legacy: 'VOLUME SCORER' },
CONDUCTOR: { c: '#4A9EFF', d: 'Assist-heavy playmaker', g: 'node', legacy: 'FLOOR GENERAL' },
FORTRESS: { c: '#A78BFA', d: 'Defense, rebounds, blocks', g: 'shield', legacy: 'TWO-WAY ANCHOR' },
ARTILLERY: { c: '#2DD4BF', d: 'Floor-spacing shooting big', g: 'target', legacy: 'STRETCH BIG' },
SURGE: { c: '#FFB347', d: 'Usage spikes when stars sit', g: 'uparrow', legacy: 'USAGE SPONGE' },
'DUAL THREAT': { c: '#00D4A0', d: 'Scoring + playmaking hybrid', g: 'twin', legacy: 'COMBO GUARD' },
CONNECTOR: { c: '#9499A8', d: 'Low-usage specialist', g: 'chain', legacy: 'ROLE GLUE' },
FASTBREAK: { c: '#22D3EE', d: 'Pace-pushing fast-break threat', g: 'chevrons', legacy: 'TRANSITION ENGINE' },
'PAINT BOSS': { c: '#FF5C5C', d: 'Back-to-basket interior scorer', g: 'postup', legacy: 'POST SCORER' },
LOCKDOWN: { c: '#6366F1', d: 'Perimeter stopper, low usage', g: 'shieldCheck', legacy: 'DEFENSIVE SPECIALIST' },
SWITCHBOARD: { c: '#38BDF8', d: 'Oversized primary creator', g: 'half', legacy: 'POINT FORWARD' },
ARCHITECT: { c: '#FB923C', d: 'Self-created shot maker, rim-attacking', g: 'slash', legacy: 'SLASHER' },
PISTON: { c: '#F472B6', d: 'Lob and putback finisher', g: 'arc', legacy: 'RIM RUNNER' },
SENTINEL: { c: '#818CF8', d: 'Catch-and-shoot plus defense', g: 'crosshair', legacy: '3-AND-D' },
IGNITER: { c: '#FACC15', d: 'Bench scoring spark', g: 'bolt', legacy: 'SIXTH MAN' },
// 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' },
DISTRIBUTOR: { c: '#C084FC', d: 'Playmaking hub from the post', g: 'node', legacy: 'POST FACILITATOR' },
SHIELD: { c: '#A78BFA', d: 'Two-way perimeter forward', g: 'shieldCheck', legacy: 'TWO-WAY WING' },
RANGE: { c: '#2DD4BF', d: 'Floor-spacing forward', g: 'target', legacy: 'STRETCH FORWARD' },
SPARK: { c: '#FB923C', d: 'Downhill scoring guard', g: 'slash', legacy: 'SLASHING GUARD' },
ANCHOR: { c: '#6366F1', d: 'Dominant paint defender and rebounder', g: 'shield', legacy: 'INTERIOR ANCHOR' },
// 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' },
BOMBER: { c: '#FF6B4A', d: 'Middle-of-the-order power producer', g: 'triangle', legacy: 'POWER SLUGGER' },
BRUSH: { c: '#3DDC84', d: 'High average, low strikeout', g: 'crosshair', legacy: 'CONTACT' },
DRIVER: { c: '#4A9EFF', d: 'RBI-dependent, lineup context', g: 'diamond', legacy: 'RUN PRODUCER' },
ALPHA: { c: '#A78BFA', d: 'High K/9, low WHIP, deep games', g: 'star', legacy: 'ACE' },
WHIFF: { c: '#FFB347', d: 'Bat-missing arm, high K with traffic', g: 'bolt', legacy: 'STRIKEOUT ARTIST' },
GHOST: { c: '#2DD4BF', d: 'Stolen bases, speed score', g: 'chevrons', legacy: 'SPEED THREAT' },
HYBRID: { c: '#F472B6', d: 'Bats and pitches at elite level', g: 'half', legacy: 'TWO-WAY PLAYER' },
FLEX: { c: '#22D3EE', d: 'Multi-position lineup flex', g: 'plus', legacy: 'UTILITY PLAYER' },
WORKHORSE: { c: '#818CF8', d: 'Durable, deep-start arm', g: 'clock', legacy: 'INNINGS EATER' },
CATALYST: { c: '#38BDF8', d: 'On-base leadoff catalyst', g: 'diamondLine', legacy: 'TABLE SETTER' },
MIRROR: { c: '#34D399', d: 'Gap-to-gap line-drive bat', g: 'uparrow', legacy: 'GAP HITTER' },
HAMMER: { c: '#FB7185', d: 'Ninth-inning save specialist', g: 'lock', legacy: 'CLOSER' },
SINKER: { c: '#FBBF24', d: 'Groundball spot-starter / long relief', g: 'swap', legacy: 'SWINGMAN' },
BRIDGE: { c: '#6366F1', d: 'Setup / high-leverage middle relief', g: 'shieldCheck', legacy: 'BULLPEN ARM' },
SWITCH: { c: '#FF5C5C', d: 'Platoon-leveraged, glove-first bat', g: 'batball', legacy: 'DEFENSIVE WIZARD' },
// 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' },
FINISHER: { c: '#FF5C5C', d: 'Penalty-box finisher', g: 'crosshair', legacy: 'POACHER' },
MAESTRO: { c: '#4A9EFF', d: 'Chance-creating playmaker', g: 'node', legacy: 'CREATOR' },
TOWER: { c: '#FF6B4A', d: 'Hold-up aerial striker', g: 'triangle', legacy: 'TARGET MAN' },
MOTOR: { c: '#00D4A0', d: 'All-action central midfielder', g: 'chevrons', legacy: 'BOX-TO-BOX' },
BLADE: { c: '#2DD4BF', d: 'Dribbling wide threat', g: 'slash', legacy: 'WING WIZARD' },
WALL: { c: '#A78BFA', d: 'Distributing goalkeeper', g: 'shield', legacy: 'SWEEPER KEEPER' },
};
const FALLBACK = { c: '#9499A8', d: '', g: '' };
// Reverse index so an old legacy name (e.g. "POWER SLUGGER") still resolves to
// its VYNDR Original (BOMBER) — defends any stale cached data post-rename.
const LEGACY_INDEX = {};
for (const [k, v] of Object.entries(ARCHETYPE_MAP)) {
if (v.legacy) LEGACY_INDEX[v.legacy.toUpperCase()] = k;
}
function archetypeInfo(name) {
const key = (name == null ? '' : String(name)).toUpperCase();
return ARCHETYPE_MAP[key] || FALLBACK;
if (ARCHETYPE_MAP[key]) return ARCHETYPE_MAP[key];
if (LEGACY_INDEX[key]) return ARCHETYPE_MAP[LEGACY_INDEX[key]];
return FALLBACK;
}
function archetypeColor(name) {
@@ -115,8 +126,11 @@ function badgeStyle(name, variant = 'tint', size = 'sm') {
} else {
textColor = info.c; bg = info.c + '1F'; borderColor = info.c + '52'; glyphColor = info.c;
}
// Display the canonical VYNDR name even if a legacy name was passed.
const upper = (name == null ? '' : String(name)).toUpperCase();
const canonical = ARCHETYPE_MAP[upper] ? upper : LEGACY_INDEX[upper] || upper;
return {
name: (name == null ? '' : String(name)).toUpperCase(),
name: canonical,
desc: info.d,
glyph: info.g,
color: info.c,
+18
View File
@@ -175,6 +175,23 @@ function mapPitchers(game) {
};
}
/**
* Should this game still show on the slate (Session 44)? Upcoming + live games
* always show; a COMPLETED game is dropped once it's more than 24h old, so a
* 5-day-old FINAL never lingers on the dashboard. Unknown/missing date keep
* (degrade open). `now` is injectable for tests.
*/
function isRelevantGame(game, now = Date.now()) {
if (!game) return false;
const state = String(game.state || game.status || '').toLowerCase();
const isFinal = state === 'final' || state === 'post' || state === 'closed' || state === 'complete';
if (!isFinal) return true;
const raw = game.date || game.gameTime || game.commence_time || game.startTime;
const t = raw ? new Date(raw).getTime() : NaN;
if (Number.isNaN(t)) return true; // no parseable date → don't hide
return (now - t) / 3_600_000 < 24;
}
module.exports = {
parseAmericanOdds,
detectBestLines,
@@ -183,4 +200,5 @@ module.exports = {
formatGameTime,
groupPropsByPlayer,
mapPitchers,
isRelevantGame,
};