diff --git a/app/db_engine.py b/app/db_engine.py index 3e0c786..dcff8ee 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -474,6 +474,7 @@ class Card(BaseModel): team = ForeignKeyField(Team, null=True) pack = ForeignKeyField(Pack, null=True) value = IntegerField(default=0) + variant = IntegerField(null=True, default=None) def __str__(self): if self.player: @@ -755,6 +756,7 @@ class BattingCard(BaseModel): running = IntegerField() offense_col = IntegerField() hand = CharField(default="R") + image_url = CharField(null=True, max_length=500) class Meta: database = db @@ -824,6 +826,7 @@ class PitchingCard(BaseModel): batting = CharField(null=True) offense_col = IntegerField() hand = CharField(default="R") + image_url = CharField(null=True, max_length=500) class Meta: database = db @@ -1232,6 +1235,7 @@ class RefractorCardState(BaseModel): current_value = FloatField(default=0.0) fully_evolved = BooleanField(default=False) last_evaluated_at = DateTimeField(null=True) + variant = IntegerField(null=True) class Meta: database = db @@ -1290,9 +1294,29 @@ class RefractorCosmetic(BaseModel): table_name = "refractor_cosmetic" +class RefractorBoostAudit(BaseModel): + card_state = ForeignKeyField(RefractorCardState, on_delete="CASCADE") + tier = IntegerField() # 1-4 + battingcard = ForeignKeyField(BattingCard, null=True) + pitchingcard = ForeignKeyField(PitchingCard, null=True) + variant_created = IntegerField() + boost_delta_json = TextField() # JSONB in PostgreSQL; TextField for SQLite test compat + applied_at = DateTimeField(default=datetime.now) + + class Meta: + database = db + table_name = "refractor_boost_audit" + + if not SKIP_TABLE_CREATION: db.create_tables( - [RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic], + [ + RefractorTrack, + RefractorCardState, + RefractorTierBoost, + RefractorCosmetic, + RefractorBoostAudit, + ], safe=True, ) diff --git a/app/services/refractor_boost.py b/app/services/refractor_boost.py new file mode 100644 index 0000000..321c34c --- /dev/null +++ b/app/services/refractor_boost.py @@ -0,0 +1,315 @@ +"""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, + refractor_tier: int, + cosmetics: list[str] | None = None, +) -> int: + """Compute a stable, deterministic variant identifier for a boosted card. + + Hashes the combination of player_id, refractor_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. + refractor_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, + "refractor_tier": refractor_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 diff --git a/migrations/2026-03-28_refractor_phase2_boost.sql b/migrations/2026-03-28_refractor_phase2_boost.sql new file mode 100644 index 0000000..599b9b0 --- /dev/null +++ b/migrations/2026-03-28_refractor_phase2_boost.sql @@ -0,0 +1,47 @@ +-- Migration: Refractor Phase 2 — rating boost support +-- Date: 2026-03-28 +-- Purpose: Extends the Refractor system to track and audit rating boosts +-- applied at each tier-up. Adds a variant column to +-- refractor_card_state (mirrors card.variant for promoted copies) +-- and creates the refractor_boost_audit table to record the +-- boost delta, source card, and variant assigned at each tier. +-- +-- Tables affected: +-- refractor_card_state — new column: variant INTEGER +-- refractor_boost_audit — new table +-- +-- Run on dev first, verify with: +-- SELECT column_name FROM information_schema.columns +-- WHERE table_name = 'refractor_card_state' +-- AND column_name = 'variant'; +-- SELECT count(*) FROM refractor_boost_audit; +-- +-- Rollback: See DROP/ALTER statements at bottom of file + +BEGIN; + +-- Verify card.variant column exists (should be from Phase 1 migration). +-- If not present, uncomment: +-- ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL; + +-- New columns on refractor_card_state (additive, no data migration needed) +ALTER TABLE refractor_card_state ADD COLUMN IF NOT EXISTS variant INTEGER; + +-- Boost audit table: records what was applied at each tier-up +CREATE TABLE IF NOT EXISTS refractor_boost_audit ( + id SERIAL PRIMARY KEY, + card_state_id INTEGER NOT NULL REFERENCES refractor_card_state(id) ON DELETE CASCADE, + tier SMALLINT NOT NULL, + battingcard_id INTEGER REFERENCES battingcard(id), + pitchingcard_id INTEGER REFERENCES pitchingcard(id), + variant_created INTEGER NOT NULL, + boost_delta_json JSONB NOT NULL, + applied_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(card_state_id, tier) -- Prevent duplicate audit records on retry +); + +COMMIT; + +-- Rollback: +-- DROP TABLE IF EXISTS refractor_boost_audit; +-- ALTER TABLE refractor_card_state DROP COLUMN IF EXISTS variant; diff --git a/tests/conftest.py b/tests/conftest.py index 9d9a6a7..d3ec000 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,10 +44,13 @@ from app.db_engine import ( BattingSeasonStats, PitchingSeasonStats, ProcessedGame, + BattingCard, + PitchingCard, RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic, + RefractorBoostAudit, ScoutOpportunity, ScoutClaim, ) @@ -80,6 +83,9 @@ _TEST_MODELS = [ RefractorCardState, RefractorTierBoost, RefractorCosmetic, + BattingCard, + PitchingCard, + RefractorBoostAudit, ] diff --git a/tests/test_refractor_boost.py b/tests/test_refractor_boost.py new file mode 100644 index 0000000..3c5e97a --- /dev/null +++ b/tests/test_refractor_boost.py @@ -0,0 +1,906 @@ +"""Unit tests for refractor_boost.py — Phase 2 boost functions. + +Tests are grouped by function: batter boost, pitcher boost, and variant hash. +Each class covers one behavioral concern. + +The functions under test are pure (no DB, no I/O), so every test calls the +function directly with a plain dict and asserts on the returned dict. +""" + +import pytest +from app.services.refractor_boost import ( + apply_batter_boost, + apply_pitcher_boost, + compute_variant_hash, + BATTER_OUTCOME_COLUMNS, + PITCHER_OUTCOME_COLUMNS, + PITCHER_PRIORITY, + PITCHER_XCHECK_COLUMNS, +) + + +# --------------------------------------------------------------------------- +# Fixtures: representative card ratings +# --------------------------------------------------------------------------- + + +def _silver_batter_vr(): + """Robinson Cano 2020 vR split — Silver batter with typical distribution. + + Sum of the 22 outcome columns is exactly 108.0. + Has comfortable strikeout (14.7) and groundout_a (3.85) buffers so a + normal boost can be applied without triggering truncation. + flyout_rf_b adjusted to 21.2 so the 22-column total is exactly 108.0. + """ + return { + "homerun": 3.05, + "bp_homerun": 0.0, + "triple": 0.0, + "double_three": 2.25, + "double_two": 2.25, + "double_pull": 8.95, + "single_two": 3.35, + "single_one": 14.55, + "single_center": 5.0, + "bp_single": 0.0, + "hbp": 6.2, + "walk": 7.4, + "strikeout": 14.7, + "lineout": 0.0, + "popout": 0.0, + "flyout_a": 5.3, + "flyout_bq": 1.45, + "flyout_lf_b": 1.95, + "flyout_rf_b": 21.2, + "groundout_a": 3.85, + "groundout_b": 3.0, + "groundout_c": 3.55, + } + + +def _contact_batter_vl(): + """Low-strikeout contact hitter — different archetype for variety testing. + + Strikeout (8.0) and groundout_a (5.0) are both non-zero, so the normal + boost applies without truncation. Sum of 22 columns is exactly 108.0. + flyout_rf_b raised to 16.5 to bring sum from 107.5 to 108.0. + """ + return { + "homerun": 1.5, + "bp_homerun": 0.0, + "triple": 0.5, + "double_three": 3.0, + "double_two": 3.0, + "double_pull": 7.0, + "single_two": 5.0, + "single_one": 18.0, + "single_center": 6.0, + "bp_single": 0.0, + "hbp": 4.0, + "walk": 8.0, + "strikeout": 8.0, + "lineout": 0.0, + "popout": 0.0, + "flyout_a": 6.0, + "flyout_bq": 2.0, + "flyout_lf_b": 3.0, + "flyout_rf_b": 16.5, + "groundout_a": 5.0, + "groundout_b": 6.0, + "groundout_c": 5.5, + } + + +def _power_batter_vr(): + """High-HR power hitter — third archetype for variety testing. + + Large strikeout (22.0) and groundout_a (2.0). Sum of 22 columns is 108.0. + """ + return { + "homerun": 8.0, + "bp_homerun": 0.0, + "triple": 0.0, + "double_three": 2.0, + "double_two": 2.0, + "double_pull": 6.0, + "single_two": 3.0, + "single_one": 10.0, + "single_center": 4.0, + "bp_single": 0.0, + "hbp": 5.0, + "walk": 9.0, + "strikeout": 22.0, + "lineout": 0.0, + "popout": 0.0, + "flyout_a": 7.0, + "flyout_bq": 2.0, + "flyout_lf_b": 3.0, + "flyout_rf_b": 15.0, + "groundout_a": 2.0, + "groundout_b": 5.0, + "groundout_c": 3.0, + } + + +def _singles_pitcher_vl(): + """Gibson 2020 vL — contact/groundball SP with typical distribution. + + Variable outcome columns (18) sum to 79. X-check columns sum to 29. + Full card sums to 108. double_cf=2.95 so the priority algorithm will + start there before moving to singles. + """ + return { + # Variable columns (sum=79) + "homerun": 3.3, + "bp_homerun": 2.0, + "triple": 0.75, + "double_three": 0.0, + "double_two": 0.0, + "double_cf": 2.95, + "single_two": 5.7, + "single_one": 0.0, + "single_center": 5.0, + "bp_single": 5.0, + "hbp": 3.0, + "walk": 5.0, + "strikeout": 10.0, + "flyout_lf_b": 15.1, + "flyout_cf_b": 0.9, + "flyout_rf_b": 0.0, + "groundout_a": 15.1, + "groundout_b": 5.2, + # X-check columns (sum=29) + "xcheck_p": 1.0, + "xcheck_c": 3.0, + "xcheck_1b": 2.0, + "xcheck_2b": 6.0, + "xcheck_3b": 3.0, + "xcheck_ss": 7.0, + "xcheck_lf": 2.0, + "xcheck_cf": 3.0, + "xcheck_rf": 2.0, + } + + +def _no_doubles_pitcher(): + """Pitcher with 0 in all double columns — tests priority skip behavior. + + All three double columns (double_cf, double_three, double_two) are 0.0, + so the algorithm must skip past them and start reducing singles. + Variable columns sum to 79; x-checks sum to 29; full card sums to 108. + groundout_b raised to 10.5 to bring variable sum from 77.0 to 79.0. + """ + return { + # Variable columns (sum=79) + "homerun": 2.0, + "bp_homerun": 1.0, + "triple": 0.5, + "double_three": 0.0, + "double_two": 0.0, + "double_cf": 0.0, + "single_two": 6.0, + "single_one": 3.0, + "single_center": 7.0, + "bp_single": 5.0, + "hbp": 2.0, + "walk": 6.0, + "strikeout": 12.0, + "flyout_lf_b": 8.0, + "flyout_cf_b": 4.0, + "flyout_rf_b": 2.0, + "groundout_a": 10.0, + "groundout_b": 10.5, + # X-check columns (sum=29) + "xcheck_p": 1.0, + "xcheck_c": 3.0, + "xcheck_1b": 2.0, + "xcheck_2b": 6.0, + "xcheck_3b": 3.0, + "xcheck_ss": 7.0, + "xcheck_lf": 2.0, + "xcheck_cf": 3.0, + "xcheck_rf": 2.0, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _batter_sum(d: dict) -> float: + """Return sum of the 22 batter outcome columns.""" + return sum(d[col] for col in BATTER_OUTCOME_COLUMNS) + + +def _pitcher_var_sum(d: dict) -> float: + """Return sum of the 18 pitcher variable outcome columns.""" + return sum(d[col] for col in PITCHER_OUTCOME_COLUMNS) + + +# --------------------------------------------------------------------------- +# Batter boost: 108-sum invariant +# --------------------------------------------------------------------------- + + +class TestBatterBoost108Sum: + """Verify the 22-column 108-sum invariant is maintained after batter boost.""" + + def test_single_tier(self): + """After one boost, all 22 batter outcome columns still sum to exactly 108. + + What: Apply a single boost to the Cano vR card and assert that the sum + of all 22 outcome columns is 108.0 within float tolerance (1e-6). + + Why: The fundamental invariant of every batter card is that outcomes + add to 108 (the number of results in a standard at-bat die table). + A boost that violates this would corrupt every game simulation that + uses the card. + """ + result = apply_batter_boost(_silver_batter_vr()) + assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) + + def test_cumulative_four_tiers(self): + """Apply boost 4 times sequentially; sum must be 108.0 after each tier. + + What: Start from the Cano vR card and apply four successive tier + boosts. Assert the 108-sum after each application — not just the + last one. + + Why: Floating-point drift can accumulate over multiple applications. + By checking after every tier we catch any incremental drift before it + compounds to a visible error in gameplay. + """ + card = _silver_batter_vr() + for tier in range(1, 5): + card = apply_batter_boost(card) + total = _batter_sum(card) + assert total == pytest.approx(108.0, abs=1e-6), ( + f"Sum drifted to {total} after tier {tier}" + ) + + def test_different_starting_ratings(self): + """Apply boost to 3 different batter archetypes; all maintain 108. + + What: Boost the Silver (Cano), Contact, and Power batter cards once + each and verify the 108-sum for every one. + + Why: The 108-sum must hold regardless of the starting distribution. + A card with unusual column proportions (e.g. very high or low + strikeout) might expose an edge case in the scaling math. + """ + for card_fn in (_silver_batter_vr, _contact_batter_vl, _power_batter_vr): + result = apply_batter_boost(card_fn()) + total = _batter_sum(result) + assert total == pytest.approx(108.0, abs=1e-6), ( + f"108-sum violated for {card_fn.__name__}: got {total}" + ) + + +# --------------------------------------------------------------------------- +# Batter boost: correct deltas +# --------------------------------------------------------------------------- + + +class TestBatterBoostDeltas: + """Verify that exactly the right columns change by exactly the right amounts.""" + + def test_positive_columns_increase(self): + """homerun, double_pull, single_one, and walk each increase by exactly 0.5. + + What: Compare the four positive-delta columns before and after a boost + on a card where no truncation occurs (Cano vR — strikeout=14.7 and + groundout_a=3.85 are well above the required reductions). + + Why: The boost's offensive intent is encoded in these four columns. + Any deviation (wrong column, wrong amount) would silently change card + power without the intended effect being applied. + """ + before = _silver_batter_vr() + after = apply_batter_boost(before.copy()) + + assert after["homerun"] == pytest.approx(before["homerun"] + 0.5, abs=1e-9) + assert after["double_pull"] == pytest.approx( + before["double_pull"] + 0.5, abs=1e-9 + ) + assert after["single_one"] == pytest.approx( + before["single_one"] + 0.5, abs=1e-9 + ) + assert after["walk"] == pytest.approx(before["walk"] + 0.5, abs=1e-9) + + def test_negative_columns_decrease(self): + """strikeout decreases by 1.5 and groundout_a decreases by 0.5. + + What: Compare the two negative-delta columns before and after a boost + where no truncation occurs (Cano vR card). + + Why: These reductions are the cost of the offensive improvement. + If either column is not reduced by the correct amount the 108-sum + would remain balanced only by accident. + """ + before = _silver_batter_vr() + after = apply_batter_boost(before.copy()) + + assert after["strikeout"] == pytest.approx(before["strikeout"] - 1.5, abs=1e-9) + assert after["groundout_a"] == pytest.approx( + before["groundout_a"] - 0.5, abs=1e-9 + ) + + def test_extra_keys_passed_through(self): + """Extra keys in the input dict (like x-check columns) survive the boost unchanged. + + What: Add a full set of x-check keys (xcheck_p, xcheck_c, xcheck_1b, + xcheck_2b, xcheck_3b, xcheck_ss, xcheck_lf, xcheck_cf, xcheck_rf) to + the standard Cano vR batter dict before boosting. Assert that every + x-check key is present in the output and that its value is identical to + what was passed in. Also assert the 22 outcome columns still sum to 108. + + Why: The batter boost function only reads and writes BATTER_OUTCOME_COLUMNS. + Any additional keys in the input dict should be forwarded to the output + via the `result = dict(ratings_dict)` copy. This guards against a + regression where the function returns a freshly-constructed dict that + drops non-outcome keys, which would strip x-check data when the caller + chains batter and pitcher operations on a shared dict. + """ + xcheck_values = { + "xcheck_p": 1.0, + "xcheck_c": 3.0, + "xcheck_1b": 2.0, + "xcheck_2b": 6.0, + "xcheck_3b": 3.0, + "xcheck_ss": 7.0, + "xcheck_lf": 2.0, + "xcheck_cf": 3.0, + "xcheck_rf": 2.0, + } + card = _silver_batter_vr() + card.update(xcheck_values) + + result = apply_batter_boost(card) + + # All x-check keys survive with unchanged values + for col, expected in xcheck_values.items(): + assert col in result, f"X-check key '{col}' missing from output" + assert result[col] == pytest.approx(expected, abs=1e-9), ( + f"X-check column '{col}' value changed: {expected} → {result[col]}" + ) + # The 22 outcome columns still satisfy the 108-sum invariant + assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) + + def test_unmodified_columns_unchanged(self): + """All 16 columns not in BATTER_POSITIVE_DELTAS or BATTER_NEGATIVE_DELTAS + are identical before and after a boost where no truncation occurs. + + What: After boosting the Cano vR card, compare every column that is + NOT one of the six modified columns and assert it is unchanged. + + Why: A bug that accidentally modifies an unrelated column (e.g. a + copy-paste error or an off-by-one in a column list) would corrupt + game balance silently. This acts as a "column integrity" guard. + """ + modified_cols = { + "homerun", + "double_pull", + "single_one", + "walk", # positive + "strikeout", + "groundout_a", # negative + } + unmodified_cols = [c for c in BATTER_OUTCOME_COLUMNS if c not in modified_cols] + + before = _silver_batter_vr() + after = apply_batter_boost(before.copy()) + + for col in unmodified_cols: + assert after[col] == pytest.approx(before[col], abs=1e-9), ( + f"Column '{col}' changed unexpectedly: {before[col]} → {after[col]}" + ) + + +# --------------------------------------------------------------------------- +# Batter boost: 0-floor truncation +# --------------------------------------------------------------------------- + + +class TestBatterBoostTruncation: + """Verify that the 0-floor is enforced and positive deltas are scaled + proportionally so the 108-sum is preserved even when negative columns + cannot be fully reduced. + """ + + def test_groundout_a_near_zero(self): + """Card with groundout_a=0.3: only 0.3 is taken, positive deltas + scale down proportionally, and the sum still equals 108. + + What: Construct a card where groundout_a=0.3 (less than the requested + 0.5 reduction). The algorithm must floor groundout_a at 0 (taking only + 0.3), and scale the positive deltas so that total_added == total_reduced + (1.5 + 0.3 = 1.8 from strikeout + groundout_a; positive budget is + also reduced accordingly). + + Why: Without the 0-floor, groundout_a would go negative — an invalid + card state. Without proportional scaling the 108-sum would break. + This test validates both protections together. + """ + card = _silver_batter_vr() + card["groundout_a"] = 0.3 + # Re-balance: move the excess 3.55 to groundout_b to keep sum==108 + diff = 3.85 - 0.3 # original groundout_a was 3.85 + card["groundout_b"] = card["groundout_b"] + diff + + result = apply_batter_boost(card) + + # groundout_a must be exactly 0 (floored) + assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9) + # 108-sum preserved + assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) + + def test_strikeout_near_zero(self): + """Card with strikeout=1.0: only 1.0 is taken instead of the requested + 1.5, and positive deltas are scaled down to compensate. + + What: Build a card with strikeout=1.0 (less than the requested 1.5 + reduction). The algorithm can only take 1.0 from strikeout (floored + at 0), and the full 0.5 from groundout_a — so total_actually_reduced + is 1.5 against a total_requested_reduction of 2.0. Truncation DOES + occur: positive deltas are scaled to 0.75x (1.5 / 2.0) so the + 108-sum is preserved, and strikeout lands exactly at 0.0. + + Why: Strikeout is the most commonly near-zero column on elite contact + hitters. Verifying the floor specifically on strikeout ensures that + the tier-1 boost on a card already boosted multiple times won't + produce an invalid negative strikeout value. + """ + card = _silver_batter_vr() + # Replace strikeout=14.7 with strikeout=1.0 and re-balance via flyout_rf_b + diff = 14.7 - 1.0 + card["strikeout"] = 1.0 + card["flyout_rf_b"] = card["flyout_rf_b"] + diff + + result = apply_batter_boost(card) + + # strikeout must be exactly 0 (fully consumed by the floor) + assert result["strikeout"] == pytest.approx(0.0, abs=1e-9) + # 108-sum preserved regardless of truncation + assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) + + def test_both_out_columns_zero(self): + """When both strikeout=0 and groundout_a=0, the boost becomes a no-op. + + What: Build a card where strikeout=0.0 and groundout_a=0.0 (the 18.55 + chances freed by zeroing both are redistributed to flyout_rf_b so the + 22-column sum remains exactly 108.0). When the boost runs, + total_actually_reduced is 0, so the scale factor is 0 and every + positive delta becomes 0 as well. + + Why: This validates the edge of the truncation path where no budget + whatsoever is available. Without the `if total_requested_addition > 0` + guard, the algorithm would divide by zero; without correct scaling it + would silently apply non-zero positive deltas and break the 108-sum. + """ + card = _silver_batter_vr() + # Zero out both negative-delta source columns and compensate via flyout_rf_b + freed = card["strikeout"] + card["groundout_a"] # 14.7 + 3.85 = 18.55 + card["strikeout"] = 0.0 + card["groundout_a"] = 0.0 + card["flyout_rf_b"] = card["flyout_rf_b"] + freed + + before_homerun = card["homerun"] + before_double_pull = card["double_pull"] + before_single_one = card["single_one"] + before_walk = card["walk"] + + result = apply_batter_boost(card) + + # 108-sum is preserved (nothing moved) + assert _batter_sum(result) == pytest.approx(108.0, abs=1e-6) + # Source columns remain at zero + assert result["strikeout"] == pytest.approx(0.0, abs=1e-9) + assert result["groundout_a"] == pytest.approx(0.0, abs=1e-9) + # Positive-delta columns are unchanged (scale == 0 means no additions) + assert result["homerun"] == pytest.approx(before_homerun, abs=1e-9) + assert result["double_pull"] == pytest.approx(before_double_pull, abs=1e-9) + assert result["single_one"] == pytest.approx(before_single_one, abs=1e-9) + assert result["walk"] == pytest.approx(before_walk, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Pitcher boost: 108-sum invariant +# --------------------------------------------------------------------------- + + +class TestPitcherBoost108Sum: + """Verify the pitcher card invariant: 18 variable columns sum to 79, + x-checks sum to 29, total is 108 — all after every boost. + """ + + def test_singles_heavy_pitcher(self): + """Gibson-like pitcher (double_cf=2.95, no other doubles) maintains + 79-sum for the 18 variable columns after one boost. + + What: Boost the Gibson vL card once. Assert that the 18 variable + columns (PITCHER_OUTCOME_COLUMNS) still sum to 79. + + Why: The pitcher boost algorithm converts hit/walk chances to + strikeouts without changing the total number of outcomes. If any + chance is created or destroyed, the 79-sum breaks and game simulation + results become unreliable. + """ + result = apply_pitcher_boost(_singles_pitcher_vl()) + assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6) + + def test_no_doubles_pitcher(self): + """Pitcher with all three double columns at 0 skips them, reduces + singles instead, and the 18-column variable sum stays at 79. + + What: Boost the no-doubles pitcher fixture once. The priority list + starts with double_cf, double_three, double_two — all zero — so the + algorithm must skip them and begin consuming single_center. The + 79-sum must be preserved. + + Why: Validates the zero-skip logic in the priority loop. Without it, + the algorithm would incorrectly deduct from a 0-value column, producing + a negative entry and an invalid 79-sum. + """ + result = apply_pitcher_boost(_no_doubles_pitcher()) + assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6) + + def test_cumulative_four_tiers(self): + """Four successive boosts: sum==79 after each tier; no column is negative. + + What: Apply four boosts in sequence to the Gibson vL card (highest + number of reducible doubles/singles available). After each boost, + assert that the 18-column sum is 79.0 and that no individual column + went negative. + + Why: Cumulative boost scenarios are the real production use case. + Float drift and edge cases in the priority budget loop could silently + corrupt the card over multiple tier-ups. Checking after every + iteration catches both issues early. + """ + card = _singles_pitcher_vl() + for tier in range(1, 5): + card = apply_pitcher_boost(card) + total = _pitcher_var_sum(card) + assert total == pytest.approx(79.0, abs=1e-6), ( + f"79-sum drifted to {total} after tier {tier}" + ) + for col in PITCHER_OUTCOME_COLUMNS: + assert card[col] >= -1e-9, ( + f"Column '{col}' went negative ({card[col]}) after tier {tier}" + ) + + +# --------------------------------------------------------------------------- +# Pitcher boost: determinism +# --------------------------------------------------------------------------- + + +class TestPitcherBoostDeterminism: + """Same input always produces identical output across multiple calls.""" + + def test_repeated_calls_identical(self): + """Call apply_pitcher_boost 10 times with the same input; all outputs match. + + What: Pass the same Gibson vL dict to apply_pitcher_boost ten times + and assert that every result is byte-for-byte equal to the first. + + Why: Determinism is a hard requirement for reproducible card states. + Any non-determinism (e.g. from random number usage, hash iteration + order, or floating-point compiler variance) would make two identically- + seeded boosts produce different card variants — a correctness bug. + """ + base = _singles_pitcher_vl() + first = apply_pitcher_boost(base.copy()) + for i in range(1, 10): + result = apply_pitcher_boost(base.copy()) + for col in PITCHER_OUTCOME_COLUMNS: + assert result[col] == first[col], ( + f"Call {i} differed on column '{col}': " + f"first={first[col]}, got={result[col]}" + ) + + +# --------------------------------------------------------------------------- +# Pitcher boost: TB budget accounting +# --------------------------------------------------------------------------- + + +class TestPitcherBoostTBAccounting: + """Verify the TB budget is debited at the correct rate per outcome type.""" + + def test_doubles_cost_2tb(self): + """Reducing a double column by 0.75 chances spends exactly 1.5 TB. + + What: Build a minimal pitcher card where only double_cf is non-zero + (2.0 chances) and all singles/walks/HR are 0. With the default + budget of 1.5 TB and a cost of 2 TB per chance, the algorithm can + take at most 0.75 chances. Assert that double_cf decreases by exactly + 0.75 and strikeout increases by exactly 0.75. + + Why: If the TB cost factor for doubles were applied incorrectly (e.g. + as 1 instead of 2), the algorithm would over-consume chances and + produce a card that has been boosted more than a single tier allows. + """ + # Construct a minimal card: only double_cf has value + card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} + card["double_cf"] = 2.0 + card["strikeout"] = 77.0 # 2.0 + 77.0 = 79.0 to satisfy invariant + for col in PITCHER_XCHECK_COLUMNS: + card[col] = 0.0 + card["xcheck_2b"] = 29.0 # put all xcheck budget in one column + + result = apply_pitcher_boost(card) + + # Budget = 1.5 TB, cost = 2 TB/chance → max chances = 0.75 + assert result["double_cf"] == pytest.approx(2.0 - 0.75, abs=1e-9) + assert result["strikeout"] == pytest.approx(77.0 + 0.75, abs=1e-9) + + def test_singles_cost_1tb(self): + """Reducing a singles column spends 1 TB per chance. + + What: Build a minimal card where only single_center is non-zero + (5.0 chances) and all higher-priority columns are 0. With the + default budget of 1.5 TB and a cost of 1 TB per chance, the algorithm + takes exactly 1.5 chances from single_center. + + Why: Singles are the most common target in the priority list. Getting + the cost factor wrong (e.g. 2 instead of 1) would halve the effective + boost impact on contact-heavy pitchers. + """ + card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} + card["single_center"] = 5.0 + card["strikeout"] = 74.0 # 5.0 + 74.0 = 79.0 + for col in PITCHER_XCHECK_COLUMNS: + card[col] = 0.0 + card["xcheck_2b"] = 29.0 + + result = apply_pitcher_boost(card) + + # Budget = 1.5 TB, cost = 1 TB/chance → takes exactly 1.5 chances + assert result["single_center"] == pytest.approx(5.0 - 1.5, abs=1e-9) + assert result["strikeout"] == pytest.approx(74.0 + 1.5, abs=1e-9) + + def test_budget_exhaustion(self): + """Algorithm stops exactly when the TB budget reaches 0, leaving the + next priority column untouched. + + What: Build a card where double_cf=1.0 (costs 2 TB/chance). With + budget=1.5 the algorithm takes 0.75 chances from double_cf (spending + exactly 1.5 TB) and then stops. single_center, which comes later in + the priority list, must be completely untouched. + + Why: Overspending the budget would boost the pitcher by more than one + tier allows. This test directly verifies the `if remaining <= 0: + break` guard in the loop. + """ + card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} + card["double_cf"] = 1.0 + card["single_center"] = 5.0 + card["strikeout"] = 73.0 # 1.0 + 5.0 + 73.0 = 79.0 + for col in PITCHER_XCHECK_COLUMNS: + card[col] = 0.0 + card["xcheck_2b"] = 29.0 + + result = apply_pitcher_boost(card) + + # double_cf consumed 0.75 chances (1.5 TB at 2 TB/chance) — budget gone + assert result["double_cf"] == pytest.approx(1.0 - 0.75, abs=1e-9) + # single_center must be completely unchanged (budget was exhausted) + assert result["single_center"] == pytest.approx(5.0, abs=1e-9) + # strikeout gained exactly 0.75 (from double_cf only) + assert result["strikeout"] == pytest.approx(73.0 + 0.75, abs=1e-9) + + +# --------------------------------------------------------------------------- +# Pitcher boost: zero-skip and x-check protection +# --------------------------------------------------------------------------- + + +class TestPitcherBoostZeroSkip: + """Verify that zero-valued priority columns are skipped and that x-check + columns are never modified under any circumstances. + """ + + def test_skip_zero_doubles(self): + """Pitcher with all three double columns at 0: first reduction comes + from single_center (the first non-zero column in priority order). + + What: Boost the no-doubles pitcher fixture once. Assert that + double_cf, double_three, and double_two are still 0.0 after the boost, + and that single_center has decreased (the first non-zero column in + the priority list after the three doubles). + + Why: The priority loop must not subtract from columns that are already + at 0 — doing so would create negative values and an invalid card. + This test confirms the `if ratings[col] <= 0: continue` guard works. + """ + before = _no_doubles_pitcher() + after = apply_pitcher_boost(before.copy()) + + # All double columns were 0 and must remain 0 + assert after["double_cf"] == pytest.approx(0.0, abs=1e-9) + assert after["double_three"] == pytest.approx(0.0, abs=1e-9) + assert after["double_two"] == pytest.approx(0.0, abs=1e-9) + + # single_center (4th in priority) must have decreased + assert after["single_center"] < before["single_center"], ( + "single_center should have been reduced when doubles were all 0" + ) + + def test_all_priority_columns_zero(self): + """When every priority column is 0, the TB budget cannot be spent. + + What: Build a pitcher card where all 12 PITCHER_PRIORITY columns + (double_cf, double_three, double_two, single_center, single_two, + single_one, bp_single, walk, homerun, bp_homerun, triple, hbp) are + 0.0. The remaining 79 variable-column chances are distributed across + the non-priority variable columns (strikeout, flyout_lf_b, flyout_cf_b, + flyout_rf_b, groundout_a, groundout_b). X-check columns sum to 29 as + usual. The algorithm iterates all 12 priority entries, finds nothing + to take, logs a warning, and returns. + + Why: This is the absolute edge of the zero-skip path. Without the + `if remaining > 0: logger.warning(...)` guard the unspent budget would + be silently discarded. More importantly, no column should be modified: + the 79-sum must be preserved, strikeout must not change, and every + priority column must still be exactly 0.0. + """ + priority_cols = {col for col, _ in PITCHER_PRIORITY} + card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} + # Distribute all 79 variable chances across non-priority outcome columns + card["strikeout"] = 20.0 + card["flyout_lf_b"] = 15.0 + card["flyout_cf_b"] = 15.0 + card["flyout_rf_b"] = 15.0 + card["groundout_a"] = 7.0 + card["groundout_b"] = 7.0 + # Add x-check columns (sum=29), required by the card structure + for col in PITCHER_XCHECK_COLUMNS: + card[col] = 0.0 + card["xcheck_2b"] = 29.0 + + before_strikeout = card["strikeout"] + + result = apply_pitcher_boost(card) + + # Variable columns still sum to 79 + assert _pitcher_var_sum(result) == pytest.approx(79.0, abs=1e-6) + # Strikeout is unchanged — nothing was moved into it + assert result["strikeout"] == pytest.approx(before_strikeout, abs=1e-9) + # All priority columns remain at 0 + for col in priority_cols: + assert result[col] == pytest.approx(0.0, abs=1e-9), ( + f"Priority column '{col}' changed unexpectedly: {result[col]}" + ) + # X-check columns are untouched + for col in PITCHER_XCHECK_COLUMNS: + assert result[col] == pytest.approx(card[col], abs=1e-9) + + def test_xcheck_columns_never_modified(self): + """All 9 x-check columns are identical before and after a boost. + + What: Boost both the Gibson vL card and the no-doubles pitcher card + once each and verify that every xcheck_* column is unchanged. + + Why: X-check columns encode defensive routing weights that are + completely separate from the offensive outcome probabilities. The + boost algorithm must never touch them; modifying them would break + game simulation logic that relies on their fixed sum of 29. + """ + for card_fn in (_singles_pitcher_vl, _no_doubles_pitcher): + before = card_fn() + after = apply_pitcher_boost(before.copy()) + for col in PITCHER_XCHECK_COLUMNS: + assert after[col] == pytest.approx(before[col], abs=1e-9), ( + f"X-check column '{col}' was unexpectedly modified in " + f"{card_fn.__name__}: {before[col]} → {after[col]}" + ) + + +# --------------------------------------------------------------------------- +# Variant hash +# --------------------------------------------------------------------------- + + +class TestVariantHash: + """Verify the behavior of compute_variant_hash: determinism, uniqueness, + never-zero guarantee, and cosmetics influence. + """ + + def test_deterministic(self): + """Same (player_id, refractor_tier, cosmetics) inputs produce the + same hash on every call. + + What: Call compute_variant_hash 20 times with the same arguments and + assert every result equals the first. + + Why: The variant hash is stored in the database as a stable card + identifier. Any non-determinism would cause the same card state to + generate a different variant key on different calls, creating phantom + duplicate variants in the DB. + """ + first = compute_variant_hash(42, 2, ["foil"]) + for _ in range(19): + assert compute_variant_hash(42, 2, ["foil"]) == first + + def test_different_tiers_different_hash(self): + """Tier 1 and tier 2 for the same player produce different hashes. + + What: Compare compute_variant_hash(player_id=1, refractor_tier=1) + vs compute_variant_hash(player_id=1, refractor_tier=2). + + Why: Each Refractor tier represents a distinct card version. If two + tiers produced the same hash, the DB unique-key constraint on variant + would incorrectly merge them into a single entry. + """ + h1 = compute_variant_hash(1, 1) + h2 = compute_variant_hash(1, 2) + assert h1 != h2, f"Tier 1 and tier 2 unexpectedly produced the same hash: {h1}" + + def test_different_players_different_hash(self): + """Same tier for different players produces different hashes. + + What: Compare compute_variant_hash(player_id=10, refractor_tier=1) + vs compute_variant_hash(player_id=11, refractor_tier=1). + + Why: Each player's Refractor card is a distinct asset. If two players + at the same tier shared a hash, their boosted variants could not be + distinguished in the database. + """ + ha = compute_variant_hash(10, 1) + hb = compute_variant_hash(11, 1) + assert ha != hb, ( + f"Player 10 and player 11 at tier 1 unexpectedly share hash: {ha}" + ) + + def test_never_zero(self): + """Hash is never 0 across a broad set of (player_id, tier) combinations. + + What: Generate hashes for player_ids 0–999 at tiers 0–4 (5000 total) + and assert every result is >= 1. + + Why: Variant 0 is the reserved sentinel for base (un-boosted) cards. + The function must remap any SHA-256-derived value that happens to be 0 + to 1. Testing with a large batch guards against the extremely unlikely + collision while confirming the guard is active. + """ + for player_id in range(1000): + for tier in range(5): + result = compute_variant_hash(player_id, tier) + assert result >= 1, ( + f"compute_variant_hash({player_id}, {tier}) returned 0" + ) + + def test_cosmetics_affect_hash(self): + """Adding cosmetics to the same player/tier produces a different hash. + + What: Compare compute_variant_hash(1, 1, cosmetics=None) vs + compute_variant_hash(1, 1, cosmetics=["chrome"]). + + Why: Cosmetic variants (special art, foil treatment, etc.) must be + distinguishable from the base-tier variant. If cosmetics were ignored + by the hash, two cards that look different would share the same variant + key and collide in the database. + """ + base = compute_variant_hash(1, 1) + with_cosmetic = compute_variant_hash(1, 1, ["chrome"]) + assert base != with_cosmetic, "Adding cosmetics did not change the variant hash" + + def test_cosmetics_order_independent(self): + """Cosmetics list is sorted before hashing; order does not matter. + + What: Compare compute_variant_hash(1, 1, ["foil", "chrome"]) vs + compute_variant_hash(1, 1, ["chrome", "foil"]). They must be equal. + + Why: Callers should not need to sort cosmetics before passing them in. + If the hash were order-dependent, the same logical card state could + produce two different variant keys depending on how the caller + constructed the list. + """ + h1 = compute_variant_hash(1, 1, ["foil", "chrome"]) + h2 = compute_variant_hash(1, 1, ["chrome", "foil"]) + assert h1 == h2, ( + f"Cosmetics order affected hash: ['foil','chrome']={h1}, " + f"['chrome','foil']={h2}" + )