From e3220bf337b071e10ed4b33a8f41040f7555bcce Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 12:38:01 -0500 Subject: [PATCH 01/16] Remove hardcoded secrets, load API token from environment - Replace hardcoded PD API bearer token in db_calls.py with dotenv/env var - Delete scripts/supabase_doodling.py (dead scratch file with hardcoded Supabase JWT) - Add python-dotenv dependency and .env.example template - Consolidate check_prod_missing_ratings.py to import AUTH_TOKEN from db_calls - Hard fail if PD_API_TOKEN is missing to prevent silent auth failures Fixes #2, Fixes #3 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ops-rework/check_prod_missing_ratings.py | 8 +- .env.example | 2 + db_calls.py | 18 +++-- pyproject.toml | 2 + scripts/supabase_doodling.py | 75 ------------------- 5 files changed, 24 insertions(+), 81 deletions(-) create mode 100644 .env.example delete mode 100644 scripts/supabase_doodling.py diff --git a/.claude/ops-rework/check_prod_missing_ratings.py b/.claude/ops-rework/check_prod_missing_ratings.py index 176f23d..0397b87 100644 --- a/.claude/ops-rework/check_prod_missing_ratings.py +++ b/.claude/ops-rework/check_prod_missing_ratings.py @@ -1,8 +1,14 @@ import asyncio +import sys +from pathlib import Path + import aiohttp import pandas as pd -AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"} +# Add project root so we can import db_calls +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from db_calls import AUTH_TOKEN + PROD_URL = "https://pd.manticorum.com/api" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44c592a --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Paper Dynasty API +PD_API_TOKEN=your-bearer-token-here diff --git a/db_calls.py b/db_calls.py index ae3d4a9..7cb3c33 100644 --- a/db_calls.py +++ b/db_calls.py @@ -1,10 +1,18 @@ +import os + import aiohttp import pybaseball as pb +from dotenv import load_dotenv from typing import Literal from exceptions import logger -AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"} +load_dotenv() + +_token = os.environ.get("PD_API_TOKEN") +if not _token: + raise EnvironmentError("PD_API_TOKEN environment variable is required") +AUTH_TOKEN = {"Authorization": f"Bearer {_token}"} DB_URL = "https://pd.manticorum.com/api" master_debug = True alt_database = None @@ -25,7 +33,7 @@ def param_char(other_params): def get_req_url( endpoint: str, api_ver: int = 2, object_id: int = None, params: list = None ): - req_url = f'{DB_URL}/v{api_ver}/{endpoint}{"/" if object_id is not None else ""}{object_id if object_id is not None else ""}' + req_url = f"{DB_URL}/v{api_ver}/{endpoint}{'/' if object_id is not None else ''}{object_id if object_id is not None else ''}" if params: other_params = False @@ -39,11 +47,11 @@ def get_req_url( def log_return_value(log_string: str): if master_debug: logger.info( - f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n' + f"return: {log_string[:1200]}{' [ S N I P P E D ]' if len(log_string) > 1200 else ''}\n" ) else: logger.debug( - f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n' + f"return: {log_string[:1200]}{' [ S N I P P E D ]' if len(log_string) > 1200 else ''}\n" ) @@ -183,4 +191,4 @@ def get_player_data( def player_desc(this_player) -> str: if this_player["p_name"] in this_player["description"]: return this_player["description"] - return f'{this_player["description"]} {this_player["p_name"]}' + return f"{this_player['description']} {this_player['p_name']}" diff --git a/pyproject.toml b/pyproject.toml index 82ec9b4..c19e0ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dependencies = [ "pydantic>=2.9.0", # AWS "boto3>=1.35.0", + # Environment + "python-dotenv>=1.0.0", # Scraping "beautifulsoup4>=4.12.0", "lxml>=5.0.0", diff --git a/scripts/supabase_doodling.py b/scripts/supabase_doodling.py deleted file mode 100644 index 068fa02..0000000 --- a/scripts/supabase_doodling.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import Literal -import requests -from exceptions import logger, log_exception - -AUTH_TOKEN = { - "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNucGhwbnV2aGp2cXprY2J3emRrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTgxMTc4NCwiZXhwIjoyMDYxMzg3Nzg0fQ.7dG_y2zU2PajBwTD8vut5GcWf3CSaZePkYW_hMf0fVg", - "apikey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNucGhwbnV2aGp2cXprY2J3emRrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTgxMTc4NCwiZXhwIjoyMDYxMzg3Nzg0fQ.7dG_y2zU2PajBwTD8vut5GcWf3CSaZePkYW_hMf0fVg", -} -DB_URL = "https://cnphpnuvhjvqzkcbwzdk.supabase.co/rest/v1" - - -def get_req_url(endpoint: str, params: list = None): - req_url = f"{DB_URL}/{endpoint}?" - - if params: - other_params = False - for x in params: - req_url += f'{"&" if other_params else "?"}{x[0]}={x[1]}' - other_params = True - - return req_url - - -def log_return_value(log_string: str, log_type: Literal["info", "debug"]): - if log_type == "info": - logger.info( - f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n' - ) - else: - logger.debug( - f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n' - ) - - -def db_get( - endpoint: str, - params: dict = None, - limit: int = 1000, - offset: int = 0, - none_okay: bool = True, - timeout: int = 3, -): - req_url = f"{DB_URL}/{endpoint}?limit={limit}&offset={offset}" - logger.info(f"HTTP GET: {req_url}, params: {params}") - - response = requests.request("GET", req_url, params=params, headers=AUTH_TOKEN) - logger.info(response) - - if response.status_code != requests.codes.ok: - log_exception(Exception, response.text) - - data = response.json() - if isinstance(data, list) and len(data) == 0: - if none_okay: - return None - else: - log_exception(Exception, "Query returned no results and none_okay = False") - - return data - - # async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session: - # async with session.get(req_url) as r: - # logger.info(f'session info: {r}') - # if r.status == 200: - # js = await r.json() - # log_return_value(f'{js}') - # return js - # elif none_okay: - # e = await r.text() - # logger.error(e) - # return None - # else: - # e = await r.text() - # logger.error(e) - # raise ValueError(f'DB: {e}') From b39d3283fdd39ca0187051bbc9c31fd38929fc64 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 16:03:45 -0500 Subject: [PATCH 02/16] fix: correct Dict[str, any] to Dict[str, Any] in type annotations (#15) Closes #15 `any` (lowercase) refers to the builtin function, not `typing.Any`. Added `Any` to the `typing` imports in both files and updated the `cardset` parameter annotation accordingly. Co-Authored-By: Claude Sonnet 4.6 --- batters/creation.py | 12 ++++++------ pitchers/creation.py | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/batters/creation.py b/batters/creation.py index da932e8..7beca6f 100644 --- a/batters/creation.py +++ b/batters/creation.py @@ -3,7 +3,7 @@ import urllib.parse import pandas as pd import numpy as np -from typing import Dict +from typing import Any, Dict from creation_helpers import ( get_all_pybaseball_ids, sanitize_name, @@ -158,8 +158,8 @@ async def create_new_players( { "p_name": f"{f_name} {l_name}", "cost": NEW_PLAYER_COST, - "image": f'{card_base_url}/{df_data["player_id"]}/battingcard' - f'{urllib.parse.quote("?d=")}{release_dir}', + "image": f"{card_base_url}/{df_data['player_id']}/battingcard" + f"{urllib.parse.quote('?d=')}{release_dir}", "mlbclub": CLUB_LIST[df_data["Tm_vL"]], "franchise": FRANCHISE_LIST[df_data["Tm_vL"]], "cardset_id": cardset["id"], @@ -302,7 +302,7 @@ async def calculate_batting_ratings(offense_stats: pd.DataFrame, to_post: bool): async def post_player_updates( - cardset: Dict[str, any], + cardset: Dict[str, Any], card_base_url: str, release_dir: str, player_desc: str, @@ -432,8 +432,8 @@ async def post_player_updates( [ ( "image", - f'{card_base_url}/{df_data["player_id"]}/battingcard' - f'{urllib.parse.quote("?d=")}{release_dir}', + f"{card_base_url}/{df_data['player_id']}/battingcard" + f"{urllib.parse.quote('?d=')}{release_dir}", ) ] ) diff --git a/pitchers/creation.py b/pitchers/creation.py index 064627d..88c64ab 100644 --- a/pitchers/creation.py +++ b/pitchers/creation.py @@ -1,7 +1,7 @@ import datetime import urllib.parse import pandas as pd -from typing import Dict +from typing import Any, Dict from creation_helpers import ( get_all_pybaseball_ids, @@ -196,8 +196,8 @@ async def create_new_players( { "p_name": f"{f_name} {l_name}", "cost": NEW_PLAYER_COST, - "image": f'{card_base_url}/{df_data["player_id"]}/' - f'pitchingcard{urllib.parse.quote("?d=")}{release_dir}', + "image": f"{card_base_url}/{df_data['player_id']}/" + f"pitchingcard{urllib.parse.quote('?d=')}{release_dir}", "mlbclub": CLUB_LIST[df_data["Tm_vL"]], "franchise": FRANCHISE_LIST[df_data["Tm_vL"]], "cardset_id": cardset["id"], @@ -268,7 +268,7 @@ async def calculate_pitching_cards( def create_pitching_card(df_data): logger.info( - f'Creating pitching card for {df_data["name_first"]} {df_data["name_last"]} / fg ID: {df_data["key_fangraphs"]}' + f"Creating pitching card for {df_data['name_first']} {df_data['name_last']} / fg ID: {df_data['key_fangraphs']}" ) pow_data = cde.pow_ratings( float(df_data["Inn_def"]), df_data["GS"], df_data["G"] @@ -298,11 +298,11 @@ async def calculate_pitching_cards( int(df_data["GF"]), int(df_data["SV"]), int(df_data["G"]) ), "hand": df_data["pitch_hand"], - "batting": f'#1W{df_data["pitch_hand"]}-C', + "batting": f"#1W{df_data['pitch_hand']}-C", } ) except Exception as e: - logger.error(f'Skipping fg ID {df_data["key_fangraphs"]} due to: {e}') + logger.error(f"Skipping fg ID {df_data['key_fangraphs']} due to: {e}") print("Calculating pitching cards...") pitching_stats.apply(create_pitching_card, axis=1) @@ -333,7 +333,7 @@ async def create_position( def create_pit_position(df_data): if df_data["key_bbref"] in df_p.index: - logger.debug(f'Running P stats for {df_data["p_name"]}') + logger.debug(f"Running P stats for {df_data['p_name']}") pit_positions.append( { "player_id": int(df_data["player_id"]), @@ -364,7 +364,7 @@ async def create_position( ) except Exception: logger.error( - f'Could not create pitcher position for {df_data["key_bbref"]}' + f"Could not create pitcher position for {df_data['key_bbref']}" ) print("Calculating pitcher fielding lines now...") @@ -386,7 +386,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers: pitching_ratings.extend(cpi.get_pitcher_ratings(df_data)) except Exception: logger.error( - f'Could not create a pitching card for {df_data["key_fangraphs"]}' + f"Could not create a pitching card for {df_data['key_fangraphs']}" ) print("Calculating card ratings...") @@ -400,7 +400,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers: async def post_player_updates( - cardset: Dict[str, any], + cardset: Dict[str, Any], player_description: str, card_base_url: str, release_dir: str, @@ -525,8 +525,8 @@ async def post_player_updates( [ ( "image", - f'{card_base_url}/{df_data["player_id"]}/pitchingcard' - f'{urllib.parse.quote("?d=")}{release_dir}', + f"{card_base_url}/{df_data['player_id']}/pitchingcard" + f"{urllib.parse.quote('?d=')}{release_dir}", ) ] ) From 09cb942435a08f969174580becc40c2aba18e646 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 16:33:00 -0500 Subject: [PATCH 03/16] chore: pin peewee and polars to exact versions (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #24 Pins the two unpinned dependencies in requirements.txt: - peewee (unversioned → 3.19.0) - polars (unversioned → 1.36.1) All other dependencies were already pinned with ==. Co-Authored-By: Claude Sonnet 4.6 --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7c11a2c..a401a00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,9 +23,9 @@ multidict==6.1.0 numpy==2.1.2 packaging==24.1 pandas==2.2.3 -peewee +peewee==3.19.0 pillow==11.0.0 -polars +polars==1.36.1 pluggy==1.5.0 propcache==0.2.0 # pyarrow==17.0.0 From 46fdde3d020e4c8810a534a64ccd1deb6237fc21 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 20:02:55 -0500 Subject: [PATCH 04/16] fix: narrow swallowed exception in get_pitching_peripherals() (#10) Closes #10 Replace `except Exception: pass` with `except KeyError: pass` so only the expected missing-attribute case (`cell["data-append-csv"]` not present) is silently skipped. Network errors, encoding issues, and other unexpected exceptions will now propagate instead of being hidden. Co-Authored-By: Claude Sonnet 4.6 --- creation_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/creation_helpers.py b/creation_helpers.py index f1081e0..e181a11 100644 --- a/creation_helpers.py +++ b/creation_helpers.py @@ -533,7 +533,7 @@ def get_pitching_peripherals(season: int): row_data.append(player_id) if len(headers) == 0: col_names.append("key_bbref") - except Exception: + except KeyError: pass row_data.append(cell.text) if len(headers) == 0: From 8e24b4e686d1cd152fd1e5fc904cc2510961a709 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 21:32:14 -0500 Subject: [PATCH 05/16] fix: return default 8 on XBT% parse error in running() (#8) Closes #8 Co-Authored-By: Claude Sonnet 4.6 --- batters/calcs_batter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/batters/calcs_batter.py b/batters/calcs_batter.py index 0693d0f..b039041 100644 --- a/batters/calcs_batter.py +++ b/batters/calcs_batter.py @@ -573,7 +573,7 @@ def stealing_line(steal_data: dict): else: good_jump = "2-12" - return f'{"*" if sd[2] else ""}{good_jump}/- ({sd[1] if sd[1] else "-"}-{sd[0] if sd[0] else "-"})' + return f"{'*' if sd[2] else ''}{good_jump}/- ({sd[1] if sd[1] else '-'}-{sd[0] if sd[0] else '-'})" def running(extra_base_pct: str): @@ -583,7 +583,7 @@ def running(extra_base_pct: str): xb_pct = float(extra_base_pct.strip("%")) / 80 except Exception as e: logger.error(f"calcs_batter running - {e}") - xb_pct = 20 + return 8 return max(min(round(6 + (10 * xb_pct)), 17), 8) @@ -693,11 +693,11 @@ def get_batter_ratings(df_data) -> List[dict]: logger.debug( f"all on base: {vl.hbp + vl.walk + vl.total_hits()} / all chances: {vl.total_chances()}" - f'{"*******ERROR ABOVE*******" if vl.hbp + vl.walk + vl.total_hits() != vl.total_chances() else ""}' + f"{'*******ERROR ABOVE*******' if vl.hbp + vl.walk + vl.total_hits() != vl.total_chances() else ''}" ) logger.debug( f"all on base: {vr.hbp + vr.walk + vr.total_hits()} / all chances: {vr.total_chances()}" - f'{"*******ERROR ABOVE*******" if vr.hbp + vr.walk + vr.total_hits() != vr.total_chances() else ""}' + f"{'*******ERROR ABOVE*******' if vr.hbp + vr.walk + vr.total_hits() != vr.total_chances() else ''}" ) vl.calculate_strikeouts(df_data["SO_vL"], df_data["AB_vL"], df_data["H_vL"]) From 1d96223c78cdc81ade30cb90955f5d29958ff94b Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 23:03:43 -0500 Subject: [PATCH 06/16] fix: use player_id instead of key_bbref in create_pit_position() (#7) Closes #7 The fallback branch of create_pit_position() used `int(df_data["key_bbref"])` which always raises ValueError for string IDs like 'verlaju01'. The exception was silently swallowed, causing pitchers without defensive stats to receive no position record at all. Fix: use `int(float(df_data["player_id"]))` to match the pattern used in create_pitching_card() on the same file. Co-Authored-By: Claude Sonnet 4.6 --- pitchers/creation.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pitchers/creation.py b/pitchers/creation.py index 064627d..09f38ad 100644 --- a/pitchers/creation.py +++ b/pitchers/creation.py @@ -196,8 +196,8 @@ async def create_new_players( { "p_name": f"{f_name} {l_name}", "cost": NEW_PLAYER_COST, - "image": f'{card_base_url}/{df_data["player_id"]}/' - f'pitchingcard{urllib.parse.quote("?d=")}{release_dir}', + "image": f"{card_base_url}/{df_data['player_id']}/" + f"pitchingcard{urllib.parse.quote('?d=')}{release_dir}", "mlbclub": CLUB_LIST[df_data["Tm_vL"]], "franchise": FRANCHISE_LIST[df_data["Tm_vL"]], "cardset_id": cardset["id"], @@ -268,7 +268,7 @@ async def calculate_pitching_cards( def create_pitching_card(df_data): logger.info( - f'Creating pitching card for {df_data["name_first"]} {df_data["name_last"]} / fg ID: {df_data["key_fangraphs"]}' + f"Creating pitching card for {df_data['name_first']} {df_data['name_last']} / fg ID: {df_data['key_fangraphs']}" ) pow_data = cde.pow_ratings( float(df_data["Inn_def"]), df_data["GS"], df_data["G"] @@ -298,11 +298,11 @@ async def calculate_pitching_cards( int(df_data["GF"]), int(df_data["SV"]), int(df_data["G"]) ), "hand": df_data["pitch_hand"], - "batting": f'#1W{df_data["pitch_hand"]}-C', + "batting": f"#1W{df_data['pitch_hand']}-C", } ) except Exception as e: - logger.error(f'Skipping fg ID {df_data["key_fangraphs"]} due to: {e}') + logger.error(f"Skipping fg ID {df_data['key_fangraphs']} due to: {e}") print("Calculating pitching cards...") pitching_stats.apply(create_pitching_card, axis=1) @@ -333,7 +333,7 @@ async def create_position( def create_pit_position(df_data): if df_data["key_bbref"] in df_p.index: - logger.debug(f'Running P stats for {df_data["p_name"]}') + logger.debug(f"Running P stats for {df_data['p_name']}") pit_positions.append( { "player_id": int(df_data["player_id"]), @@ -355,7 +355,7 @@ async def create_position( try: pit_positions.append( { - "player_id": int(df_data["key_bbref"]), + "player_id": int(float(df_data["player_id"])), "position": "P", "innings": 1, "range": 5, @@ -364,7 +364,7 @@ async def create_position( ) except Exception: logger.error( - f'Could not create pitcher position for {df_data["key_bbref"]}' + f"Could not create pitcher position for {df_data['key_bbref']}" ) print("Calculating pitcher fielding lines now...") @@ -386,7 +386,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers: pitching_ratings.extend(cpi.get_pitcher_ratings(df_data)) except Exception: logger.error( - f'Could not create a pitching card for {df_data["key_fangraphs"]}' + f"Could not create a pitching card for {df_data['key_fangraphs']}" ) print("Calculating card ratings...") @@ -525,8 +525,8 @@ async def post_player_updates( [ ( "image", - f'{card_base_url}/{df_data["player_id"]}/pitchingcard' - f'{urllib.parse.quote("?d=")}{release_dir}', + f"{card_base_url}/{df_data['player_id']}/pitchingcard" + f"{urllib.parse.quote('?d=')}{release_dir}", ) ] ) From b52c5418dbde29208ce8e193152ed372915e17b7 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 23:33:06 -0500 Subject: [PATCH 07/16] fix: resolve unreachable duplicate elif 'DO*' branch in result_string() (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second `elif "DO*" in data_string` was dead code — the first always matched, so `spaces -= 2` for the DO** variant was silently skipped. Fix: check "DO**" first (spaces -= 2), then "DO*" (spaces -= 1). Closes #6 Co-Authored-By: Claude Sonnet 4.6 --- creation_helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/creation_helpers.py b/creation_helpers.py index f1081e0..cb626e5 100644 --- a/creation_helpers.py +++ b/creation_helpers.py @@ -618,10 +618,10 @@ def result_string(tba_data, row_num, split_min=None, split_max=None): spaces -= 3 elif "SI**" in data_string: spaces += 1 + elif "DO**" in data_string: + spaces -= 2 elif "DO*" in data_string: spaces -= 1 - elif "DO*" in data_string: - spaces -= 2 elif "so" in data_string: spaces += 3 elif "gb" in data_string: From a2e374cd4fe168b3089ecee316f22407031f6718 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 21 Mar 2026 00:02:01 -0500 Subject: [PATCH 08/16] fix: correct get_of() opposite-field direction for switch hitters Switch hitters batting vs LHP hit right-handed (pull=lf, oppo=rf). Switch hitters batting vs RHP hit left-handed (pull=rf, oppo=lf). Copy-paste error had both pull_side branches returning the same value. Closes #5 Co-Authored-By: Claude Sonnet 4.6 --- creation_helpers.py | 48 ++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/creation_helpers.py b/creation_helpers.py index f1081e0..53d0535 100644 --- a/creation_helpers.py +++ b/creation_helpers.py @@ -595,21 +595,21 @@ def legal_splits(tot_chances): def result_string(tba_data, row_num, split_min=None, split_max=None): - bold1 = f'{"" if tba_data["bold"] else ""}' - bold2 = f'{"" if tba_data["bold"] else ""}' - row_string = f'{" " if int(row_num) < 10 else ""}{row_num}' + bold1 = f"{'' if tba_data['bold'] else ''}" + bold2 = f"{'' if tba_data['bold'] else ''}" + row_string = f"{' ' if int(row_num) < 10 else ''}{row_num}" if TESTING: print( - f'adding {tba_data["string"]} to row {row_num} / ' + f"adding {tba_data['string']} to row {row_num} / " f"split_min: {split_min} / split_max: {split_max}" ) # No splits; standard result if not split_min: - return f'{bold1}{row_string}-{tba_data["string"]}{bold2}' + return f"{bold1}{row_string}-{tba_data['string']}{bold2}" # With splits - split_nums = f'{split_min if split_min != 20 else ""}{"-" if split_min != 20 else ""}{split_max}' + split_nums = f"{split_min if split_min != 20 else ''}{'-' if split_min != 20 else ''}{split_max}" data_string = ( tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"] ) @@ -638,41 +638,39 @@ def result_string(tba_data, row_num, split_min=None, split_max=None): row_output = " " if TESTING: print(f"row_output: {row_output}") - return f'{bold1}{row_output}{data_string}{" " * spaces}{split_nums}{bold2}' + return f"{bold1}{row_output}{data_string}{' ' * spaces}{split_nums}{bold2}" def result_data( tba_data, row_num, tba_data_bottom=None, top_split_max=None, fatigue=False ): ret_data = {} - top_bold1 = f'{"" if tba_data["bold"] else ""}' - top_bold2 = f'{"" if tba_data["bold"] else ""}' + top_bold1 = f"{'' if tba_data['bold'] else ''}" + top_bold2 = f"{'' if tba_data['bold'] else ''}" bot_bold1 = None bot_bold2 = None if tba_data_bottom: - bot_bold1 = f'{"" if tba_data_bottom["bold"] else ""}' - bot_bold2 = f'{"" if tba_data_bottom["bold"] else ""}' + bot_bold1 = f"{'' if tba_data_bottom['bold'] else ''}" + bot_bold2 = f"{'' if tba_data_bottom['bold'] else ''}" if tba_data_bottom is None: ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}" ret_data["splits"] = f"{top_bold1}‎{top_bold2}" ret_data["result"] = ( - f"{top_bold1}" - f'{tba_data["string"]}{" •" if fatigue else ""}' - f"{top_bold2}" + f"{top_bold1}{tba_data['string']}{' •' if fatigue else ''}{top_bold2}" ) else: ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}\n" ret_data["splits"] = ( - f'{top_bold1}1{"-" if top_split_max != 1 else ""}' - f'{top_split_max if top_split_max != 1 else ""}{top_bold2}\n' - f'{bot_bold1}{top_split_max+1}{"-20" if top_split_max != 19 else ""}{bot_bold2}' + f"{top_bold1}1{'-' if top_split_max != 1 else ''}" + f"{top_split_max if top_split_max != 1 else ''}{top_bold2}\n" + f"{bot_bold1}{top_split_max + 1}{'-20' if top_split_max != 19 else ''}{bot_bold2}" ) ret_data["result"] = ( - f'{top_bold1}{tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"]}' + f"{top_bold1}{tba_data['sm-string'] if 'sm-string' in tba_data.keys() else tba_data['string']}" f"{top_bold2}\n" f"{bot_bold1}" - f'{tba_data_bottom["sm-string"] if "sm-string" in tba_data_bottom.keys() else tba_data_bottom["string"]}' + f"{tba_data_bottom['sm-string'] if 'sm-string' in tba_data_bottom.keys() else tba_data_bottom['string']}" f"{bot_bold2}" ) @@ -688,9 +686,9 @@ def get_of(batter_hand, pitcher_hand, pull_side=True): if batter_hand == "S": if pitcher_hand == "L": - return "rf" if pull_side else "rf" + return "lf" if pull_side else "rf" else: - return "lf" if pull_side else "lf" + return "rf" if pull_side else "lf" def get_col(col_num): @@ -729,7 +727,7 @@ def get_position_string(all_pos: list, inc_p: bool): for x in all_pos: if x.position == "OF": - of_arm = f'{"+" if "-" not in x.arm else ""}{x.arm}' + of_arm = f"{'+' if '-' not in x.arm else ''}{x.arm}" of_error = x.error of_innings = x.innings elif x.position == "CF": @@ -744,7 +742,7 @@ def get_position_string(all_pos: list, inc_p: bool): elif x.position == "C": all_def.append( ( - f'c-{x.range}({"+" if int(x.arm) >= 0 else ""}{x.arm}) e{x.error} T-{x.overthrow}(pb-{x.pb})', + f"c-{x.range}({'+' if int(x.arm) >= 0 else ''}{x.arm}) e{x.error} T-{x.overthrow}(pb-{x.pb})", x.innings, ) ) @@ -1079,7 +1077,7 @@ def mlbteam_and_franchise(mlbam_playerid): p_data["franchise"] = normalize_franchise(data["currentTeam"]["name"]) else: logger.error( - f'Could not set team for {mlbam_playerid}; received {data["currentTeam"]["name"]}' + f"Could not set team for {mlbam_playerid}; received {data['currentTeam']['name']}" ) else: logger.error( @@ -1222,5 +1220,5 @@ def get_hand(df_data): else: return "R" except Exception: - logger.error(f'Error in get_hand for {df_data["Name"]}') + logger.error(f"Error in get_hand for {df_data['Name']}") return "R" From 5b8d027d4671e4962a4706ceff1380d674da7efb Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 21 Mar 2026 02:02:10 -0500 Subject: [PATCH 09/16] fix: remove test_positions_df non-test that always passes (#16) Closes #16 Deleted test_positions_df which called an async function synchronously (returning a coroutine, not a DataFrame) and asserted True == True. Zero coverage. Also removed the now-unused pd_positions_df import. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_helpers.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 875d8ab..83d4268 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,10 +1,4 @@ -from creation_helpers import pd_positions_df, mround, sanitize_chance_output - - -def test_positions_df(): - cardset_19_pos = pd_positions_df(19) - - assert True == True +from creation_helpers import mround, sanitize_chance_output def test_mround(): From 937620e2e9d049c7ce25bd865684c1397dffa887 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 21 Mar 2026 07:03:38 -0500 Subject: [PATCH 10/16] =?UTF-8?q?fix:=20remove=20dead=20LAST=5FWEEK=5FRATI?= =?UTF-8?q?O=20ternary=20=E2=80=94=20both=20branches=20are=200.0=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #19 The conditional `0.0 if PLAYER_DESCRIPTION == 'Live' else 0.0` is dead code: both branches evaluate to the same value. Simplified to a direct assignment. Co-Authored-By: Claude Sonnet 4.6 --- retrosheet_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/retrosheet_data.py b/retrosheet_data.py index 6f41501..8b18204 100644 --- a/retrosheet_data.py +++ b/retrosheet_data.py @@ -67,7 +67,7 @@ START_DATE = 20050403 # YYYYMMDD format - 2005 Opening Day # END_DATE = 20050531 # YYYYMMDD format - May PotM END_DATE = 20050731 # End of July 2005 POST_DATA = True -LAST_WEEK_RATIO = 0.0 if PLAYER_DESCRIPTION == "Live" else 0.0 +LAST_WEEK_RATIO = 0.0 LAST_TWOWEEKS_RATIO = 0.0 LAST_MONTH_RATIO = 0.0 From 9e48616274a555f252633f8825fa678c1220e2c5 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 18:01:48 -0500 Subject: [PATCH 11/16] fix: use logger.exception() in calculate_pitcher_ratings error handler Replaces logger.error() with logger.exception() so the full stack trace is captured when a pitcher card fails to generate, making it possible to diagnose the root cause rather than just seeing the error message. Closes #17 Co-Authored-By: Claude Sonnet 4.6 --- pitchers/creation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pitchers/creation.py b/pitchers/creation.py index 2ccd695..fa9fdd5 100644 --- a/pitchers/creation.py +++ b/pitchers/creation.py @@ -301,8 +301,10 @@ async def calculate_pitching_cards( "batting": f"#1W{df_data['pitch_hand']}-C", } ) - except Exception as e: - logger.error(f"Skipping fg ID {df_data['key_fangraphs']} due to: {e}") + except Exception: + logger.exception( + f"Skipping fg ID {df_data['key_fangraphs']} due to exception" + ) print("Calculating pitching cards...") pitching_stats.apply(create_pitching_card, axis=1) From df6e96bc76046948bb81ebb1e16cb5e78e3da6b0 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 18:32:15 -0500 Subject: [PATCH 12/16] fix: replace wildcard import from db_calls_card_creation (#13) Closes #13 Replace `from db_calls_card_creation import *` with an explicit `from db_calls_card_creation import PitcherData`. Only PitcherData is referenced in creation_helpers.py; the wildcard was also pulling in all Peewee ORM internals via a transitive `from peewee import *`, polluting the namespace. Co-Authored-By: Claude Sonnet 4.6 --- creation_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/creation_helpers.py b/creation_helpers.py index 117c6ea..35f576f 100644 --- a/creation_helpers.py +++ b/creation_helpers.py @@ -10,7 +10,7 @@ import requests import time from db_calls import db_get -from db_calls_card_creation import * +from db_calls_card_creation import PitcherData from bs4 import BeautifulSoup # Card Creation Constants From d43927258aa4766608639f2697d4a9c8c73366f8 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 21 Mar 2026 13:04:09 -0500 Subject: [PATCH 13/16] fix: remove import-time derived globals in retrosheet_data.py (#14) Closes #14 Five globals (MIN_PA_VL, MIN_PA_VR, MIN_TBF_VL, MIN_TBF_VR, CARDSET_ID) were derived from PLAYER_DESCRIPTION at module load time, creating a hidden ordering dependency: any value baked in before the CLI overrides PLAYER_DESCRIPTION would be silently wrong if a caller relied on the derived relationship. The CLI explicitly sets all of them anyway, so replacing with scalar defaults makes the module self-contained and safe. Also collapses LAST_WEEK_RATIO dead ternary (both branches were 0.0). Co-Authored-By: Claude Sonnet 4.6 --- retrosheet_data.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/retrosheet_data.py b/retrosheet_data.py index 8b18204..6188d95 100644 --- a/retrosheet_data.py +++ b/retrosheet_data.py @@ -53,13 +53,11 @@ PROMO_INCLUSION_RETRO_IDS = [ # 'haraa001', # Aaron Harang (SP) # 'hofft001', # Trevor Hoffman (RP) ] -MIN_PA_VL = 20 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM -MIN_PA_VR = 40 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM -MIN_TBF_VL = MIN_PA_VL -MIN_TBF_VR = MIN_PA_VR -CARDSET_ID = ( - 27 if "live" in PLAYER_DESCRIPTION.lower() else 28 -) # 27: 2005 Live, 28: 2005 Promos +MIN_PA_VL = 20 # 1 for PotM +MIN_PA_VR = 40 # 1 for PotM +MIN_TBF_VL = 20 +MIN_TBF_VR = 40 +CARDSET_ID = 27 # 27: 2005 Live, 28: 2005 Promos # Per-Update Parameters SEASON_PCT = 81 / 162 # Through end of July (~half season) From 63a30bd434db7b65616ea0e985b3f5e940851aad Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Mar 2026 20:35:08 -0500 Subject: [PATCH 14/16] fix: derive SEASON_PCT from date range instead of hardcoding half-season (#9) Closes #9 Previously SEASON_PCT was hardcoded to 81/162 (~0.5) while END_DATE was set to 20050731 (~65% through the season). Running retrosheet_data.py directly (without the CLI which overrides SEASON_PCT at runtime) would silently generate cards using half-season normalizations on stats covering a larger portion of the season. Fix: move START_DATE/END_DATE before SEASON_PCT and derive SEASON_PCT from the date range using SEASON_END_DATE (2005 regular season end). Now changing END_DATE automatically produces the correct SEASON_PCT. Co-Authored-By: Claude Sonnet 4.6 --- retrosheet_data.py | 39 +++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/retrosheet_data.py b/retrosheet_data.py index 6188d95..85b2fc9 100644 --- a/retrosheet_data.py +++ b/retrosheet_data.py @@ -60,10 +60,21 @@ MIN_TBF_VR = 40 CARDSET_ID = 27 # 27: 2005 Live, 28: 2005 Promos # Per-Update Parameters -SEASON_PCT = 81 / 162 # Through end of July (~half season) START_DATE = 20050403 # YYYYMMDD format - 2005 Opening Day # END_DATE = 20050531 # YYYYMMDD format - May PotM END_DATE = 20050731 # End of July 2005 +SEASON_END_DATE = 20051002 # 2005 regular season end date (used to derive SEASON_PCT) +SEASON_PCT = min( + ( + datetime.datetime.strptime(str(END_DATE), "%Y%m%d") + - datetime.datetime.strptime(str(START_DATE), "%Y%m%d") + ).days + / ( + datetime.datetime.strptime(str(SEASON_END_DATE), "%Y%m%d") + - datetime.datetime.strptime(str(START_DATE), "%Y%m%d") + ).days, + 1.0, +) POST_DATA = True LAST_WEEK_RATIO = 0.0 LAST_TWOWEEKS_RATIO = 0.0 @@ -1427,7 +1438,7 @@ def calc_pitching_cards(ps: pd.DataFrame, season_pct: float) -> pd.DataFrame: "closer_rating": [ cpi.closer_rating(int(row["GF"]), int(row["SV"]), int(row["G"])) ], - "batting": [f'#1W{row["pitch_hand"].upper()}-C'], + "batting": [f"#1W{row['pitch_hand'].upper()}-C"], } ) return y.loc[0] @@ -1596,7 +1607,7 @@ def calc_positions(bs: pd.DataFrame) -> pd.DataFrame: ]: if row["key_bbref"] in pos_df.index: logger.info( - f'Running {position} stats for {row["use_name"]} {row["last_name"]}' + f"Running {position} stats for {row['use_name']} {row['last_name']}" ) try: if "bis_runs_total" in pos_df.columns: @@ -1863,8 +1874,8 @@ async def get_or_post_players( def new_player_payload(row, ratings_df: pd.DataFrame): return { - "p_name": f'{row["use_name"]} {row["last_name"]}', - "cost": f'{ratings_df.loc[row['key_bbref']]["cost"]}', + "p_name": f"{row['use_name']} {row['last_name']}", + "cost": f"{ratings_df.loc[row['key_bbref']]['cost']}", "image": "change-me", "mlbclub": CLUB_LIST[row["Tm"]], "franchise": FRANCHISE_LIST[row["Tm"]], @@ -1914,11 +1925,11 @@ async def get_or_post_players( # Update positions for existing players too all_pos = get_player_record_pos(def_rat_df, row) patch_params = [ - ("cost", f'{bat_rat_df.loc[row['key_bbref']]["cost"]}'), + ("cost", f"{bat_rat_df.loc[row['key_bbref']]['cost']}"), ("rarity_id", int(bat_rat_df.loc[row["key_bbref"]]["rarity_id"])), ( "image", - f'{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}', + f"{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", ), ] # Add position updates - set all 8 slots to clear any old positions @@ -1962,7 +1973,7 @@ async def get_or_post_players( params=[ ( "image", - f'{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}', + f"{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", ) ], ) @@ -2001,11 +2012,11 @@ async def get_or_post_players( # Determine pitcher positions based on ratings patch_params = [ - ("cost", f'{pit_rat_df.loc[row['key_bbref']]["cost"]}'), + ("cost", f"{pit_rat_df.loc[row['key_bbref']]['cost']}"), ("rarity_id", int(pit_rat_df.loc[row["key_bbref"]]["rarity_id"])), ( "image", - f'{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}', + f"{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", ), ] @@ -2079,7 +2090,7 @@ async def get_or_post_players( params=[ ( "image", - f'{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}', + f"{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", ) ], ) @@ -2103,10 +2114,10 @@ async def get_or_post_players( raise KeyError("Could not get players - not enough stat DFs were supplied") pd.DataFrame(player_deltas[1:], columns=player_deltas[0]).to_csv( - f'{"batter" if bstat_df is not None else "pitcher"}-deltas.csv' + f"{'batter' if bstat_df is not None else 'pitcher'}-deltas.csv" ) pd.DataFrame(new_players[1:], columns=new_players[0]).to_csv( - f'new-{"batter" if bstat_df is not None else "pitcher"}s.csv' + f"new-{'batter' if bstat_df is not None else 'pitcher'}s.csv" ) players_df = pd.DataFrame(all_players).set_index("bbref_id") @@ -2278,7 +2289,7 @@ async def post_positions(pos_df: pd.DataFrame, delete_existing: bool = False): deleted_count += 1 except Exception as e: logger.warning( - f'Failed to delete cardposition {pos["id"]}: {e}' + f"Failed to delete cardposition {pos['id']}: {e}" ) logger.info(f"Deleted {deleted_count} positions for players in current run") From 50ee2d0446f5787dc6be524b18c8f63acaec66d5 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 21 Mar 2026 09:02:53 -0500 Subject: [PATCH 15/16] fix: use archetype role ratings in pitcher card creation (#11) Closes #11 `starter_rating`, `relief_rating`, and `closer_rating` were hardcoded stubs (5/5/None) in `create_pitching_card`. The chosen `PitcherArchetype` already carries these values; now they are propagated through `card_data` when the pitcher workflow builds its initial dict and consumed correctly when writing the pitching card record to the database. Co-Authored-By: Claude Sonnet 4.6 --- custom_cards/interactive_creator.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/custom_cards/interactive_creator.py b/custom_cards/interactive_creator.py index ef1465d..70c0c9d 100644 --- a/custom_cards/interactive_creator.py +++ b/custom_cards/interactive_creator.py @@ -179,7 +179,12 @@ class CustomCardCreator: else: calc = PitcherRatingCalculator(archetype) ratings = calc.calculate_ratings(pitchingcard_id=0) # Temp ID - card_data = {"ratings": ratings} + 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( @@ -347,7 +352,7 @@ class CustomCardCreator: 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}" + f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {rating['obp'] + rating['slg']:.3f}" ) # Show hit distribution @@ -364,7 +369,7 @@ class CustomCardCreator: + 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})" + 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 @@ -389,7 +394,7 @@ class CustomCardCreator: ) ) 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})" + 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 @@ -580,9 +585,9 @@ class CustomCardCreator: "name_first": player_info["name_first"], "name_last": player_info["name_last"], "hand": player_info["hand"], - "starter_rating": 5, # TODO: Get from archetype - "relief_rating": 5, # TODO: Get from archetype - "closer_rating": None, # TODO: Get from archetype + "starter_rating": card_data["starter_rating"], + "relief_rating": card_data["relief_rating"], + "closer_rating": card_data["closer_rating"], } ] } From 962b9cf6f1d35faad3e52338e7983ee0d779b249 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 21 Mar 2026 17:04:03 -0500 Subject: [PATCH 16/16] feat: implement tweak_archetype() and manual_adjustments() (#12) 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 --- custom_cards/interactive_creator.py | 164 ++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 8 deletions(-) diff --git a/custom_cards/interactive_creator.py b/custom_cards/interactive_creator.py index 70c0c9d..9a1dde7 100644 --- a/custom_cards/interactive_creator.py +++ b/custom_cards/interactive_creator.py @@ -6,6 +6,7 @@ baseball archetypes with iterative review and refinement. """ import asyncio +import copy import sys from typing import Literal from datetime import datetime @@ -425,10 +426,68 @@ class CustomCardCreator: print("-" * 70) print("\nAdjust key percentages (press Enter to keep current value):\n") - # TODO: Implement percentage tweaking - # For now, return unchanged - print("(Feature coming soon - manual adjustments available in option 3)") - return card_data + 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 @@ -439,10 +498,99 @@ class CustomCardCreator: print("-" * 70) print("\nDirectly edit D20 chances (must sum to 108):\n") - # TODO: Implement manual adjustments - # For now, return unchanged - print("(Feature coming soon)") - return card_data + 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,