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.
Commands for creating and managing fictional player cards using YAML profiles.
Supports both batters and pitchers with their respective schemas.
"""
import asyncio
from pathlib import Path
from typing import Optional
from typing import Optional, Literal
import typer
import yaml
@ -19,6 +20,20 @@ console = Console()
# Profile directory relative to this file
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:
"""Get the path to a character profile YAML file."""
@ -38,8 +53,31 @@ def load_profile(name: str) -> dict:
return yaml.safe_load(f)
def calc_ops(ratings: dict) -> tuple[float, float, float]:
"""Calculate AVG, OBP, SLG, OPS from ratings dict."""
def detect_player_type(profile: dict) -> Literal['batter', 'pitcher']:
"""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 = (
ratings['homerun'] + ratings['bp_homerun'] / 2 + ratings['triple'] +
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
def verify_total(ratings: dict) -> float:
"""Verify ratings sum to 108."""
def calc_pitcher_ops(ratings: dict) -> tuple[float, float, float]:
"""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([
ratings['homerun'], ratings['bp_homerun'], ratings['triple'],
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")
def list_profiles():
"""List all available character profiles."""
@ -85,6 +163,7 @@ def list_profiles():
table = Table(title="Custom Character Profiles")
table.add_column("Name", style="cyan")
table.add_column("Type", style="magenta")
table.add_column("Target OPS", justify="right")
table.add_column("Positions")
table.add_column("Player ID", justify="right")
@ -94,12 +173,13 @@ def list_profiles():
with open(profile_path) as f:
profile = yaml.safe_load(f)
name = profile.get('name', profile_path.stem)
player_type = detect_player_type(profile)
target_ops = profile.get('target_ops', '-')
positions = ', '.join(profile.get('positions', {}).keys()) if isinstance(profile.get('positions'), dict) else '-'
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:
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)
@ -110,29 +190,50 @@ def preview(
):
"""Preview a custom character's calculated ratings."""
profile = load_profile(character)
player_type = detect_player_type(profile)
console.print()
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()
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"Player ID: {profile.get('player_id', 'Not created')}")
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
if 'positions' in profile:
console.print()
console.print("[bold]Defensive Positions:[/bold]")
for pos, stats in profile['positions'].items():
console.print(f" {pos}: Range {stats.get('range', '-')} / Error {stats.get('error', '-')} / Arm {stats.get('arm', '-')}")
console.print()
if isinstance(stats, dict):
console.print(f" {pos}: Range {stats.get('range', '-')} / Error {stats.get('error', '-')} / Arm {stats.get('arm', '-')}")
else:
console.print(f" {pos}")
# Display ratings
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']:
if vs_hand in profile['ratings']:
ratings = profile['ratings'][vs_hand]
@ -140,7 +241,9 @@ def preview(
ops = obp + slg
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" Total Chances: {total:.2f} (must be 108.0)")
@ -149,17 +252,21 @@ def preview(
# Calculate combined OPS
if 'vs_L' in profile['ratings'] and 'vs_R' in profile['ratings']:
_, _, slg_l = calc_ops(profile['ratings']['vs_L'])
_, obp_l, _ = calc_ops(profile['ratings']['vs_L'])
_, _, slg_r = calc_ops(profile['ratings']['vs_R'])
_, obp_r, _ = calc_ops(profile['ratings']['vs_R'])
_, obp_l, slg_l = calc_ops(profile['ratings']['vs_L'])
_, obp_r, slg_r = calc_ops(profile['ratings']['vs_R'])
ops_l = obp_l + slg_l
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]")
# Display baserunning
if 'baserunning' in profile:
# Display baserunning (batters only)
if player_type == 'batter' and 'baserunning' in profile:
br = profile['baserunning']
console.print("\n[bold]Baserunning:[/bold]")
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."""
profile = load_profile(character)
player_type = detect_player_type(profile)
console.print()
console.print("=" * 70)
console.print(f"[bold]SUBMITTING {profile['name']}[/bold]")
console.print(f"[bold]SUBMITTING {profile['name']} ({player_type})[/bold]")
console.print("=" * 70)
if dry_run:
@ -189,6 +297,8 @@ def submit(
console.print()
# Verify ratings
verify_total = verify_pitcher_total if player_type == 'pitcher' else verify_batter_total
if 'ratings' in profile:
for vs_hand in ['vs_L', 'vs_R']:
if vs_hand in profile['ratings']:
@ -212,10 +322,19 @@ def submit(
async def do_submit():
player_id = profile.get('player_id')
batting_card_id = profile.get('batting_card_id')
if not player_id or not batting_card_id:
console.print("[red]ERROR: Profile missing player_id or batting_card_id[/red]")
if player_type == 'pitcher':
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.")
raise typer.Exit(1)
@ -226,13 +345,13 @@ def submit(
for vs_hand, ratings in profile['ratings'].items():
hand = vs_hand[-1] # 'L' or 'R'
rating_data = {
'battingcard_id': batting_card_id,
card_id_field: card_id,
'vs_hand': hand,
**ratings
}
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]")
# Update positions
@ -240,11 +359,17 @@ def submit(
console.print("Updating positions...")
positions_list = []
for pos, stats in profile['positions'].items():
positions_list.append({
'player_id': player_id,
'position': pos,
**stats
})
if isinstance(stats, dict):
positions_list.append({
'player_id': player_id,
'position': pos,
**stats
})
else:
positions_list.append({
'player_id': player_id,
'position': pos,
})
await db_put('cardpositions', payload={'positions': positions_list}, timeout=10)
console.print("[green] Positions updated[/green]")
@ -263,8 +388,9 @@ def submit(
@app.command()
def new(
name: str = typer.Option(..., "--name", "-n", help="Character name"),
hand: str = typer.Option("R", "--hand", "-h", help="Batting hand (L/R/S)"),
target_ops: float = typer.Option(0.750, "--target-ops", help="Target OPS"),
player_type: str = typer.Option("batter", "--type", "-t", help="Player type (batter/pitcher)"),
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."""
profile_path = get_profile_path(name)
@ -273,14 +399,41 @@ def new(
console.print(f"[red]Profile already exists:[/red] {profile_path}")
raise typer.Exit(1)
# Create template
template = {
player_type = player_type.lower()
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,
'player_type': 'batter',
'hand': hand.upper(),
'target_ops': target_ops,
'cardset_id': None, # Set after creation
'player_id': None, # Set after creation
'batting_card_id': None, # Set after creation
'cardset_id': None,
'player_id': None,
'batting_card_id': None,
'positions': {
'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:
yaml.dump(template, f, default_flow_style=False, sort_keys=False)
def create_pitcher_template(name: str, hand: str, target_ops: float) -> dict:
"""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}")
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(' ', '_')}")
'positions': {
'P': None, # Pitchers just have P position, no defensive ratings
},
'pitching': {
'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