Closes #12 - tweak_archetype(): prompts user for updated archetype stats (avg/obp/slg/bb%/k% vs L and R, power and batted-ball profile, baserunning for batters), then recalculates D20 card ratings via the existing calculator - manual_adjustments(): prompts user to choose a split (vs L or vs R), displays all 22 D20 chance fields with running total, accepts field-number + value edits, and warns if total deviates from 108 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
902 lines
33 KiB
Python
902 lines
33 KiB
Python
"""
|
|
Interactive custom card creation workflow for Paper Dynasty.
|
|
|
|
This script guides users through creating fictional player cards using
|
|
baseball archetypes with iterative review and refinement.
|
|
"""
|
|
|
|
import asyncio
|
|
import copy
|
|
import sys
|
|
from typing import Literal
|
|
from datetime import datetime
|
|
import boto3
|
|
import aiohttp
|
|
|
|
from custom_cards.archetype_definitions import (
|
|
BATTER_ARCHETYPES,
|
|
PITCHER_ARCHETYPES,
|
|
BatterArchetype,
|
|
PitcherArchetype,
|
|
describe_archetypes,
|
|
)
|
|
from custom_cards.archetype_calculator import (
|
|
BatterRatingCalculator,
|
|
PitcherRatingCalculator,
|
|
calculate_total_ops,
|
|
)
|
|
from creation_helpers import (
|
|
CLUB_LIST,
|
|
mlbteam_and_franchise,
|
|
)
|
|
from db_calls import db_get, db_post, db_put, db_patch
|
|
from exceptions import logger
|
|
|
|
# AWS Configuration
|
|
AWS_BUCKET_NAME = "paper-dynasty"
|
|
AWS_REGION = "us-east-1"
|
|
S3_BASE_URL = f"https://{AWS_BUCKET_NAME}.s3.{AWS_REGION}.amazonaws.com"
|
|
|
|
|
|
class CustomCardCreator:
|
|
"""Interactive workflow for creating custom fictional player cards."""
|
|
|
|
def __init__(self):
|
|
"""Initialize the creator."""
|
|
self.cardset = None
|
|
self.season = None
|
|
self.player_description = None
|
|
self.s3_client = boto3.client("s3", region_name=AWS_REGION)
|
|
|
|
async def run(self):
|
|
"""Main workflow entry point."""
|
|
print("\n" + "=" * 70)
|
|
print("PAPER DYNASTY - CUSTOM CARD CREATOR")
|
|
print("=" * 70)
|
|
print("\nCreate fictional player cards using baseball archetypes.")
|
|
print("You'll define a player profile, review the calculated ratings,")
|
|
print("and iteratively tweak until satisfied.\n")
|
|
|
|
# Step 1: Setup cardset
|
|
await self.setup_cardset()
|
|
|
|
# Step 2: Create players loop
|
|
while True:
|
|
player_type = self.prompt_player_type()
|
|
if player_type == "quit":
|
|
break
|
|
|
|
await self.create_player(player_type)
|
|
|
|
# Ask if they want to create another
|
|
another = (
|
|
input("\nCreate another custom player? (yes/no): ").strip().lower()
|
|
)
|
|
if another not in ["y", "yes"]:
|
|
break
|
|
|
|
print("\n" + "=" * 70)
|
|
print("Custom card creation complete!")
|
|
print("=" * 70 + "\n")
|
|
|
|
async def setup_cardset(self):
|
|
"""Setup the target cardset for custom cards."""
|
|
print("\n" + "-" * 70)
|
|
print("CARDSET SETUP")
|
|
print("-" * 70)
|
|
|
|
cardset_name = input(
|
|
"\nEnter cardset name (e.g., 'Custom Players 2025'): "
|
|
).strip()
|
|
|
|
# Search for existing cardset
|
|
c_query = await db_get("cardsets", params=[("name", cardset_name)])
|
|
|
|
if c_query["count"] == 0:
|
|
# Cardset doesn't exist - ask if they want to create it
|
|
print(f"\nCardset '{cardset_name}' not found.")
|
|
create = input("Create new cardset? (yes/no): ").strip().lower()
|
|
|
|
if create not in ["y", "yes"]:
|
|
print("Cannot proceed without a cardset. Exiting.")
|
|
sys.exit(0)
|
|
|
|
# Get cardset details
|
|
season = int(input("Enter season year (e.g., 2025): ").strip())
|
|
ranked_legal = input("Ranked legal? (yes/no): ").strip().lower() in [
|
|
"y",
|
|
"yes",
|
|
]
|
|
in_packs = input("Available in packs? (yes/no): ").strip().lower() in [
|
|
"y",
|
|
"yes",
|
|
]
|
|
|
|
# Create cardset
|
|
cardset_payload = {
|
|
"name": cardset_name,
|
|
"season": season,
|
|
"ranked_legal": ranked_legal,
|
|
"in_packs": in_packs,
|
|
}
|
|
new_cardset = await db_post("cardsets", payload=cardset_payload)
|
|
self.cardset = new_cardset
|
|
print(f"\nCreated new cardset: {cardset_name} (ID: {new_cardset['id']})")
|
|
else:
|
|
self.cardset = c_query["cardsets"][0]
|
|
print(
|
|
f"\nUsing existing cardset: {cardset_name} (ID: {self.cardset['id']})"
|
|
)
|
|
|
|
# Set season and player description
|
|
self.season = self.cardset.get("season", datetime.now().year)
|
|
self.player_description = (
|
|
input(f"\nPlayer description (default: '{cardset_name}'): ").strip()
|
|
or cardset_name
|
|
)
|
|
|
|
print(f"\nCardset ID: {self.cardset['id']}")
|
|
print(f"Season: {self.season}")
|
|
print(f"Player Description: {self.player_description}")
|
|
|
|
def prompt_player_type(self) -> Literal["batter", "pitcher", "quit"]:
|
|
"""Prompt for player type."""
|
|
print("\n" + "-" * 70)
|
|
print("PLAYER TYPE")
|
|
print("-" * 70)
|
|
print("\n1. Batter")
|
|
print("2. Pitcher")
|
|
print("3. Quit")
|
|
|
|
while True:
|
|
choice = input("\nSelect player type (1-3): ").strip()
|
|
if choice == "1":
|
|
return "batter"
|
|
elif choice == "2":
|
|
return "pitcher"
|
|
elif choice == "3":
|
|
return "quit"
|
|
else:
|
|
print("Invalid choice. Please enter 1, 2, or 3.")
|
|
|
|
async def create_player(self, player_type: Literal["batter", "pitcher"]):
|
|
"""Create a custom player of the specified type."""
|
|
print("\n" + "=" * 70)
|
|
print(f"CREATING CUSTOM {player_type.upper()}")
|
|
print("=" * 70)
|
|
|
|
# Step 1: Choose archetype
|
|
archetype = self.choose_archetype(player_type)
|
|
|
|
# Step 2: Get player info
|
|
player_info = self.get_player_info(player_type, archetype)
|
|
|
|
# Step 3: Calculate initial ratings
|
|
if player_type == "batter":
|
|
calc = BatterRatingCalculator(archetype)
|
|
ratings = calc.calculate_ratings(battingcard_id=0) # Temp ID
|
|
baserunning = calc.calculate_baserunning()
|
|
card_data = {"ratings": ratings, "baserunning": baserunning}
|
|
else:
|
|
calc = PitcherRatingCalculator(archetype)
|
|
ratings = calc.calculate_ratings(pitchingcard_id=0) # Temp ID
|
|
card_data = {
|
|
"ratings": ratings,
|
|
"starter_rating": archetype.starter_rating,
|
|
"relief_rating": archetype.relief_rating,
|
|
"closer_rating": archetype.closer_rating,
|
|
}
|
|
|
|
# Step 4: Review and tweak loop
|
|
final_data = await self.review_and_tweak(
|
|
player_type, player_info, archetype, card_data
|
|
)
|
|
|
|
# Step 5: Create records in database
|
|
await self.create_database_records(player_type, player_info, final_data)
|
|
|
|
print(
|
|
f"\n✓ {player_info['name_first']} {player_info['name_last']} created successfully!"
|
|
)
|
|
|
|
def choose_archetype(
|
|
self, player_type: Literal["batter", "pitcher"]
|
|
) -> BatterArchetype | PitcherArchetype:
|
|
"""Interactive archetype selection."""
|
|
print(describe_archetypes(player_type))
|
|
|
|
archetypes = (
|
|
BATTER_ARCHETYPES if player_type == "batter" else PITCHER_ARCHETYPES
|
|
)
|
|
archetype_keys = list(archetypes.keys())
|
|
|
|
while True:
|
|
choice = input(f"\nSelect archetype (1-{len(archetype_keys)}): ").strip()
|
|
try:
|
|
idx = int(choice) - 1
|
|
if 0 <= idx < len(archetype_keys):
|
|
key = archetype_keys[idx]
|
|
return archetypes[key]
|
|
else:
|
|
print(f"Please enter a number between 1 and {len(archetype_keys)}.")
|
|
except ValueError:
|
|
print("Invalid input. Please enter a number.")
|
|
|
|
def get_player_info(
|
|
self,
|
|
player_type: Literal["batter", "pitcher"],
|
|
archetype: BatterArchetype | PitcherArchetype,
|
|
) -> dict:
|
|
"""Collect basic player information."""
|
|
print("\n" + "-" * 70)
|
|
print("PLAYER INFORMATION")
|
|
print("-" * 70)
|
|
|
|
name_first = input("\nFirst name: ").strip().title()
|
|
name_last = input("Last name: ").strip().title()
|
|
|
|
# Hand
|
|
if player_type == "batter":
|
|
print("\nBatting hand:")
|
|
print("1. Right (R)")
|
|
print("2. Left (L)")
|
|
print("3. Switch (S)")
|
|
hand_choice = input("Select (1-3): ").strip()
|
|
hand = {"1": "R", "2": "L", "3": "S"}.get(hand_choice, "R")
|
|
else:
|
|
print("\nThrowing hand:")
|
|
print("1. Right (R)")
|
|
print("2. Left (L)")
|
|
hand_choice = input("Select (1-2): ").strip()
|
|
hand = {"1": "R", "2": "L"}.get(hand_choice, "R")
|
|
|
|
# Team
|
|
print("\nMLB Team:")
|
|
for i, team in enumerate(sorted(CLUB_LIST.keys()), 1):
|
|
if i % 3 == 1:
|
|
print()
|
|
print(f"{i:2d}. {team:25s}", end="")
|
|
print("\n")
|
|
|
|
team_clubs = sorted(CLUB_LIST.keys())
|
|
while True:
|
|
team_choice = input(f"Select team (1-{len(team_clubs)}): ").strip()
|
|
try:
|
|
idx = int(team_choice) - 1
|
|
if 0 <= idx < len(team_clubs):
|
|
team_abbrev = team_clubs[idx]
|
|
break
|
|
else:
|
|
print(f"Please enter a number between 1 and {len(team_clubs)}.")
|
|
except ValueError:
|
|
print("Invalid input. Please enter a number.")
|
|
|
|
mlb_team_id, franchise_id = mlbteam_and_franchise(team_abbrev)
|
|
|
|
# Positions
|
|
if player_type == "batter":
|
|
print("\nDefensive positions (primary first):")
|
|
print("Enter positions separated by spaces (e.g., 'CF RF DH')")
|
|
print("Available: C 1B 2B 3B SS LF CF RF DH")
|
|
positions_input = input("Positions: ").strip().upper().split()
|
|
positions = positions_input[:8] # Max 8 positions
|
|
else:
|
|
positions = ["P"] # Pitchers always have P
|
|
|
|
return {
|
|
"name_first": name_first,
|
|
"name_last": name_last,
|
|
"hand": hand,
|
|
"team_abbrev": team_abbrev,
|
|
"mlb_team_id": mlb_team_id,
|
|
"franchise_id": franchise_id,
|
|
"positions": positions,
|
|
}
|
|
|
|
async def review_and_tweak(
|
|
self,
|
|
player_type: Literal["batter", "pitcher"],
|
|
player_info: dict,
|
|
archetype: BatterArchetype | PitcherArchetype,
|
|
card_data: dict,
|
|
) -> dict:
|
|
"""Review calculated ratings and allow iterative tweaking."""
|
|
print("\n" + "=" * 70)
|
|
print("REVIEW & TWEAK RATINGS")
|
|
print("=" * 70)
|
|
|
|
while True:
|
|
# Display current ratings
|
|
self.display_ratings(player_type, player_info, card_data)
|
|
|
|
# Show options
|
|
print("\nOptions:")
|
|
print("1. Accept these ratings")
|
|
print("2. Tweak archetype percentages")
|
|
print("3. Manually adjust specific ratings")
|
|
print("4. Start over with different archetype")
|
|
|
|
choice = input("\nSelect (1-4): ").strip()
|
|
|
|
if choice == "1":
|
|
# Accept
|
|
return card_data
|
|
elif choice == "2":
|
|
# Tweak archetype
|
|
card_data = await self.tweak_archetype(
|
|
player_type, archetype, card_data
|
|
)
|
|
elif choice == "3":
|
|
# Manual adjustments
|
|
card_data = await self.manual_adjustments(player_type, card_data)
|
|
elif choice == "4":
|
|
# Start over
|
|
return await self.create_player(player_type)
|
|
else:
|
|
print("Invalid choice.")
|
|
|
|
def display_ratings(
|
|
self,
|
|
player_type: Literal["batter", "pitcher"],
|
|
player_info: dict,
|
|
card_data: dict,
|
|
):
|
|
"""Display current calculated ratings in a readable format."""
|
|
name = f"{player_info['name_first']} {player_info['name_last']}"
|
|
print(f"\n{name} ({player_info['hand']}) - {player_info['team_abbrev']}")
|
|
print("-" * 70)
|
|
|
|
ratings = card_data["ratings"]
|
|
|
|
# Display vs L and vs R stats
|
|
for rating in ratings:
|
|
vs_hand = rating["vs_hand"]
|
|
print(f"\nVS {vs_hand}{'HP' if player_type == 'batter' else 'HB'}:")
|
|
print(
|
|
f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {rating['obp'] + rating['slg']:.3f}"
|
|
)
|
|
|
|
# Show hit distribution
|
|
total_hits = (
|
|
rating["homerun"]
|
|
+ rating["bp_homerun"]
|
|
+ rating["triple"]
|
|
+ rating["double_three"]
|
|
+ rating["double_two"]
|
|
+ rating["double_pull"]
|
|
+ rating["single_two"]
|
|
+ rating["single_one"]
|
|
+ rating["single_center"]
|
|
+ rating["bp_single"]
|
|
)
|
|
print(
|
|
f" Hits: {total_hits:.1f} (HR: {rating['homerun']:.1f} 3B: {rating['triple']:.1f} 2B: {rating['double_pull'] + rating['double_two'] + rating['double_three']:.1f} 1B: {total_hits - rating['homerun'] - rating['bp_homerun'] - rating['triple'] - rating['double_pull'] - rating['double_two'] - rating['double_three']:.1f})"
|
|
)
|
|
|
|
# Show walk/strikeout
|
|
print(
|
|
f" BB: {rating['walk']:.1f} HBP: {rating['hbp']:.1f} K: {rating['strikeout']:.1f}"
|
|
)
|
|
|
|
# Show batted ball distribution
|
|
outs = (
|
|
rating["lineout"]
|
|
+ rating["popout"]
|
|
+ (
|
|
rating["flyout_a"]
|
|
+ rating["flyout_bq"]
|
|
+ rating["flyout_lf_b"]
|
|
+ rating["flyout_rf_b"]
|
|
)
|
|
+ (
|
|
rating["groundout_a"]
|
|
+ rating["groundout_b"]
|
|
+ rating["groundout_c"]
|
|
)
|
|
)
|
|
print(
|
|
f" Outs: {outs:.1f} (K: {rating['strikeout']:.1f} LD: {rating['lineout']:.1f} FB: {rating['flyout_a'] + rating['flyout_bq'] + rating['flyout_lf_b'] + rating['flyout_rf_b']:.1f} GB: {rating['groundout_a'] + rating['groundout_b'] + rating['groundout_c']:.1f})"
|
|
)
|
|
|
|
# Calculate and display total OPS
|
|
is_pitcher = player_type == "pitcher"
|
|
total_ops = calculate_total_ops(ratings[0], ratings[1], is_pitcher)
|
|
print(
|
|
f"\n{'Total OPS' if player_type == 'batter' else 'Total OPS Against'}: {total_ops:.3f}"
|
|
)
|
|
|
|
# Show baserunning for batters
|
|
if player_type == "batter" and "baserunning" in card_data:
|
|
br = card_data["baserunning"]
|
|
print("\nBaserunning:")
|
|
print(
|
|
f" Steal: {br['steal_low']}-{br['steal_high']} (Auto: {br['steal_auto']}, Jump: {br['steal_jump']})"
|
|
)
|
|
print(f" Running: {br['running']}/10 Hit-and-Run: {br['hit_and_run']}/10")
|
|
|
|
async def tweak_archetype(
|
|
self,
|
|
player_type: Literal["batter", "pitcher"],
|
|
archetype: BatterArchetype | PitcherArchetype,
|
|
card_data: dict,
|
|
) -> dict:
|
|
"""Allow tweaking of archetype percentages."""
|
|
print("\n" + "-" * 70)
|
|
print("TWEAK ARCHETYPE")
|
|
print("-" * 70)
|
|
print("\nAdjust key percentages (press Enter to keep current value):\n")
|
|
|
|
def prompt_float(label: str, current: float) -> float:
|
|
val = input(f" {label} [{current:.3f}]: ").strip()
|
|
if not val:
|
|
return current
|
|
try:
|
|
return float(val)
|
|
except ValueError:
|
|
print(" Invalid value, keeping current.")
|
|
return current
|
|
|
|
def prompt_int(label: str, current: int) -> int:
|
|
val = input(f" {label} [{current}]: ").strip()
|
|
if not val:
|
|
return current
|
|
try:
|
|
return int(val)
|
|
except ValueError:
|
|
print(" Invalid value, keeping current.")
|
|
return current
|
|
|
|
arch = copy.copy(archetype)
|
|
|
|
print("--- vs RHP/RHB ---")
|
|
arch.avg_vs_r = prompt_float("AVG vs R", arch.avg_vs_r)
|
|
arch.obp_vs_r = prompt_float("OBP vs R", arch.obp_vs_r)
|
|
arch.slg_vs_r = prompt_float("SLG vs R", arch.slg_vs_r)
|
|
arch.bb_pct_vs_r = prompt_float("BB% vs R", arch.bb_pct_vs_r)
|
|
arch.k_pct_vs_r = prompt_float("K% vs R", arch.k_pct_vs_r)
|
|
|
|
print("\n--- vs LHP/LHB ---")
|
|
arch.avg_vs_l = prompt_float("AVG vs L", arch.avg_vs_l)
|
|
arch.obp_vs_l = prompt_float("OBP vs L", arch.obp_vs_l)
|
|
arch.slg_vs_l = prompt_float("SLG vs L", arch.slg_vs_l)
|
|
arch.bb_pct_vs_l = prompt_float("BB% vs L", arch.bb_pct_vs_l)
|
|
arch.k_pct_vs_l = prompt_float("K% vs L", arch.k_pct_vs_l)
|
|
|
|
print("\n--- Power Profile ---")
|
|
arch.hr_per_hit = prompt_float("HR/Hit", arch.hr_per_hit)
|
|
arch.triple_per_hit = prompt_float("3B/Hit", arch.triple_per_hit)
|
|
arch.double_per_hit = prompt_float("2B/Hit", arch.double_per_hit)
|
|
|
|
print("\n--- Batted Ball Profile ---")
|
|
arch.gb_pct = prompt_float("GB%", arch.gb_pct)
|
|
arch.fb_pct = prompt_float("FB%", arch.fb_pct)
|
|
arch.ld_pct = prompt_float("LD%", arch.ld_pct)
|
|
|
|
if player_type == "batter":
|
|
print("\n--- Baserunning ---")
|
|
arch.speed_rating = prompt_int("Speed (1-10)", arch.speed_rating) # type: ignore[arg-type]
|
|
arch.steal_jump = prompt_int("Jump (1-10)", arch.steal_jump) # type: ignore[arg-type]
|
|
arch.xbt_pct = prompt_float("XBT%", arch.xbt_pct) # type: ignore[union-attr]
|
|
|
|
# Recalculate card ratings with the modified archetype
|
|
if player_type == "batter":
|
|
calc = BatterRatingCalculator(arch) # type: ignore[arg-type]
|
|
ratings = calc.calculate_ratings(battingcard_id=0)
|
|
baserunning = calc.calculate_baserunning()
|
|
return {"ratings": ratings, "baserunning": baserunning}
|
|
else:
|
|
calc_p = PitcherRatingCalculator(arch) # type: ignore[arg-type]
|
|
ratings = calc_p.calculate_ratings(pitchingcard_id=0)
|
|
return {"ratings": ratings}
|
|
|
|
async def manual_adjustments(
|
|
self, player_type: Literal["batter", "pitcher"], card_data: dict
|
|
) -> dict:
|
|
"""Allow manual adjustment of specific D20 ratings."""
|
|
print("\n" + "-" * 70)
|
|
print("MANUAL ADJUSTMENTS")
|
|
print("-" * 70)
|
|
print("\nDirectly edit D20 chances (must sum to 108):\n")
|
|
|
|
D20_FIELDS = [
|
|
"homerun",
|
|
"bp_homerun",
|
|
"triple",
|
|
"double_three",
|
|
"double_two",
|
|
"double_pull",
|
|
"single_two",
|
|
"single_one",
|
|
"single_center",
|
|
"bp_single",
|
|
"hbp",
|
|
"walk",
|
|
"strikeout",
|
|
"lineout",
|
|
"popout",
|
|
"flyout_a",
|
|
"flyout_bq",
|
|
"flyout_lf_b",
|
|
"flyout_rf_b",
|
|
"groundout_a",
|
|
"groundout_b",
|
|
"groundout_c",
|
|
]
|
|
|
|
# Choose which split to edit
|
|
print("Which split to edit?")
|
|
for i, rating in enumerate(card_data["ratings"]):
|
|
vs = rating["vs_hand"]
|
|
print(f" {i + 1}. vs {vs}{'HP' if player_type == 'batter' else 'HB'}")
|
|
|
|
while True:
|
|
choice = input("\nSelect split (1-2): ").strip()
|
|
try:
|
|
idx = int(choice) - 1
|
|
if 0 <= idx < len(card_data["ratings"]):
|
|
break
|
|
else:
|
|
print("Invalid choice.")
|
|
except ValueError:
|
|
print("Invalid input.")
|
|
|
|
result = copy.deepcopy(card_data)
|
|
rating = result["ratings"][idx]
|
|
|
|
while True:
|
|
vs = rating["vs_hand"]
|
|
print(
|
|
f"\n--- VS {vs}{'HP' if player_type == 'batter' else 'HB'} D20 Chances ---"
|
|
)
|
|
total = 0.0
|
|
for i, field in enumerate(D20_FIELDS, 1):
|
|
val = rating[field]
|
|
print(f" {i:2d}. {field:<20s}: {val:.2f}")
|
|
total += val
|
|
print(f"\n Total: {total:.2f} (target: 108.00)")
|
|
|
|
user_input = input(
|
|
"\nEnter field number and new value (e.g. '1 3.5'), or 'done': "
|
|
).strip()
|
|
if user_input.lower() in ("done", "q", ""):
|
|
break
|
|
|
|
parts = user_input.split()
|
|
if len(parts) != 2:
|
|
print(" Enter a field number and a value separated by a space.")
|
|
continue
|
|
|
|
try:
|
|
field_idx = int(parts[0]) - 1
|
|
new_val = float(parts[1])
|
|
except ValueError:
|
|
print(" Invalid input.")
|
|
continue
|
|
|
|
if not (0 <= field_idx < len(D20_FIELDS)):
|
|
print(f" Field number must be between 1 and {len(D20_FIELDS)}.")
|
|
continue
|
|
|
|
if new_val < 0:
|
|
print(" Value cannot be negative.")
|
|
continue
|
|
|
|
rating[D20_FIELDS[field_idx]] = new_val
|
|
|
|
total = sum(rating[f] for f in D20_FIELDS)
|
|
if abs(total - 108.0) > 0.01:
|
|
print(
|
|
f"\nWarning: Total is {total:.2f} (expected 108.00). "
|
|
"Ratings saved but card probabilities may be incorrect."
|
|
)
|
|
|
|
return result
|
|
|
|
async def create_database_records(
|
|
self,
|
|
player_type: Literal["batter", "pitcher"],
|
|
player_info: dict,
|
|
card_data: dict,
|
|
):
|
|
"""Create Player, Card, Ratings, and Position records in the database."""
|
|
print("\n" + "-" * 70)
|
|
print("CREATING DATABASE RECORDS")
|
|
print("-" * 70)
|
|
|
|
# Step 1: Create/verify MLBPlayer record
|
|
# For custom players, we use a fake bbref_id based on name
|
|
bbref_id = f"custom_{player_info['name_last'].lower()}{player_info['name_first'][0].lower()}01"
|
|
|
|
mlb_query = await db_get("mlbplayers", params=[("key_bbref", bbref_id)])
|
|
if mlb_query["count"] > 0:
|
|
mlbplayer_id = mlb_query["players"][0]["id"]
|
|
print(f"✓ Using existing MLBPlayer record (ID: {mlbplayer_id})")
|
|
else:
|
|
mlbplayer_payload = {
|
|
"key_bbref": bbref_id,
|
|
"key_fangraphs": 0, # Custom player, no real ID
|
|
"key_mlbam": 0,
|
|
"key_retro": "",
|
|
"name_first": player_info["name_first"],
|
|
"name_last": player_info["name_last"],
|
|
"mlb_team_id": player_info["mlb_team_id"],
|
|
}
|
|
new_mlbplayer = await db_post("mlbplayers/one", payload=mlbplayer_payload)
|
|
mlbplayer_id = new_mlbplayer["id"]
|
|
print(f"✓ Created MLBPlayer record (ID: {mlbplayer_id})")
|
|
|
|
# Step 2: Create Player record (with placeholder player_id for image URL)
|
|
# Note: We'll get the actual player_id after POST, then set the image URL
|
|
now = datetime.now()
|
|
release_date = f"{now.year}-{now.month}-{now.day}"
|
|
|
|
# We need to create player first, then PATCH with image URL
|
|
player_payload = {
|
|
"p_name": f"{player_info['name_first']} {player_info['name_last']}",
|
|
"bbref_id": bbref_id,
|
|
"fangraphs_id": 0,
|
|
"mlbam_id": 0,
|
|
"retrosheet_id": "",
|
|
"hand": player_info["hand"],
|
|
"mlb_team_id": player_info["mlb_team_id"],
|
|
"franchise_id": player_info["franchise_id"],
|
|
"cardset": self.cardset["id"],
|
|
"player_description": self.player_description,
|
|
"is_custom": True,
|
|
"mlbplayer_id": mlbplayer_id,
|
|
}
|
|
|
|
new_player = await db_post("players", payload=player_payload)
|
|
player_id = new_player["player_id"]
|
|
print(f"✓ Created Player record (ID: {player_id})")
|
|
|
|
# Now add the image URL via PATCH
|
|
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)])
|
|
print("✓ Updated Player with image URL")
|
|
|
|
# Step 3: Create Card and Ratings
|
|
if player_type == "batter":
|
|
await self.create_batting_card(player_id, player_info, card_data)
|
|
else:
|
|
await self.create_pitching_card(player_id, player_info, card_data)
|
|
|
|
# Step 4: Create CardPosition records
|
|
await self.create_positions(player_id, player_info["positions"], card_data)
|
|
|
|
print(f"\n✓ All database records created for player ID {player_id}")
|
|
|
|
# Step 5: Generate card image and upload to S3
|
|
await self.image_generation_and_upload(player_id, player_type, release_date)
|
|
|
|
async def create_batting_card(
|
|
self, player_id: int, player_info: dict, card_data: dict
|
|
):
|
|
"""Create batting card and ratings."""
|
|
baserunning = card_data["baserunning"]
|
|
|
|
# Create batting card
|
|
batting_card_payload = {
|
|
"cards": [
|
|
{
|
|
"player_id": player_id,
|
|
"key_bbref": f"custom_{player_info['name_last'].lower()}{player_info['name_first'][0].lower()}01",
|
|
"key_fangraphs": 0,
|
|
"key_mlbam": 0,
|
|
"key_retro": "",
|
|
"name_first": player_info["name_first"],
|
|
"name_last": player_info["name_last"],
|
|
"steal_low": baserunning["steal_low"],
|
|
"steal_high": baserunning["steal_high"],
|
|
"steal_auto": baserunning["steal_auto"],
|
|
"steal_jump": baserunning["steal_jump"],
|
|
"hit_and_run": baserunning["hit_and_run"],
|
|
"running": baserunning["running"],
|
|
"hand": player_info["hand"],
|
|
}
|
|
]
|
|
}
|
|
|
|
card_resp = await db_put(
|
|
"battingcards", payload=batting_card_payload, timeout=10
|
|
)
|
|
print("✓ Created batting card")
|
|
|
|
# Get the created card ID
|
|
bc_query = await db_get("battingcards", params=[("player_id", player_id)])
|
|
battingcard_id = bc_query["cards"][0]["id"]
|
|
|
|
# Create ratings (update with real card ID)
|
|
ratings = card_data["ratings"]
|
|
for rating in ratings:
|
|
rating["battingcard_id"] = battingcard_id
|
|
|
|
ratings_payload = {"ratings": ratings}
|
|
await db_put("battingcardratings", payload=ratings_payload, timeout=10)
|
|
print("✓ Created batting ratings")
|
|
|
|
async def create_pitching_card(
|
|
self, player_id: int, player_info: dict, card_data: dict
|
|
):
|
|
"""Create pitching card and ratings."""
|
|
# Determine starter/relief/closer ratings based on archetype
|
|
# For now, use defaults - can be enhanced later
|
|
pitching_card_payload = {
|
|
"cards": [
|
|
{
|
|
"player_id": player_id,
|
|
"key_bbref": f"custom_{player_info['name_last'].lower()}{player_info['name_first'][0].lower()}01",
|
|
"key_fangraphs": 0,
|
|
"key_mlbam": 0,
|
|
"key_retro": "",
|
|
"name_first": player_info["name_first"],
|
|
"name_last": player_info["name_last"],
|
|
"hand": player_info["hand"],
|
|
"starter_rating": card_data["starter_rating"],
|
|
"relief_rating": card_data["relief_rating"],
|
|
"closer_rating": card_data["closer_rating"],
|
|
}
|
|
]
|
|
}
|
|
|
|
card_resp = await db_put(
|
|
"pitchingcards", payload=pitching_card_payload, timeout=10
|
|
)
|
|
print("✓ Created pitching card")
|
|
|
|
# Get the created card ID
|
|
pc_query = await db_get("pitchingcards", params=[("player_id", player_id)])
|
|
pitchingcard_id = pc_query["cards"][0]["id"]
|
|
|
|
# Create ratings (update with real card ID)
|
|
ratings = card_data["ratings"]
|
|
for rating in ratings:
|
|
rating["pitchingcard_id"] = pitchingcard_id
|
|
|
|
ratings_payload = {"ratings": ratings}
|
|
await db_put("pitchingcardratings", payload=ratings_payload, timeout=10)
|
|
print("✓ Created pitching ratings")
|
|
|
|
async def create_positions(
|
|
self, player_id: int, positions: list[str], card_data: dict
|
|
):
|
|
"""Create CardPosition records."""
|
|
positions_payload = {
|
|
"positions": [
|
|
{"player_id": player_id, "position": pos} for pos in positions
|
|
]
|
|
}
|
|
|
|
await db_post("cardpositions", payload=positions_payload)
|
|
print(f"✓ Created {len(positions)} position record(s)")
|
|
|
|
async def image_generation_and_upload(
|
|
self,
|
|
player_id: int,
|
|
player_type: Literal["batter", "pitcher"],
|
|
release_date: str,
|
|
):
|
|
"""
|
|
Generate card image from API and upload to S3.
|
|
|
|
This implements the 4-step workflow:
|
|
1. GET Player.image to trigger card generation
|
|
2. Fetch the generated PNG image bytes
|
|
3. Upload to S3 with proper metadata
|
|
4. PATCH player record with S3 URL
|
|
"""
|
|
print("\n" + "-" * 70)
|
|
print("GENERATING & UPLOADING CARD IMAGE")
|
|
print("-" * 70)
|
|
|
|
card_type = "batting" if player_type == "batter" else "pitching"
|
|
api_image_url = f"https://pd.manticorum.com/api/v2/players/{player_id}/{card_type}card?d={release_date}"
|
|
|
|
try:
|
|
# Step 1 & 2: Trigger card generation and fetch image bytes
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
api_image_url, timeout=aiohttp.ClientTimeout(total=10)
|
|
) as resp:
|
|
if resp.status == 200:
|
|
image_bytes = await resp.read()
|
|
image_size_kb = len(image_bytes) / 1024
|
|
print("✓ Triggered card generation at API")
|
|
print(f"✓ Fetched card image ({image_size_kb:.1f} KB)")
|
|
logger.info(
|
|
f"Fetched {card_type} card for player {player_id}: {image_size_kb:.1f} KB"
|
|
)
|
|
else:
|
|
error_text = await resp.text()
|
|
raise ValueError(
|
|
f"Card generation failed (HTTP {resp.status}): {error_text}"
|
|
)
|
|
|
|
# Step 3: Upload to S3
|
|
s3_url = self.upload_card_to_s3(
|
|
image_bytes, player_id, card_type, release_date, self.cardset["id"]
|
|
)
|
|
print(
|
|
f"✓ Uploaded to S3: cards/cardset-{self.cardset['id']:03d}/player-{player_id}/{card_type}card.png"
|
|
)
|
|
|
|
# Step 4: Update player record with S3 URL
|
|
await db_patch("players", object_id=player_id, params=[("image", s3_url)])
|
|
print("✓ Updated player record with S3 URL")
|
|
logger.info(f"Updated player {player_id} with S3 URL: {s3_url}")
|
|
|
|
except aiohttp.ClientError as e:
|
|
error_msg = f"Network error during card generation: {e}"
|
|
logger.error(error_msg)
|
|
print(f"\n⚠ WARNING: {error_msg}")
|
|
print(" Player created but card image not uploaded to S3.")
|
|
print(" Manual upload may be needed later.")
|
|
|
|
except ValueError as e:
|
|
error_msg = f"Card generation error: {e}"
|
|
logger.error(error_msg)
|
|
print(f"\n⚠ WARNING: {error_msg}")
|
|
print(" Player created but card image not uploaded to S3.")
|
|
print(" Check player record and card data.")
|
|
|
|
except Exception as e:
|
|
error_msg = f"S3 upload error: {e}"
|
|
logger.error(error_msg)
|
|
print(f"\n⚠ WARNING: {error_msg}")
|
|
print(" Card generated but S3 upload failed.")
|
|
print(" Player still has API image URL.")
|
|
|
|
def upload_card_to_s3(
|
|
self,
|
|
image_data: bytes,
|
|
player_id: int,
|
|
card_type: str,
|
|
release_date: str,
|
|
cardset_id: int,
|
|
) -> str:
|
|
"""
|
|
Upload card image to S3 and return the S3 URL with cache-busting param.
|
|
|
|
Args:
|
|
image_data: Raw PNG image bytes
|
|
player_id: Player ID
|
|
card_type: 'batting' or 'pitching'
|
|
release_date: Date string for cache busting (e.g., '2025-11-11')
|
|
cardset_id: Cardset ID (will be zero-padded to 3 digits)
|
|
|
|
Returns:
|
|
Full S3 URL with ?d= parameter
|
|
"""
|
|
# Format cardset_id with 3 digits and leading zeros
|
|
cardset_str = f"{cardset_id:03d}"
|
|
s3_key = f"cards/cardset-{cardset_str}/player-{player_id}/{card_type}card.png"
|
|
|
|
self.s3_client.put_object(
|
|
Bucket=AWS_BUCKET_NAME,
|
|
Key=s3_key,
|
|
Body=image_data,
|
|
ContentType="image/png",
|
|
CacheControl="public, max-age=300", # 5 minute cache
|
|
Metadata={
|
|
"player-id": str(player_id),
|
|
"card-type": card_type,
|
|
"upload-date": datetime.now().isoformat(),
|
|
},
|
|
)
|
|
|
|
# Return URL with cache-busting parameter
|
|
s3_url = f"{S3_BASE_URL}/{s3_key}?d={release_date}"
|
|
logger.info(f"Uploaded {card_type} card for player {player_id} to S3: {s3_url}")
|
|
return s3_url
|
|
|
|
|
|
async def main():
|
|
"""Entry point."""
|
|
creator = CustomCardCreator()
|
|
await creator.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|