"""Refractor rating boost service (Phase 2). Pure functions for computing boosted card ratings when a player reaches a new Refractor tier. Called by the orchestration layer in apply_tier_boost(). Batter boost: fixed +0.5 to four offensive columns per tier. Pitcher boost: 1.5 TB-budget priority algorithm per tier. """ from decimal import Decimal, ROUND_HALF_UP import hashlib import json import logging logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Batter constants # --------------------------------------------------------------------------- BATTER_POSITIVE_DELTAS: dict[str, Decimal] = { "homerun": Decimal("0.50"), "double_pull": Decimal("0.50"), "single_one": Decimal("0.50"), "walk": Decimal("0.50"), } BATTER_NEGATIVE_DELTAS: dict[str, Decimal] = { "strikeout": Decimal("-1.50"), "groundout_a": Decimal("-0.50"), } # All 22 outcome columns that must sum to 108. BATTER_OUTCOME_COLUMNS: list[str] = [ "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", ] # --------------------------------------------------------------------------- # Pitcher constants # --------------------------------------------------------------------------- # (column, tb_cost) pairs in priority order. PITCHER_PRIORITY: list[tuple[str, int]] = [ ("double_cf", 2), ("double_three", 2), ("double_two", 2), ("single_center", 1), ("single_two", 1), ("single_one", 1), ("bp_single", 1), ("walk", 1), ("homerun", 4), ("bp_homerun", 4), ("triple", 3), ("hbp", 1), ] # All 18 variable outcome columns that must sum to 79. PITCHER_OUTCOME_COLUMNS: list[str] = [ "homerun", "bp_homerun", "triple", "double_three", "double_two", "double_cf", "single_two", "single_one", "single_center", "bp_single", "hbp", "walk", "strikeout", "flyout_lf_b", "flyout_cf_b", "flyout_rf_b", "groundout_a", "groundout_b", ] # Cross-check columns that are NEVER modified by the boost algorithm. PITCHER_XCHECK_COLUMNS: list[str] = [ "xcheck_p", "xcheck_c", "xcheck_1b", "xcheck_2b", "xcheck_3b", "xcheck_ss", "xcheck_lf", "xcheck_cf", "xcheck_rf", ] PITCHER_TB_BUDGET = Decimal("1.5") # --------------------------------------------------------------------------- # Batter boost # --------------------------------------------------------------------------- def apply_batter_boost(ratings_dict: dict) -> dict: """Apply one Refractor tier boost to a batter's outcome ratings. Adds fixed positive deltas to four offensive columns (homerun, double_pull, single_one, walk) while funding that increase by reducing strikeout and groundout_a. A 0-floor is enforced on negative columns: if the full reduction cannot be taken, positive deltas are scaled proportionally so that the invariant (22 columns sum to 108.0) is always preserved. Args: ratings_dict: Dict containing at minimum all 22 BATTER_OUTCOME_COLUMNS as numeric (int or float) values. Returns: New dict with the same keys as ratings_dict, with boosted outcome column values as floats. All other keys are passed through unchanged. Raises: KeyError: If any BATTER_OUTCOME_COLUMNS key is missing from ratings_dict. """ result = dict(ratings_dict) # Step 1 — convert the 22 outcome columns to Decimal for precise arithmetic. ratings: dict[str, Decimal] = { col: Decimal(str(result[col])) for col in BATTER_OUTCOME_COLUMNS } # Step 2 — apply negative deltas with 0-floor, tracking how much was # actually removed versus how much was requested. total_requested_reduction = Decimal("0") total_actually_reduced = Decimal("0") for col, delta in BATTER_NEGATIVE_DELTAS.items(): requested = abs(delta) total_requested_reduction += requested actual = min(requested, ratings[col]) ratings[col] -= actual total_actually_reduced += actual # Step 3 — check whether any truncation occurred. total_truncated = total_requested_reduction - total_actually_reduced # Step 4 — scale positive deltas if we couldn't take the full reduction. if total_truncated > Decimal("0"): # Positive additions must equal what was actually reduced so the # 108-sum is preserved. total_requested_addition = sum(BATTER_POSITIVE_DELTAS.values()) if total_requested_addition > Decimal("0"): scale = total_actually_reduced / total_requested_addition else: scale = Decimal("0") logger.warning( "refractor_boost: batter truncation occurred — " "requested_reduction=%.4f actually_reduced=%.4f scale=%.6f", float(total_requested_reduction), float(total_actually_reduced), float(scale), ) # Quantize the first N-1 deltas independently, then assign the last # delta as the remainder so the total addition equals # total_actually_reduced exactly (no quantize drift across 4 ops). pos_cols = list(BATTER_POSITIVE_DELTAS.keys()) positive_deltas = {} running_sum = Decimal("0") for col in pos_cols[:-1]: scaled = (BATTER_POSITIVE_DELTAS[col] * scale).quantize( Decimal("0.000001"), rounding=ROUND_HALF_UP ) positive_deltas[col] = scaled running_sum += scaled last_delta = total_actually_reduced - running_sum positive_deltas[pos_cols[-1]] = max(last_delta, Decimal("0")) else: positive_deltas = BATTER_POSITIVE_DELTAS # Step 5 — apply (possibly scaled) positive deltas. for col, delta in positive_deltas.items(): ratings[col] += delta # Write boosted values back as floats. for col in BATTER_OUTCOME_COLUMNS: result[col] = float(ratings[col]) return result # --------------------------------------------------------------------------- # Pitcher boost # --------------------------------------------------------------------------- def apply_pitcher_boost(ratings_dict: dict, tb_budget: float = 1.5) -> dict: """Apply one Refractor tier boost to a pitcher's outcome ratings. Iterates through PITCHER_PRIORITY in order, converting as many outcome chances as the TB budget allows into strikeouts. The TB cost per chance varies by outcome type (e.g. a double costs 2 TB budget units, a single costs 1). The strikeout column absorbs all converted chances. X-check columns (xcheck_p through xcheck_rf) are never touched. Args: ratings_dict: Dict containing at minimum all 18 PITCHER_OUTCOME_COLUMNS as numeric (int or float) values. tb_budget: Total base budget available for this boost tier. Defaults to 1.5 (PITCHER_TB_BUDGET). Returns: New dict with the same keys as ratings_dict, with boosted outcome column values as floats. All other keys are passed through unchanged. Raises: KeyError: If any PITCHER_OUTCOME_COLUMNS key is missing from ratings_dict. """ result = dict(ratings_dict) # Step 1 — convert outcome columns to Decimal, set remaining budget. ratings: dict[str, Decimal] = { col: Decimal(str(result[col])) for col in PITCHER_OUTCOME_COLUMNS } remaining = Decimal(str(tb_budget)) # Step 2 — iterate priority list, draining budget. for col, tb_cost in PITCHER_PRIORITY: if ratings[col] <= Decimal("0"): continue tb_cost_d = Decimal(str(tb_cost)) max_chances = remaining / tb_cost_d chances_to_take = min(ratings[col], max_chances) ratings[col] -= chances_to_take ratings["strikeout"] += chances_to_take remaining -= chances_to_take * tb_cost_d if remaining <= Decimal("0"): break # Step 3 — warn if budget was not fully spent (rare, indicates all priority # columns were already at zero). if remaining > Decimal("0"): logger.warning( "refractor_boost: pitcher TB budget not fully spent — " "remaining=%.4f of tb_budget=%.4f", float(remaining), tb_budget, ) # Write boosted values back as floats. for col in PITCHER_OUTCOME_COLUMNS: result[col] = float(ratings[col]) return result # --------------------------------------------------------------------------- # Variant hash # --------------------------------------------------------------------------- def compute_variant_hash( player_id: int, evolution_tier: int, cosmetics: list[str] | None = None, ) -> int: """Compute a stable, deterministic variant identifier for a boosted card. Hashes the combination of player_id, evolution_tier, and an optional sorted list of cosmetic identifiers to produce a compact integer suitable for use as a database variant key. The result is derived from the first 8 hex characters of a SHA-256 digest, so collisions are extremely unlikely in practice. variant=0 is reserved and will never be returned; any hash that resolves to 0 is remapped to 1. Args: player_id: Player primary key. evolution_tier: Refractor tier (0–4) the card has reached. cosmetics: Optional list of cosmetic tag strings (e.g. special art identifiers). Order is normalised — callers need not sort. Returns: A positive integer in the range [1, 2^32 - 1]. """ inputs = { "player_id": player_id, "evolution_tier": evolution_tier, "cosmetics": sorted(cosmetics or []), } raw = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest() result = int(raw[:8], 16) return result if result != 0 else 1 # variant=0 is reserved