""" 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(' ', '_')}")