paper-dynasty-card-creation/pd_cards/commands/custom.py
Cal Corum 2e28d29ced Add pd-cards CLI skeleton with Typer
Introduces new pd-cards CLI tool for all card creation workflows:
- custom: manage fictional character cards via YAML profiles
- live-series: live season card updates (stub)
- retrosheet: historical data processing (stub)
- scouting: scouting report generation (stub)
- upload: S3 card image upload (stub)

Key features:
- Typer-based CLI with auto-generated help and shell completion
- YAML profiles for custom characters (replaces per-character Python scripts)
- Preview, submit, new, and list commands for custom cards
- First character migrated: Kalin Young

Install with: uv pip install -e .
Run with: pd-cards --help

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 16:08:32 -06:00

335 lines
13 KiB
Python

"""
Custom character card management.
Commands for creating and managing fictional player cards using YAML profiles.
"""
import asyncio
from pathlib import Path
from typing import Optional
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"
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 calc_ops(ratings: dict) -> tuple[float, float, float]:
"""Calculate AVG, OBP, SLG, OPS from 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 verify_total(ratings: dict) -> float:
"""Verify 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']
])
@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("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)
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)
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)
console.print()
console.print("=" * 70)
console.print(f"[bold]{profile['name']}[/bold] - CUSTOM PLAYER PREVIEW")
console.print("=" * 70)
console.print()
console.print(f"Hand: {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 positions
if 'positions' in profile:
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()
# Display ratings
if 'ratings' in profile:
console.print("[bold]Batting Ratings:[/bold]")
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)
console.print(f"\n VS {vs_hand[-1]}HP:")
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']:
_, _, 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'])
ops_l = obp_l + slg_l
ops_r = obp_r + slg_r
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:
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)
console.print()
console.print("=" * 70)
console.print(f"[bold]SUBMITTING {profile['name']}[/bold]")
console.print("=" * 70)
if dry_run:
console.print("[yellow]DRY RUN - no changes will be made[/yellow]")
console.print()
# Verify ratings
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')
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]")
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 = {
'battingcard_id': batting_card_id,
'vs_hand': hand,
**ratings
}
ratings_list.append(rating_data)
await db_put('battingcardratings', 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():
positions_list.append({
'player_id': player_id,
'position': pos,
**stats
})
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"),
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"),
):
"""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)
# Create template
template = {
'name': name,
'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
'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,
},
},
}
# 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 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(' ', '_')}")