Standardize formatting with black and apply ruff auto-fixes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
901 lines
31 KiB
Python
901 lines
31 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 datetime import datetime
|
|
from pathlib import Path
|
|
from typing import 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(" [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"
|
|
),
|
|
create: bool = typer.Option(
|
|
False,
|
|
"--create",
|
|
"-c",
|
|
help="Create new player in database (requires cardset_id in profile)",
|
|
),
|
|
):
|
|
"""Submit a custom character to the database."""
|
|
profile = load_profile(character)
|
|
player_type = detect_player_type(profile)
|
|
|
|
console.print()
|
|
console.print("=" * 70)
|
|
action = "CREATING" if create else "SUBMITTING"
|
|
console.print(f"[bold]{action} {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)
|
|
|
|
# Validate create requirements
|
|
if create:
|
|
if not profile.get("cardset_id"):
|
|
console.print(
|
|
"[red]ERROR: cardset_id required in profile for --create[/red]"
|
|
)
|
|
raise typer.Exit(1)
|
|
if profile.get("player_id"):
|
|
console.print(
|
|
"[red]ERROR: player_id already set - use submit without --create to update[/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, db_post
|
|
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, db_post
|
|
|
|
async def do_create():
|
|
"""Create a new player in the database."""
|
|
# Parse name
|
|
name_parts = profile["name"].split()
|
|
name_first = name_parts[0]
|
|
name_last = " ".join(name_parts[1:]) if len(name_parts) > 1 else name_parts[0]
|
|
|
|
# Generate bbref_id for custom player (with timestamp to avoid conflicts)
|
|
timestamp = int(datetime.now().timestamp())
|
|
bbref_id = f"custom_{name_last.lower().replace(' ', '')}{name_first[0].lower()}{timestamp}"
|
|
|
|
cardset_id = profile["cardset_id"]
|
|
hand = profile.get("hand", "R")
|
|
|
|
console.print(f"Name: {name_first} {name_last}")
|
|
console.print(f"BBRef ID: {bbref_id}")
|
|
console.print(f"Cardset: {cardset_id}")
|
|
console.print()
|
|
|
|
# Step 1: Create Player record (custom players don't need MLBPlayer)
|
|
console.print("Creating Player record...")
|
|
now = datetime.now()
|
|
release_date = f"{now.year}-{now.month}-{now.day}"
|
|
|
|
# Get first position for pos_1
|
|
positions = list(profile.get("positions", {}).keys())
|
|
pos_1 = positions[0] if positions else "DH"
|
|
|
|
player_payload = {
|
|
"p_name": f"{name_first} {name_last}",
|
|
"bbref_id": bbref_id,
|
|
"fangraphs_id": 0,
|
|
"mlbam_id": 0,
|
|
"retrosheet_id": "",
|
|
"hand": hand,
|
|
"mlb_team_id": 1,
|
|
"franchise_id": 1, # Default franchise
|
|
"cardset_id": cardset_id,
|
|
"description": "Custom",
|
|
"is_custom": True,
|
|
# Required fields
|
|
"cost": 100, # Default cost
|
|
"image": "", # Will be patched after
|
|
"mlbclub": "Custom Ballplayers",
|
|
"franchise": "Custom Ballplayers",
|
|
"set_num": 9999,
|
|
"rarity_id": 5, # Common
|
|
"pos_1": pos_1,
|
|
}
|
|
|
|
new_player = await db_post("players", payload=player_payload)
|
|
player_id = new_player["player_id"]
|
|
console.print(f"[green] Created Player (ID: {player_id})[/green]")
|
|
|
|
# Step 3: Patch Player with image URL
|
|
card_type = "batting" if player_type == "batter" else "pitching"
|
|
image_url = f"https://pd.manticorum.com/api/v2/players/{player_id}/{card_type}card?d={release_date}"
|
|
await db_patch("players", object_id=player_id, params=[("image", image_url)])
|
|
console.print("[green] Updated Player with image URL[/green]")
|
|
|
|
# Step 4: Create Card
|
|
if player_type == "batter":
|
|
console.print("Creating BattingCard...")
|
|
baserunning = profile.get("baserunning", {})
|
|
batting_card_payload = {
|
|
"cards": [
|
|
{
|
|
"player_id": player_id,
|
|
"key_bbref": bbref_id,
|
|
"key_fangraphs": 0,
|
|
"key_mlbam": 0,
|
|
"key_retro": "",
|
|
"name_first": name_first,
|
|
"name_last": name_last,
|
|
"steal_low": baserunning.get("steal_low", 5),
|
|
"steal_high": baserunning.get("steal_high", 12),
|
|
"steal_auto": 1 if baserunning.get("steal_auto") else 0,
|
|
"steal_jump": baserunning.get("steal_jump", 0.15),
|
|
"hit_and_run": baserunning.get("hit_and_run", "C"),
|
|
"running": baserunning.get("running", 10),
|
|
"bunting": baserunning.get("bunting", "C"),
|
|
"hand": hand,
|
|
}
|
|
]
|
|
}
|
|
await db_put("battingcards", payload=batting_card_payload, timeout=10)
|
|
|
|
# Get the card ID
|
|
bc_query = await db_get("battingcards", params=[("player_id", player_id)])
|
|
card_id = bc_query["cards"][0]["id"]
|
|
card_id_field = "battingcard_id"
|
|
ratings_endpoint = "battingcardratings"
|
|
console.print(f"[green] Created BattingCard (ID: {card_id})[/green]")
|
|
else:
|
|
console.print("Creating PitchingCard...")
|
|
pitching = profile.get("pitching", {})
|
|
pitching_card_payload = {
|
|
"cards": [
|
|
{
|
|
"player_id": player_id,
|
|
"key_bbref": bbref_id,
|
|
"key_fangraphs": 0,
|
|
"key_mlbam": 0,
|
|
"key_retro": "",
|
|
"name_first": name_first,
|
|
"name_last": name_last,
|
|
"hand": hand,
|
|
"starter_rating": pitching.get("starter_rating", 5),
|
|
"relief_rating": pitching.get("relief_rating", 5),
|
|
"closer_rating": pitching.get("closer_rating"),
|
|
}
|
|
]
|
|
}
|
|
await db_put("pitchingcards", payload=pitching_card_payload, timeout=10)
|
|
|
|
# Get the card ID
|
|
pc_query = await db_get("pitchingcards", params=[("player_id", player_id)])
|
|
card_id = pc_query["cards"][0]["id"]
|
|
card_id_field = "pitchingcard_id"
|
|
ratings_endpoint = "pitchingcardratings"
|
|
console.print(f"[green] Created PitchingCard (ID: {card_id})[/green]")
|
|
|
|
# Step 5: Create ratings
|
|
console.print("Creating ratings...")
|
|
ratings_list = []
|
|
for vs_hand, ratings in profile["ratings"].items():
|
|
hand_char = vs_hand[-1] # 'L' or 'R'
|
|
rating_data = {card_id_field: card_id, "vs_hand": hand_char, **ratings}
|
|
ratings_list.append(rating_data)
|
|
|
|
await db_put(ratings_endpoint, payload={"ratings": ratings_list}, timeout=10)
|
|
console.print("[green] Created ratings[/green]")
|
|
|
|
# Step 6: Create positions
|
|
console.print("Creating positions...")
|
|
positions_list = []
|
|
for pos, stats in profile.get("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,
|
|
}
|
|
)
|
|
|
|
if positions_list:
|
|
await db_put(
|
|
"cardpositions", payload={"positions": positions_list}, timeout=10
|
|
)
|
|
console.print(f"[green] Created {len(positions_list)} position(s)[/green]")
|
|
|
|
# Step 7: Update YAML profile with IDs
|
|
console.print("Updating profile with IDs...")
|
|
profile["player_id"] = player_id
|
|
if player_type == "batter":
|
|
profile["batting_card_id"] = card_id
|
|
else:
|
|
profile["pitching_card_id"] = card_id
|
|
|
|
profile_path = get_profile_path(character)
|
|
with open(profile_path, "w") as f:
|
|
yaml.dump(profile, f, default_flow_style=False, sort_keys=False)
|
|
console.print(f"[green] Updated {profile_path.name}[/green]")
|
|
|
|
console.print()
|
|
console.print(
|
|
f"[bold green]Successfully created {profile['name']}![/bold green]"
|
|
)
|
|
console.print(f"Player ID: {player_id}")
|
|
console.print(f"Card ID: {card_id}")
|
|
console.print(
|
|
f"URL: https://pd.manticorum.com/api/v2/players/{player_id}/{card_type}card?d={release_date}"
|
|
)
|
|
|
|
async def do_submit():
|
|
"""Update an existing player in the database."""
|
|
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(
|
|
"Use --create to create a new player, or add IDs to the profile."
|
|
)
|
|
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")
|
|
|
|
if create:
|
|
asyncio.run(do_create())
|
|
else:
|
|
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,
|
|
},
|
|
},
|
|
}
|