Add pitcher support to custom cards CLI

- Add pitcher detection via player_type field or ratings schema
- Separate calc_ops and verify_total for batters vs pitchers
- Pitcher template with correct schema (double_cf, xcheck fields, etc.)
- Combined OPS formula: max() for pitchers, min() for batters
- Add --type option to 'pd-cards custom new' command
- Migrate Tony Smehrik to YAML pitcher profile

Pitcher schema differences from batters:
- double_cf instead of double_pull
- flyout_cf_b instead of flyout_a/flyout_bq
- No groundout_c
- xcheck_* fields (29 chances for fielder plays)
- pitching block for starter/relief/closer ratings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-12-18 16:17:17 -06:00
parent 2e28d29ced
commit 8ac89cfcd8
2 changed files with 348 additions and 49 deletions

View File

@ -2,11 +2,12 @@
Custom character card management. Custom character card management.
Commands for creating and managing fictional player cards using YAML profiles. Commands for creating and managing fictional player cards using YAML profiles.
Supports both batters and pitchers with their respective schemas.
""" """
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Literal
import typer import typer
import yaml import yaml
@ -19,6 +20,20 @@ console = Console()
# Profile directory relative to this file # Profile directory relative to this file
PROFILES_DIR = Path(__file__).parent.parent / "custom" / "profiles" PROFILES_DIR = Path(__file__).parent.parent / "custom" / "profiles"
# Default x-check values for pitchers (from calcs_pitcher.py)
DEFAULT_XCHECKS = {
'xcheck_p': 1.0,
'xcheck_c': 3.0,
'xcheck_1b': 2.0,
'xcheck_2b': 6.0,
'xcheck_3b': 3.0,
'xcheck_ss': 7.0,
'xcheck_lf': 2.0,
'xcheck_cf': 3.0,
'xcheck_rf': 2.0,
}
TOTAL_XCHECK = sum(DEFAULT_XCHECKS.values()) # 29.0
def get_profile_path(name: str) -> Path: def get_profile_path(name: str) -> Path:
"""Get the path to a character profile YAML file.""" """Get the path to a character profile YAML file."""
@ -38,8 +53,31 @@ def load_profile(name: str) -> dict:
return yaml.safe_load(f) return yaml.safe_load(f)
def calc_ops(ratings: dict) -> tuple[float, float, float]: def detect_player_type(profile: dict) -> Literal['batter', 'pitcher']:
"""Calculate AVG, OBP, SLG, OPS from ratings dict.""" """Detect player type from profile structure."""
# Explicit type field takes precedence
if 'player_type' in profile:
return profile['player_type']
# Check for pitcher-specific fields
if 'pitching' in profile:
return 'pitcher'
if 'pitching_card_id' in profile:
return 'pitcher'
# Check ratings structure for pitcher-specific fields
if 'ratings' in profile:
sample_rating = profile['ratings'].get('vs_L') or profile['ratings'].get('vs_R')
if sample_rating:
# Pitchers have xcheck fields and double_cf instead of double_pull
if 'xcheck_p' in sample_rating or 'double_cf' in sample_rating:
return 'pitcher'
return 'batter'
def calc_batter_ops(ratings: dict) -> tuple[float, float, float]:
"""Calculate AVG, OBP, SLG from batter ratings dict."""
avg = ( avg = (
ratings['homerun'] + ratings['bp_homerun'] / 2 + ratings['triple'] + ratings['homerun'] + ratings['bp_homerun'] / 2 + ratings['triple'] +
ratings['double_three'] + ratings['double_two'] + ratings['double_pull'] + ratings['double_three'] + ratings['double_two'] + ratings['double_pull'] +
@ -56,8 +94,27 @@ def calc_ops(ratings: dict) -> tuple[float, float, float]:
return avg, obp, slg return avg, obp, slg
def verify_total(ratings: dict) -> float: def calc_pitcher_ops(ratings: dict) -> tuple[float, float, float]:
"""Verify ratings sum to 108.""" """Calculate AVG, OBP, SLG allowed from pitcher ratings dict."""
# Pitchers use double_cf instead of double_pull
avg = (
ratings['homerun'] + ratings['bp_homerun'] / 2 + ratings['triple'] +
ratings['double_three'] + ratings['double_two'] + ratings.get('double_cf', 0) +
ratings['single_two'] + ratings['single_one'] + ratings['single_center'] +
ratings['bp_single'] / 2
) / 108
obp = avg + (ratings['hbp'] + ratings['walk']) / 108
slg = (
ratings['homerun'] * 4 + ratings['bp_homerun'] * 2 + ratings['triple'] * 3 +
(ratings['double_three'] + ratings['double_two'] + ratings.get('double_cf', 0)) * 2 +
ratings['single_two'] + ratings['single_one'] + ratings['single_center'] +
ratings['bp_single'] / 2
) / 108
return avg, obp, slg
def verify_batter_total(ratings: dict) -> float:
"""Verify batter ratings sum to 108."""
return sum([ return sum([
ratings['homerun'], ratings['bp_homerun'], ratings['triple'], ratings['homerun'], ratings['bp_homerun'], ratings['triple'],
ratings['double_three'], ratings['double_two'], ratings['double_pull'], ratings['double_three'], ratings['double_two'], ratings['double_pull'],
@ -69,6 +126,27 @@ def verify_total(ratings: dict) -> float:
]) ])
def verify_pitcher_total(ratings: dict) -> float:
"""Verify pitcher ratings sum to 108."""
return sum([
# Hits
ratings['homerun'], ratings['bp_homerun'], ratings['triple'],
ratings['double_three'], ratings['double_two'], ratings.get('double_cf', 0),
ratings['single_two'], ratings['single_one'], ratings['single_center'], ratings['bp_single'],
# On-base
ratings['walk'], ratings['hbp'],
# Outs
ratings['strikeout'],
ratings['flyout_lf_b'], ratings.get('flyout_cf_b', 0), ratings['flyout_rf_b'],
ratings['groundout_a'], ratings['groundout_b'],
# X-checks (29 total)
ratings.get('xcheck_p', 1.0), ratings.get('xcheck_c', 3.0),
ratings.get('xcheck_1b', 2.0), ratings.get('xcheck_2b', 6.0),
ratings.get('xcheck_3b', 3.0), ratings.get('xcheck_ss', 7.0),
ratings.get('xcheck_lf', 2.0), ratings.get('xcheck_cf', 3.0), ratings.get('xcheck_rf', 2.0),
])
@app.command("list") @app.command("list")
def list_profiles(): def list_profiles():
"""List all available character profiles.""" """List all available character profiles."""
@ -85,6 +163,7 @@ def list_profiles():
table = Table(title="Custom Character Profiles") table = Table(title="Custom Character Profiles")
table.add_column("Name", style="cyan") table.add_column("Name", style="cyan")
table.add_column("Type", style="magenta")
table.add_column("Target OPS", justify="right") table.add_column("Target OPS", justify="right")
table.add_column("Positions") table.add_column("Positions")
table.add_column("Player ID", justify="right") table.add_column("Player ID", justify="right")
@ -94,12 +173,13 @@ def list_profiles():
with open(profile_path) as f: with open(profile_path) as f:
profile = yaml.safe_load(f) profile = yaml.safe_load(f)
name = profile.get('name', profile_path.stem) name = profile.get('name', profile_path.stem)
player_type = detect_player_type(profile)
target_ops = profile.get('target_ops', '-') target_ops = profile.get('target_ops', '-')
positions = ', '.join(profile.get('positions', {}).keys()) if isinstance(profile.get('positions'), dict) else '-' positions = ', '.join(profile.get('positions', {}).keys()) if isinstance(profile.get('positions'), dict) else '-'
player_id = str(profile.get('player_id', '-')) player_id = str(profile.get('player_id', '-'))
table.add_row(name, str(target_ops), positions, player_id) table.add_row(name, player_type, str(target_ops), positions, player_id)
except Exception as e: except Exception as e:
table.add_row(profile_path.stem, f"[red]Error: {e}[/red]", "", "") table.add_row(profile_path.stem, "?", f"[red]Error: {e}[/red]", "", "")
console.print(table) console.print(table)
@ -110,29 +190,50 @@ def preview(
): ):
"""Preview a custom character's calculated ratings.""" """Preview a custom character's calculated ratings."""
profile = load_profile(character) profile = load_profile(character)
player_type = detect_player_type(profile)
console.print() console.print()
console.print("=" * 70) console.print("=" * 70)
console.print(f"[bold]{profile['name']}[/bold] - CUSTOM PLAYER PREVIEW") type_label = "PITCHER" if player_type == 'pitcher' else "BATTER"
console.print(f"[bold]{profile['name']}[/bold] - CUSTOM {type_label} PREVIEW")
console.print("=" * 70) console.print("=" * 70)
console.print() console.print()
console.print(f"Hand: {profile.get('hand', 'R')}") console.print(f"Type: {player_type.capitalize()}")
hand_label = "Throws" if player_type == 'pitcher' else "Bats"
console.print(f"{hand_label}: {profile.get('hand', 'R')}")
console.print(f"Target OPS: {profile.get('target_ops', 'N/A')}") console.print(f"Target OPS: {profile.get('target_ops', 'N/A')}")
console.print(f"Player ID: {profile.get('player_id', 'Not created')}") console.print(f"Player ID: {profile.get('player_id', 'Not created')}")
console.print(f"Cardset ID: {profile.get('cardset_id', 'N/A')}") console.print(f"Cardset ID: {profile.get('cardset_id', 'N/A')}")
console.print()
# Display pitcher-specific info
if player_type == 'pitcher' and 'pitching' in profile:
pit = profile['pitching']
console.print()
console.print("[bold]Pitching Roles:[/bold]")
console.print(f" Starter Rating: {pit.get('starter_rating', '-')}")
console.print(f" Relief Rating: {pit.get('relief_rating', '-')}")
console.print(f" Closer Rating: {pit.get('closer_rating', '-')}")
# Display positions # Display positions
if 'positions' in profile: if 'positions' in profile:
console.print()
console.print("[bold]Defensive Positions:[/bold]") console.print("[bold]Defensive Positions:[/bold]")
for pos, stats in profile['positions'].items(): for pos, stats in profile['positions'].items():
console.print(f" {pos}: Range {stats.get('range', '-')} / Error {stats.get('error', '-')} / Arm {stats.get('arm', '-')}") if isinstance(stats, dict):
console.print() console.print(f" {pos}: Range {stats.get('range', '-')} / Error {stats.get('error', '-')} / Arm {stats.get('arm', '-')}")
else:
console.print(f" {pos}")
# Display ratings # Display ratings
if 'ratings' in profile: if 'ratings' in profile:
console.print("[bold]Batting Ratings:[/bold]") console.print()
rating_label = "Pitching Ratings (OPS Allowed)" if player_type == 'pitcher' else "Batting Ratings"
console.print(f"[bold]{rating_label}:[/bold]")
calc_ops = calc_pitcher_ops if player_type == 'pitcher' else calc_batter_ops
verify_total = verify_pitcher_total if player_type == 'pitcher' else verify_batter_total
for vs_hand in ['vs_L', 'vs_R']: for vs_hand in ['vs_L', 'vs_R']:
if vs_hand in profile['ratings']: if vs_hand in profile['ratings']:
ratings = profile['ratings'][vs_hand] ratings = profile['ratings'][vs_hand]
@ -140,7 +241,9 @@ def preview(
ops = obp + slg ops = obp + slg
total = verify_total(ratings) total = verify_total(ratings)
console.print(f"\n VS {vs_hand[-1]}HP:") # For pitchers, vs_L means "vs left-handed batters"
hand_suffix = "HB" if player_type == 'pitcher' else "HP"
console.print(f"\n VS {vs_hand[-1]}{hand_suffix}:")
console.print(f" AVG: {avg:.3f} OBP: {obp:.3f} SLG: {slg:.3f} OPS: {ops:.3f}") console.print(f" AVG: {avg:.3f} OBP: {obp:.3f} SLG: {slg:.3f} OPS: {ops:.3f}")
console.print(f" Total Chances: {total:.2f} (must be 108.0)") console.print(f" Total Chances: {total:.2f} (must be 108.0)")
@ -149,17 +252,21 @@ def preview(
# Calculate combined OPS # Calculate combined OPS
if 'vs_L' in profile['ratings'] and 'vs_R' in profile['ratings']: if 'vs_L' in profile['ratings'] and 'vs_R' in profile['ratings']:
_, _, slg_l = calc_ops(profile['ratings']['vs_L']) _, obp_l, slg_l = calc_ops(profile['ratings']['vs_L'])
_, obp_l, _ = calc_ops(profile['ratings']['vs_L']) _, obp_r, slg_r = calc_ops(profile['ratings']['vs_R'])
_, _, slg_r = calc_ops(profile['ratings']['vs_R'])
_, obp_r, _ = calc_ops(profile['ratings']['vs_R'])
ops_l = obp_l + slg_l ops_l = obp_l + slg_l
ops_r = obp_r + slg_r ops_r = obp_r + slg_r
total_ops = (ops_l + ops_r + min(ops_l, ops_r)) / 3
# Pitchers use max(), batters use min()
if player_type == 'pitcher':
total_ops = (ops_l + ops_r + max(ops_l, ops_r)) / 3
else:
total_ops = (ops_l + ops_r + min(ops_l, ops_r)) / 3
console.print(f"\n [bold]Combined OPS: {total_ops:.3f}[/bold]") console.print(f"\n [bold]Combined OPS: {total_ops:.3f}[/bold]")
# Display baserunning # Display baserunning (batters only)
if 'baserunning' in profile: if player_type == 'batter' and 'baserunning' in profile:
br = profile['baserunning'] br = profile['baserunning']
console.print("\n[bold]Baserunning:[/bold]") console.print("\n[bold]Baserunning:[/bold]")
console.print(f" Steal Range: {br.get('steal_low', '-')}-{br.get('steal_high', '-')}") console.print(f" Steal Range: {br.get('steal_low', '-')}-{br.get('steal_high', '-')}")
@ -178,10 +285,11 @@ def submit(
): ):
"""Submit a custom character to the database.""" """Submit a custom character to the database."""
profile = load_profile(character) profile = load_profile(character)
player_type = detect_player_type(profile)
console.print() console.print()
console.print("=" * 70) console.print("=" * 70)
console.print(f"[bold]SUBMITTING {profile['name']}[/bold]") console.print(f"[bold]SUBMITTING {profile['name']} ({player_type})[/bold]")
console.print("=" * 70) console.print("=" * 70)
if dry_run: if dry_run:
@ -189,6 +297,8 @@ def submit(
console.print() console.print()
# Verify ratings # Verify ratings
verify_total = verify_pitcher_total if player_type == 'pitcher' else verify_batter_total
if 'ratings' in profile: if 'ratings' in profile:
for vs_hand in ['vs_L', 'vs_R']: for vs_hand in ['vs_L', 'vs_R']:
if vs_hand in profile['ratings']: if vs_hand in profile['ratings']:
@ -212,10 +322,19 @@ def submit(
async def do_submit(): async def do_submit():
player_id = profile.get('player_id') player_id = profile.get('player_id')
batting_card_id = profile.get('batting_card_id')
if not player_id or not batting_card_id: if player_type == 'pitcher':
console.print("[red]ERROR: Profile missing player_id or batting_card_id[/red]") card_id = profile.get('pitching_card_id')
card_id_field = 'pitchingcard_id'
ratings_endpoint = 'pitchingcardratings'
else:
card_id = profile.get('batting_card_id')
card_id_field = 'battingcard_id'
ratings_endpoint = 'battingcardratings'
if not player_id or not card_id:
id_field = 'pitching_card_id' if player_type == 'pitcher' else 'batting_card_id'
console.print(f"[red]ERROR: Profile missing player_id or {id_field}[/red]")
console.print("Create the player first, then update the profile with IDs.") console.print("Create the player first, then update the profile with IDs.")
raise typer.Exit(1) raise typer.Exit(1)
@ -226,13 +345,13 @@ def submit(
for vs_hand, ratings in profile['ratings'].items(): for vs_hand, ratings in profile['ratings'].items():
hand = vs_hand[-1] # 'L' or 'R' hand = vs_hand[-1] # 'L' or 'R'
rating_data = { rating_data = {
'battingcard_id': batting_card_id, card_id_field: card_id,
'vs_hand': hand, 'vs_hand': hand,
**ratings **ratings
} }
ratings_list.append(rating_data) ratings_list.append(rating_data)
await db_put('battingcardratings', payload={'ratings': ratings_list}, timeout=10) await db_put(ratings_endpoint, payload={'ratings': ratings_list}, timeout=10)
console.print("[green] Ratings updated[/green]") console.print("[green] Ratings updated[/green]")
# Update positions # Update positions
@ -240,11 +359,17 @@ def submit(
console.print("Updating positions...") console.print("Updating positions...")
positions_list = [] positions_list = []
for pos, stats in profile['positions'].items(): for pos, stats in profile['positions'].items():
positions_list.append({ if isinstance(stats, dict):
'player_id': player_id, positions_list.append({
'position': pos, 'player_id': player_id,
**stats 'position': pos,
}) **stats
})
else:
positions_list.append({
'player_id': player_id,
'position': pos,
})
await db_put('cardpositions', payload={'positions': positions_list}, timeout=10) await db_put('cardpositions', payload={'positions': positions_list}, timeout=10)
console.print("[green] Positions updated[/green]") console.print("[green] Positions updated[/green]")
@ -263,8 +388,9 @@ def submit(
@app.command() @app.command()
def new( def new(
name: str = typer.Option(..., "--name", "-n", help="Character name"), name: str = typer.Option(..., "--name", "-n", help="Character name"),
hand: str = typer.Option("R", "--hand", "-h", help="Batting hand (L/R/S)"), player_type: str = typer.Option("batter", "--type", "-t", help="Player type (batter/pitcher)"),
target_ops: float = typer.Option(0.750, "--target-ops", help="Target OPS"), hand: str = typer.Option("R", "--hand", "-h", help="Batting/throwing hand (L/R, or S for switch-hitter)"),
target_ops: float = typer.Option(0.750, "--target-ops", help="Target OPS (or OPS allowed for pitchers)"),
): ):
"""Create a new character profile template.""" """Create a new character profile template."""
profile_path = get_profile_path(name) profile_path = get_profile_path(name)
@ -273,14 +399,41 @@ def new(
console.print(f"[red]Profile already exists:[/red] {profile_path}") console.print(f"[red]Profile already exists:[/red] {profile_path}")
raise typer.Exit(1) raise typer.Exit(1)
# Create template player_type = player_type.lower()
template = { if player_type not in ('batter', 'pitcher'):
console.print(f"[red]Invalid player type:[/red] {player_type}")
console.print("Must be 'batter' or 'pitcher'")
raise typer.Exit(1)
if player_type == 'pitcher':
template = create_pitcher_template(name, hand, target_ops)
else:
template = create_batter_template(name, hand, target_ops)
# Ensure directory exists
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
with open(profile_path, 'w') as f:
yaml.dump(template, f, default_flow_style=False, sort_keys=False)
console.print(f"[green]Created {player_type} profile:[/green] {profile_path}")
console.print()
console.print("Next steps:")
console.print(f" 1. Edit the profile: {profile_path}")
console.print(f" 2. Preview: pd-cards custom preview {name.lower().replace(' ', '_')}")
console.print(f" 3. Submit: pd-cards custom submit {name.lower().replace(' ', '_')}")
def create_batter_template(name: str, hand: str, target_ops: float) -> dict:
"""Create a batter profile template."""
return {
'name': name, 'name': name,
'player_type': 'batter',
'hand': hand.upper(), 'hand': hand.upper(),
'target_ops': target_ops, 'target_ops': target_ops,
'cardset_id': None, # Set after creation 'cardset_id': None,
'player_id': None, # Set after creation 'player_id': None,
'batting_card_id': None, # Set after creation 'batting_card_id': None,
'positions': { 'positions': {
'RF': {'range': 3, 'error': 7, 'arm': 0}, 'RF': {'range': 3, 'error': 7, 'arm': 0},
@ -320,15 +473,62 @@ def new(
}, },
} }
# Ensure directory exists
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
with open(profile_path, 'w') as f: def create_pitcher_template(name: str, hand: str, target_ops: float) -> dict:
yaml.dump(template, f, default_flow_style=False, sort_keys=False) """Create a pitcher profile template."""
return {
'name': name,
'player_type': 'pitcher',
'hand': hand.upper(),
'target_ops': target_ops, # OPS allowed
'cardset_id': None,
'player_id': None,
'pitching_card_id': None,
console.print(f"[green]Created profile:[/green] {profile_path}") 'positions': {
console.print() 'P': None, # Pitchers just have P position, no defensive ratings
console.print("Next steps:") },
console.print(f" 1. Edit the profile: {profile_path}")
console.print(f" 2. Preview: pd-cards custom preview {name.lower().replace(' ', '_')}") 'pitching': {
console.print(f" 3. Submit: pd-cards custom submit {name.lower().replace(' ', '_')}") 'starter_rating': 5,
'relief_rating': 5,
'closer_rating': None,
},
'ratings': {
# vs_L = performance vs left-handed batters (total = 108)
'vs_L': {
# Hits allowed (23.5)
'homerun': 1.0, 'bp_homerun': 1.0, 'triple': 0.5,
'double_three': 0.0, 'double_two': 3.0, 'double_cf': 2.0,
'single_two': 4.0, 'single_one': 3.0, 'single_center': 4.0, 'bp_single': 5.0,
# On-base allowed (9.0)
'walk': 8.0, 'hbp': 1.0,
# Outs (46.5)
'strikeout': 20.0,
'flyout_lf_b': 4.0, 'flyout_cf_b': 5.0, 'flyout_rf_b': 4.0,
'groundout_a': 6.0, 'groundout_b': 7.5,
# X-checks (fielder plays - 29 total)
'xcheck_p': 1.0, 'xcheck_c': 3.0, 'xcheck_1b': 2.0,
'xcheck_2b': 6.0, 'xcheck_3b': 3.0, 'xcheck_ss': 7.0,
'xcheck_lf': 2.0, 'xcheck_cf': 3.0, 'xcheck_rf': 2.0,
},
# vs_R = performance vs right-handed batters (total = 108)
'vs_R': {
# Hits allowed (27.0)
'homerun': 1.5, 'bp_homerun': 1.5, 'triple': 0.5,
'double_three': 0.0, 'double_two': 4.0, 'double_cf': 2.0,
'single_two': 4.5, 'single_one': 3.5, 'single_center': 4.5, 'bp_single': 5.0,
# On-base allowed (10.0)
'walk': 9.0, 'hbp': 1.0,
# Outs (42.0)
'strikeout': 15.0,
'flyout_lf_b': 4.0, 'flyout_cf_b': 5.0, 'flyout_rf_b': 4.0,
'groundout_a': 6.0, 'groundout_b': 8.0,
# X-checks (29 total)
'xcheck_p': 1.0, 'xcheck_c': 3.0, 'xcheck_1b': 2.0,
'xcheck_2b': 6.0, 'xcheck_3b': 3.0, 'xcheck_ss': 7.0,
'xcheck_lf': 2.0, 'xcheck_cf': 3.0, 'xcheck_rf': 2.0,
},
},
}

View File

@ -0,0 +1,99 @@
# Tony Smehrik - Custom Pitcher
# Left-handed SP/RP with same-side dominance
#
# Target Combined OPS: 0.585
# - OPS vs L: ~0.465 (dominant same-side)
# - OPS vs R: ~0.645 (weaker opposite-side)
# K-rate vs L: ~20% (average)
# K-rate vs R: ~8% (very low)
# FB% vs L: ~35% (average)
# FB% vs R: ~45% (high)
name: Tony Smehrik
player_type: pitcher
hand: L
target_ops: 0.585 # OPS allowed (combined)
cardset_id: 29
player_id: null # Set after creation
pitching_card_id: null # Set after creation
positions:
P: null
pitching:
starter_rating: 5
relief_rating: 5
closer_rating: null
ratings:
# vs_L = performance vs left-handed batters (same-side dominant)
# Target: .185/.240/.225 OPS ~0.465
# Total must = 108
vs_L:
# Hits allowed (~15 total hits for .185 AVG area)
homerun: 0.5
bp_homerun: 0.5
triple: 0.25
double_three: 0.0
double_two: 3.0
double_cf: 1.0
single_two: 3.0
single_one: 2.5
single_center: 3.0
bp_single: 5.0
# On-base allowed (~6 for OBP gap)
walk: 5.0
hbp: 1.0
# Outs - high K rate vs same side (~22 Ks)
strikeout: 22.0
flyout_lf_b: 4.5
flyout_cf_b: 6.0
flyout_rf_b: 4.0
groundout_a: 7.75
groundout_b: 10.0
# X-checks (29 total)
xcheck_p: 1.0
xcheck_c: 3.0
xcheck_1b: 2.0
xcheck_2b: 6.0
xcheck_3b: 3.0
xcheck_ss: 7.0
xcheck_lf: 2.0
xcheck_cf: 3.0
xcheck_rf: 2.0
# vs_R = performance vs right-handed batters (opposite-side weaker)
# Target: .245/.305/.340 OPS ~0.645
# Total must = 108
vs_R:
# Hits allowed (~22 total hits for .245 AVG area)
homerun: 1.5
bp_homerun: 1.5
triple: 0.5
double_three: 0.0
double_two: 4.5
double_cf: 1.5
single_two: 4.0
single_one: 3.5
single_center: 4.0
bp_single: 5.0
# On-base allowed (~7 for OBP gap)
walk: 6.0
hbp: 1.0
# Outs - very low K rate vs opposite side (~9 Ks)
strikeout: 9.0
flyout_lf_b: 5.0
flyout_cf_b: 8.0
flyout_rf_b: 5.0
groundout_a: 7.0
groundout_b: 12.0
# X-checks (29 total)
xcheck_p: 1.0
xcheck_c: 3.0
xcheck_1b: 2.0
xcheck_2b: 6.0
xcheck_3b: 3.0
xcheck_ss: 7.0
xcheck_lf: 2.0
xcheck_cf: 3.0
xcheck_rf: 2.0