From 8ac89cfcd8859e5008dc7c7a19413dee42c479fb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Thu, 18 Dec 2025 16:17:17 -0600 Subject: [PATCH] Add pitcher support to custom cards CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pd_cards/commands/custom.py | 298 +++++++++++++++++---- pd_cards/custom/profiles/tony_smehrik.yaml | 99 +++++++ 2 files changed, 348 insertions(+), 49 deletions(-) create mode 100644 pd_cards/custom/profiles/tony_smehrik.yaml diff --git a/pd_cards/commands/custom.py b/pd_cards/commands/custom.py index c09f216..01d0e26 100644 --- a/pd_cards/commands/custom.py +++ b/pd_cards/commands/custom.py @@ -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, + }, + }, + } diff --git a/pd_cards/custom/profiles/tony_smehrik.yaml b/pd_cards/custom/profiles/tony_smehrik.yaml new file mode 100644 index 0000000..8d776eb --- /dev/null +++ b/pd_cards/custom/profiles/tony_smehrik.yaml @@ -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