diff --git a/batters/creation.py b/batters/creation.py index 7beca6f..0a666eb 100644 --- a/batters/creation.py +++ b/batters/creation.py @@ -18,11 +18,11 @@ from creation_helpers import ( calculate_rarity_cost_adjustment, DEFAULT_BATTER_OPS, ) -from db_calls import db_post, db_get, db_put, db_patch +from db_calls import db_post, db_get, db_put, db_patch, get_fully_evolved_players from . import calcs_batter as cba from defenders import calcs_defense as cde from exceptions import logger -from rarity_thresholds import get_batter_thresholds +from rarity_thresholds import get_batter_thresholds, rarity_is_downgrade async def pd_battingcards_df(cardset_id: int): @@ -384,6 +384,24 @@ async def post_player_updates( ) player_updates = {} # { : [ (param pairs) ] } + + # T4 rarity guard: identify players where OPS-derived rarity would be a + # downgrade and check whether any of them have a fully-evolved refractor + # state. If so, their current (T4-earned) rarity is the floor and must + # not be overwritten. Falls back to empty set if the API endpoint is + # unavailable, so the guard degrades safely without blocking the pipeline. + downgrade_candidates = player_data[ + player_data.apply( + lambda r: rarity_is_downgrade(r["rarity"], r["new_rarity_id"]), axis=1 + ) + ]["player_id"].tolist() + t4_protected_ids = await get_fully_evolved_players(downgrade_candidates) + if t4_protected_ids: + logger.info( + f"batters.creation.post_player_updates - {len(t4_protected_ids)} player(s) " + f"protected from rarity downgrade by T4 refractor floor: {t4_protected_ids}" + ) + rarity_group = player_data.query("rarity == new_rarity_id").groupby("rarity") average_ops = rarity_group["total_OPS"].mean().to_dict() @@ -454,13 +472,27 @@ async def post_player_updates( ) elif df_data["rarity"] != df_data["new_rarity_id"]: - # Calculate adjusted cost for rarity change using lookup table - new_cost = calculate_rarity_cost_adjustment( - old_rarity=df_data["rarity"], - new_rarity=df_data["new_rarity_id"], - old_cost=df_data["cost"], - ) - params.extend([("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])]) + # T4 guard: skip rarity downgrades for fully-evolved cards so that + # a T4-earned rarity bump is not silently reverted by the pipeline. + if ( + rarity_is_downgrade(df_data["rarity"], df_data["new_rarity_id"]) + and df_data.player_id in t4_protected_ids + ): + logger.info( + f"batters.creation.post_player_updates - Skipping rarity downgrade " + f"for player_id={df_data.player_id}: T4 floor rarity={df_data['rarity']}, " + f"OPS rarity={df_data['new_rarity_id']}" + ) + else: + # Calculate adjusted cost for rarity change using lookup table + new_cost = calculate_rarity_cost_adjustment( + old_rarity=df_data["rarity"], + new_rarity=df_data["new_rarity_id"], + old_cost=df_data["cost"], + ) + params.extend( + [("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])] + ) if len(params) > 0: if df_data.player_id not in player_updates.keys(): diff --git a/db_calls.py b/db_calls.py index faf2cc0..1f686ec 100644 --- a/db_calls.py +++ b/db_calls.py @@ -186,6 +186,31 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3) raise ValueError(f"DB: {e}") +async def get_fully_evolved_players(player_ids: list) -> set: + """Return the subset of player_ids that have any fully-evolved (T4) refractor card state. + + Calls GET /api/v2/refractor/fully-evolved with a comma-separated player_ids + query parameter. Returns an empty set if the endpoint is unavailable (404, + error, or missing field) so the caller degrades safely — no T4 protection is + applied rather than blocking the pipeline. + + NOTE: This endpoint does not yet exist in the database API. It must return + {"player_ids": [, ...]} listing player IDs with fully_evolved=True. + Until it is added, this function always returns an empty set. + """ + if not player_ids: + return set() + ids_param = ",".join(str(pid) for pid in player_ids) + result = await db_get( + "refractor/fully-evolved", + params=[("player_ids", ids_param)], + none_okay=True, + ) + if result is None or "player_ids" not in result: + return set() + return set(result["player_ids"]) + + def get_player_data( player_id: str, id_type: Literal["bbref", "fangraphs"], diff --git a/pitchers/creation.py b/pitchers/creation.py index fa9fdd5..889def2 100644 --- a/pitchers/creation.py +++ b/pitchers/creation.py @@ -17,11 +17,11 @@ from creation_helpers import ( DEFAULT_STARTER_OPS, DEFAULT_RELIEVER_OPS, ) -from db_calls import db_post, db_get, db_put, db_patch +from db_calls import db_post, db_get, db_put, db_patch, get_fully_evolved_players from defenders import calcs_defense as cde from . import calcs_pitcher as cpi from exceptions import logger -from rarity_thresholds import get_pitcher_thresholds +from rarity_thresholds import get_pitcher_thresholds, rarity_is_downgrade def get_pitching_stats( @@ -463,6 +463,20 @@ async def post_player_updates( ) player_updates = {} # { : [ (param pairs) ] } + + # T4 rarity guard: same protection as batters — see batters/creation.py. + downgrade_candidates = player_data[ + player_data.apply( + lambda r: rarity_is_downgrade(r["rarity"], r["new_rarity_id"]), axis=1 + ) + ]["player_id"].tolist() + t4_protected_ids = await get_fully_evolved_players(downgrade_candidates) + if t4_protected_ids: + logger.info( + f"pitchers.creation.post_player_updates - {len(t4_protected_ids)} player(s) " + f"protected from rarity downgrade by T4 refractor floor: {t4_protected_ids}" + ) + sp_rarity_group = player_data.query( "rarity == new_rarity_id and starter_rating >= 4" ).groupby("rarity") @@ -551,13 +565,27 @@ async def post_player_updates( ) elif df_data["rarity"] != df_data["new_rarity_id"]: - # Calculate adjusted cost for rarity change using lookup table - new_cost = calculate_rarity_cost_adjustment( - old_rarity=df_data["rarity"], - new_rarity=df_data["new_rarity_id"], - old_cost=df_data["cost"], - ) - params.extend([("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])]) + # T4 guard: skip rarity downgrades for fully-evolved cards so that + # a T4-earned rarity bump is not silently reverted by the pipeline. + if ( + rarity_is_downgrade(df_data["rarity"], df_data["new_rarity_id"]) + and df_data.player_id in t4_protected_ids + ): + logger.info( + f"pitchers.creation.post_player_updates - Skipping rarity downgrade " + f"for player_id={df_data.player_id}: T4 floor rarity={df_data['rarity']}, " + f"OPS rarity={df_data['new_rarity_id']}" + ) + else: + # Calculate adjusted cost for rarity change using lookup table + new_cost = calculate_rarity_cost_adjustment( + old_rarity=df_data["rarity"], + new_rarity=df_data["new_rarity_id"], + old_cost=df_data["cost"], + ) + params.extend( + [("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])] + ) if len(params) > 0: if df_data.player_id not in player_updates.keys(): diff --git a/rarity_thresholds.py b/rarity_thresholds.py index ed0982d..ac91961 100644 --- a/rarity_thresholds.py +++ b/rarity_thresholds.py @@ -144,3 +144,25 @@ def get_batter_thresholds(season: int) -> BatterRarityThresholds: return BATTER_THRESHOLDS_2025 else: return BATTER_THRESHOLDS_2024 + + +# Ordered from worst to best rarity. Used for ladder comparisons such as the +# T4 refractor guard — do not change the order. +RARITY_LADDER = [5, 4, 3, 2, 1, 99] # Common → Bronze → Silver → Gold → Diamond → HoF + + +def rarity_is_downgrade(current_rarity_id: int, new_rarity_id: int) -> bool: + """Return True if new_rarity_id is a less prestigious tier than current_rarity_id. + + Uses the RARITY_LADDER ordering. Unknown IDs are treated as position 0 + (worst), so an unknown current rarity will never trigger a downgrade guard. + """ + try: + current_pos = RARITY_LADDER.index(current_rarity_id) + except ValueError: + return False + try: + new_pos = RARITY_LADDER.index(new_rarity_id) + except ValueError: + return False + return current_pos > new_pos