paper-dynasty-card-creation/pd_cards/commands/custom.py
Cal Corum 8ac89cfcd8 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>
2025-12-18 16:17:17 -06:00

535 lines
21 KiB
Python

"""
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, Literal
import typer
import yaml
from rich.console import Console
from rich.table import Table
app = typer.Typer(no_args_is_help=True)
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."""
# Normalize name: lowercase, replace spaces with underscores
normalized = name.lower().replace(" ", "_").replace("-", "_")
return PROFILES_DIR / f"{normalized}.yaml"
def load_profile(name: str) -> dict:
"""Load a character profile from YAML."""
profile_path = get_profile_path(name)
if not profile_path.exists():
console.print(f"[red]Profile not found:[/red] {profile_path}")
raise typer.Exit(1)
with open(profile_path) as f:
return yaml.safe_load(f)
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'] +
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['double_pull']) * 2 +
ratings['single_two'] + ratings['single_one'] + ratings['single_center'] +
ratings['bp_single'] / 2
) / 108
return avg, obp, slg
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'],
ratings['single_two'], ratings['single_one'], ratings['single_center'], ratings['bp_single'],
ratings['walk'], ratings['hbp'], ratings['strikeout'],
ratings['lineout'], ratings['popout'],
ratings['flyout_a'], ratings['flyout_bq'], ratings['flyout_lf_b'], ratings['flyout_rf_b'],
ratings['groundout_a'], ratings['groundout_b'], ratings['groundout_c']
])
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."""
if not PROFILES_DIR.exists():
console.print("[yellow]No profiles directory found.[/yellow]")
console.print(f"Create profiles in: {PROFILES_DIR}")
return
profiles = list(PROFILES_DIR.glob("*.yaml"))
if not profiles:
console.print("[yellow]No character profiles found.[/yellow]")
console.print(f"Create YAML profiles in: {PROFILES_DIR}")
return
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")
for profile_path in sorted(profiles):
try:
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, player_type, str(target_ops), positions, player_id)
except Exception as e:
table.add_row(profile_path.stem, "?", f"[red]Error: {e}[/red]", "", "")
console.print(table)
@app.command()
def preview(
character: str = typer.Argument(..., help="Character profile name (e.g., kalin_young)"),
):
"""Preview a custom character's calculated ratings."""
profile = load_profile(character)
player_type = detect_player_type(profile)
console.print()
console.print("=" * 70)
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"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')}")
# 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():
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()
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]
avg, obp, slg = calc_ops(ratings)
ops = obp + slg
total = verify_total(ratings)
# 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)")
if abs(total - 108.0) > 0.01:
console.print(f" [red]WARNING: Total is not 108![/red]")
# Calculate combined OPS
if 'vs_L' in profile['ratings'] and 'vs_R' in profile['ratings']:
_, 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
# 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 (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', '-')}")
console.print(f" Steal Jump: {br.get('steal_jump', '-')}")
console.print(f" Running: {br.get('running', '-')}")
console.print()
console.print("[yellow]Preview only - not submitted to database[/yellow]")
@app.command()
def submit(
character: str = typer.Argument(..., help="Character profile name"),
dry_run: bool = typer.Option(False, "--dry-run", "-n", help="Preview changes without saving"),
skip_s3: bool = typer.Option(False, "--skip-s3", help="Skip S3 upload after 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']} ({player_type})[/bold]")
console.print("=" * 70)
if dry_run:
console.print("[yellow]DRY RUN - no changes will be made[/yellow]")
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']:
total = verify_total(profile['ratings'][vs_hand])
if abs(total - 108.0) > 0.01:
console.print(f"[red]ERROR: {vs_hand} ratings total is {total:.2f}, must be 108.0[/red]")
raise typer.Exit(1)
if dry_run:
console.print("[green]Validation passed - ready to submit[/green]")
return
# Import database functions
try:
from pd_cards.core.db import db_get, db_put, db_patch
except ImportError:
# Fallback to old location during migration
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from db_calls import db_get, db_put, db_patch
async def do_submit():
player_id = profile.get('player_id')
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)
# Update ratings
if 'ratings' in profile:
console.print("Updating ratings...")
ratings_list = []
for vs_hand, ratings in profile['ratings'].items():
hand = vs_hand[-1] # 'L' or 'R'
rating_data = {
card_id_field: card_id,
'vs_hand': hand,
**ratings
}
ratings_list.append(rating_data)
await db_put(ratings_endpoint, payload={'ratings': ratings_list}, timeout=10)
console.print("[green] Ratings updated[/green]")
# Update positions
if 'positions' in profile:
console.print("Updating positions...")
positions_list = []
for pos, stats in profile['positions'].items():
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]")
console.print()
console.print(f"[green]Successfully updated {profile['name']}![/green]")
if not skip_s3:
console.print()
console.print("[yellow]S3 upload not yet implemented in CLI[/yellow]")
console.print("Run manually: python check_cards_and_upload.py")
asyncio.run(do_submit())
@app.command()
def new(
name: str = typer.Option(..., "--name", "-n", help="Character name"),
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)
if profile_path.exists():
console.print(f"[red]Profile already exists:[/red] {profile_path}")
raise typer.Exit(1)
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,
'player_id': None,
'batting_card_id': None,
'positions': {
'RF': {'range': 3, 'error': 7, 'arm': 0},
},
'baserunning': {
'steal_jump': 0.15,
'steal_high': 12,
'steal_low': 5,
'steal_auto': 0,
'running': 10,
'hit_and_run': 'C',
'bunting': 'C',
},
'ratings': {
'vs_L': {
'homerun': 2.0, 'bp_homerun': 1.0, 'triple': 0.5,
'double_three': 0.0, 'double_two': 4.0, 'double_pull': 2.0,
'single_two': 5.0, 'single_one': 5.0, 'single_center': 5.0, 'bp_single': 3.0,
'walk': 10.0, 'hbp': 1.0,
'strikeout': 20.0, 'lineout': 12.0, 'popout': 2.0,
'flyout_a': 2.0, 'flyout_bq': 2.0, 'flyout_lf_b': 4.0, 'flyout_rf_b': 4.0,
'groundout_a': 5.0, 'groundout_b': 10.0, 'groundout_c': 8.5,
'pull_rate': 0.30, 'center_rate': 0.40, 'slap_rate': 0.30,
},
'vs_R': {
'homerun': 2.0, 'bp_homerun': 1.0, 'triple': 0.5,
'double_three': 0.0, 'double_two': 4.0, 'double_pull': 2.0,
'single_two': 5.0, 'single_one': 5.0, 'single_center': 5.0, 'bp_single': 3.0,
'walk': 10.0, 'hbp': 1.0,
'strikeout': 20.0, 'lineout': 12.0, 'popout': 2.0,
'flyout_a': 2.0, 'flyout_bq': 2.0, 'flyout_lf_b': 4.0, 'flyout_rf_b': 4.0,
'groundout_a': 5.0, 'groundout_b': 10.0, 'groundout_c': 8.5,
'pull_rate': 0.30, 'center_rate': 0.40, 'slap_rate': 0.30,
},
},
}
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,
'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,
},
},
}