diff --git a/app/routers_v2/refractor.py b/app/routers_v2/refractor.py index b9a15a0..daaee0e 100644 --- a/app/routers_v2/refractor.py +++ b/app/routers_v2/refractor.py @@ -52,7 +52,9 @@ def _build_card_state_response(state, player_name=None) -> dict: "current_value": state.current_value, "fully_evolved": state.fully_evolved, "last_evaluated_at": ( - state.last_evaluated_at.isoformat() if state.last_evaluated_at else None + state.last_evaluated_at.isoformat() + if hasattr(state.last_evaluated_at, "isoformat") + else state.last_evaluated_at or None ), "track": track_dict, "next_threshold": next_threshold, @@ -305,7 +307,10 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") + import os + from ..db_engine import RefractorCardState, Player, StratPlay + from ..services.refractor_boost import apply_tier_boost from ..services.refractor_evaluator import evaluate_card plays = list(StratPlay.select().where(StratPlay.game == game_id)) @@ -320,6 +325,8 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): evaluated = 0 tier_ups = [] + boost_enabled = os.environ.get("REFRACTOR_BOOST_ENABLED", "true").lower() != "false" + for player_id, team_id in pairs: try: state = RefractorCardState.get_or_none( @@ -330,11 +337,16 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): continue old_tier = state.current_tier - result = evaluate_card(player_id, team_id) + # Use dry_run=True so that current_tier is NOT written here. + # apply_tier_boost() writes current_tier + variant atomically on + # tier-up. If no tier-up occurs, apply_tier_boost is not called + # and the tier stays at old_tier (correct behaviour). + result = evaluate_card(player_id, team_id, dry_run=True) evaluated += 1 - new_tier = result.get("current_tier", old_tier) - if new_tier > old_tier: + # Use computed_tier (what the formula says) to detect tier-ups. + computed_tier = result.get("computed_tier", old_tier) + if computed_tier > old_tier: player_name = "Unknown" try: p = Player.get_by_id(player_id) @@ -342,17 +354,66 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): except Exception: pass - tier_ups.append( - { - "player_id": player_id, - "team_id": team_id, - "player_name": player_name, - "old_tier": old_tier, - "new_tier": new_tier, - "current_value": result.get("current_value", 0), - "track_name": state.track.name if state.track else "Unknown", - } - ) + # Phase 2: Apply rating boosts for each tier gained. + # apply_tier_boost() writes current_tier + variant atomically. + # If it fails, current_tier stays at old_tier — automatic retry next game. + boost_result = None + if not boost_enabled: + # Boost disabled via REFRACTOR_BOOST_ENABLED=false. + # Skip notification — current_tier was not written (dry_run), + # so reporting a tier-up would be a false notification. + continue + + card_type = state.track.card_type if state.track else None + if card_type: + last_successful_tier = old_tier + failing_tier = old_tier + 1 + try: + for tier in range(old_tier + 1, computed_tier + 1): + failing_tier = tier + boost_result = apply_tier_boost( + player_id, team_id, tier, card_type + ) + last_successful_tier = tier + except Exception as boost_exc: + logger.warning( + f"Refractor boost failed for player={player_id} " + f"team={team_id} tier={failing_tier}: {boost_exc}" + ) + # Report only the tiers that actually succeeded. + # If none succeeded, skip the tier_up notification entirely. + if last_successful_tier == old_tier: + continue + # At least one intermediate tier was committed; report that. + computed_tier = last_successful_tier + else: + # No card_type means no track — skip boost and skip notification. + # A false tier-up notification must not be sent when the boost + # was never applied (current_tier was never written to DB). + logger.warning( + f"Refractor boost skipped for player={player_id} " + f"team={team_id}: no card_type on track" + ) + continue + + tier_up_entry = { + "player_id": player_id, + "team_id": team_id, + "player_name": player_name, + "old_tier": old_tier, + "new_tier": computed_tier, + "current_value": result.get("current_value", 0), + "track_name": state.track.name if state.track else "Unknown", + } + + # Non-breaking addition: include boost info when available. + if boost_result: + tier_up_entry["variant_created"] = boost_result.get( + "variant_created" + ) + + tier_ups.append(tier_up_entry) + except Exception as exc: logger.warning( f"Refractor eval failed for player={player_id} team={team_id}: {exc}" diff --git a/app/services/refractor_boost.py b/app/services/refractor_boost.py index 321c34c..700a820 100644 --- a/app/services/refractor_boost.py +++ b/app/services/refractor_boost.py @@ -1,8 +1,9 @@ """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(). +reaches a new Refractor tier. The module-level 'db' variable is used by +apply_tier_boost() for atomic writes; tests patch this reference to redirect +writes to a shared-memory SQLite database. Batter boost: fixed +0.5 to four offensive columns per tier. Pitcher boost: 1.5 TB-budget priority algorithm per tier. @@ -13,6 +14,24 @@ import hashlib import json import logging +# Module-level db reference imported lazily so that this module can be +# imported before app.db_engine is fully initialised (e.g. in tests that +# patch DATABASE_TYPE before importing db_engine). +# Tests that need to redirect DB writes should patch this attribute at module +# level: `import app.services.refractor_boost as m; m.db = test_db`. +db = None + + +def _get_db(): + """Return the module-level db, importing lazily on first use.""" + global db + if db is None: + from app.db_engine import db as _db # noqa: PLC0415 + + db = _db + return db + + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -77,7 +96,7 @@ PITCHER_PRIORITY: list[tuple[str, int]] = [ ("hbp", 1), ] -# All 18 variable outcome columns that must sum to 79. +# All 18 variable outcome columns (sum to 79; x-checks add 29 for 108 total). PITCHER_OUTCOME_COLUMNS: list[str] = [ "homerun", "bp_homerun", @@ -313,3 +332,363 @@ def compute_variant_hash( 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 + + +# --------------------------------------------------------------------------- +# Display stat helpers +# --------------------------------------------------------------------------- + + +def compute_batter_display_stats(ratings: dict) -> dict: + """Compute avg/obp/slg from batter outcome columns. + + Uses the same formulas as the BattingCardRatingsModel Pydantic validator + so that variant card display stats are always consistent with the boosted + chance values. All denominators are 108 (the full card chance total). + + Args: + ratings: Dict containing at minimum all BATTER_OUTCOME_COLUMNS as + numeric (int or float) values. + + Returns: + Dict with keys 'avg', 'obp', 'slg' as floats. + """ + avg = ( + ratings["homerun"] + + ratings["bp_homerun"] / 2 + + ratings["triple"] + + ratings["double_three"] + + ratings["double_two"] + + ratings["double_pull"] + + ratings["single_two"] + + ratings["single_one"] + + ratings["single_center"] + + ratings["bp_single"] / 2 + ) / 108 + obp = (ratings["hbp"] + ratings["walk"]) / 108 + avg + slg = ( + ratings["homerun"] * 4 + + ratings["bp_homerun"] * 2 + + ratings["triple"] * 3 + + ratings["double_three"] * 2 + + ratings["double_two"] * 2 + + ratings["double_pull"] * 2 + + ratings["single_two"] + + ratings["single_one"] + + ratings["single_center"] + + ratings["bp_single"] / 2 + ) / 108 + return {"avg": avg, "obp": obp, "slg": slg} + + +def compute_pitcher_display_stats(ratings: dict) -> dict: + """Compute avg/obp/slg from pitcher outcome columns. + + Uses the same formulas as the PitchingCardRatingsModel Pydantic validator + so that variant card display stats are always consistent with the boosted + chance values. All denominators are 108 (the full card chance total). + + Args: + ratings: Dict containing at minimum all PITCHER_OUTCOME_COLUMNS as + numeric (int or float) values. + + Returns: + Dict with keys 'avg', 'obp', 'slg' as floats. + """ + avg = ( + ratings["homerun"] + + ratings["bp_homerun"] / 2 + + ratings["triple"] + + ratings["double_three"] + + ratings["double_two"] + + ratings["double_cf"] + + ratings["single_two"] + + ratings["single_one"] + + ratings["single_center"] + + ratings["bp_single"] / 2 + ) / 108 + obp = (ratings["hbp"] + ratings["walk"]) / 108 + avg + slg = ( + ratings["homerun"] * 4 + + ratings["bp_homerun"] * 2 + + ratings["triple"] * 3 + + ratings["double_three"] * 2 + + ratings["double_two"] * 2 + + ratings["double_cf"] * 2 + + ratings["single_two"] + + ratings["single_one"] + + ratings["single_center"] + + ratings["bp_single"] / 2 + ) / 108 + return {"avg": avg, "obp": obp, "slg": slg} + + +# --------------------------------------------------------------------------- +# Orchestration: apply_tier_boost +# --------------------------------------------------------------------------- + + +def apply_tier_boost( + player_id: int, + team_id: int, + new_tier: int, + card_type: str, + _batting_card_model=None, + _batting_ratings_model=None, + _pitching_card_model=None, + _pitching_ratings_model=None, + _card_model=None, + _state_model=None, + _audit_model=None, +) -> dict: + """Create a boosted variant card for a tier-up. + + IMPORTANT: This function is the SOLE writer of current_tier on + RefractorCardState when a tier-up occurs. The evaluator computes + the new tier but does NOT write it — this function writes tier + + variant + audit atomically inside a single db.atomic() block. + If this function fails, the tier stays at its old value and will + be retried on the next game evaluation. + + Orchestrates the full flow (card creation outside atomic; state + mutations inside db.atomic()): + 1. Determine source variant (variant=0 for T1, previous tier's hash for T2+) + 2. Fetch source card and ratings rows + 3. Apply boost formula (batter or pitcher) per vs_hand split + 4. Assert 108-sum after boost for both batters and pitchers + 5. Compute new variant hash + 6. Create new card row with new variant (idempotency: skip if exists) + 7. Create new ratings rows for both vs_hand splits (idempotency: skip if exists) + 8. Inside db.atomic(): + a. Write RefractorBoostAudit record + b. Update RefractorCardState: current_tier, variant, fully_evolved + c. Propagate variant to all Card rows for (player_id, team_id) + + Args: + player_id: Player primary key. + team_id: Team primary key. + new_tier: The tier being reached (1-4). + card_type: One of 'batter', 'sp', 'rp'. + _batting_card_model: Injectable stub for BattingCard (used in tests). + _batting_ratings_model: Injectable stub for BattingCardRatings. + _pitching_card_model: Injectable stub for PitchingCard. + _pitching_ratings_model: Injectable stub for PitchingCardRatings. + _card_model: Injectable stub for Card. + _state_model: Injectable stub for RefractorCardState. + _audit_model: Injectable stub for RefractorBoostAudit. + + Returns: + Dict with 'variant_created' (int) and 'boost_deltas' (per-split dict). + + Raises: + ValueError: If the source card or ratings are missing, or if + RefractorCardState is not found for (player_id, team_id). + """ + # Lazy model imports — same pattern as refractor_evaluator.py. + if _batting_card_model is None: + from app.db_engine import BattingCard as _batting_card_model # noqa: PLC0415 + if _batting_ratings_model is None: + from app.db_engine import BattingCardRatings as _batting_ratings_model # noqa: PLC0415 + if _pitching_card_model is None: + from app.db_engine import PitchingCard as _pitching_card_model # noqa: PLC0415 + if _pitching_ratings_model is None: + from app.db_engine import PitchingCardRatings as _pitching_ratings_model # noqa: PLC0415 + if _card_model is None: + from app.db_engine import Card as _card_model # noqa: PLC0415 + if _state_model is None: + from app.db_engine import RefractorCardState as _state_model # noqa: PLC0415 + if _audit_model is None: + from app.db_engine import RefractorBoostAudit as _audit_model # noqa: PLC0415 + + _db = _get_db() + + if card_type not in ("batter", "sp", "rp"): + raise ValueError( + f"Invalid card_type={card_type!r}; expected one of 'batter', 'sp', 'rp'" + ) + is_batter = card_type == "batter" + CardModel = _batting_card_model if is_batter else _pitching_card_model + RatingsModel = _batting_ratings_model if is_batter else _pitching_ratings_model + fk_field = "battingcard" if is_batter else "pitchingcard" + + # 1. Determine source variant. + if new_tier == 1: + source_variant = 0 + else: + source_variant = compute_variant_hash(player_id, new_tier - 1) + + # 2. Fetch source card and ratings rows. + source_card = CardModel.get_or_none( + (CardModel.player == player_id) & (CardModel.variant == source_variant) + ) + if source_card is None: + raise ValueError( + f"No {'batting' if is_batter else 'pitching'}card for " + f"player={player_id} variant={source_variant}" + ) + + ratings_rows = list( + RatingsModel.select().where(getattr(RatingsModel, fk_field) == source_card.id) + ) + if not ratings_rows: + raise ValueError(f"No ratings rows for card_id={source_card.id}") + + # 3. Apply boost to each vs_hand split. + boost_fn = apply_batter_boost if is_batter else apply_pitcher_boost + outcome_cols = BATTER_OUTCOME_COLUMNS if is_batter else PITCHER_OUTCOME_COLUMNS + boosted_splits: dict[str, dict] = {} + + for row in ratings_rows: + # Build the ratings dict: outcome columns + (pitcher) x-check columns. + ratings_dict: dict = {col: getattr(row, col) for col in outcome_cols} + if not is_batter: + for col in PITCHER_XCHECK_COLUMNS: + ratings_dict[col] = getattr(row, col) + + boosted = boost_fn(ratings_dict) + + # 4. Assert 108-sum invariant after boost (Peewee bypasses Pydantic validators). + if is_batter: + boosted_sum = sum(boosted[col] for col in BATTER_OUTCOME_COLUMNS) + else: + boosted_sum = sum(boosted[col] for col in PITCHER_OUTCOME_COLUMNS) + sum( + boosted[col] for col in PITCHER_XCHECK_COLUMNS + ) + + if abs(boosted_sum - 108.0) >= 0.01: + raise ValueError( + f"108-sum invariant violated after boost for player={player_id} " + f"vs_hand={row.vs_hand}: sum={boosted_sum:.6f}" + ) + + boosted_splits[row.vs_hand] = boosted + + # 5. Compute new variant hash. + new_variant = compute_variant_hash(player_id, new_tier) + + # 6. Create new card row (idempotency: skip if exists). + existing_card = CardModel.get_or_none( + (CardModel.player == player_id) & (CardModel.variant == new_variant) + ) + if existing_card is not None: + new_card = existing_card + else: + if is_batter: + clone_fields = [ + "steal_low", + "steal_high", + "steal_auto", + "steal_jump", + "bunting", + "hit_and_run", + "running", + "offense_col", + "hand", + ] + else: + clone_fields = [ + "balk", + "wild_pitch", + "hold", + "starter_rating", + "relief_rating", + "closer_rating", + "batting", + "offense_col", + "hand", + ] + card_data: dict = { + "player": player_id, + "variant": new_variant, + "image_url": None, # No rendered image for variant cards yet. + } + for fname in clone_fields: + card_data[fname] = getattr(source_card, fname) + new_card = CardModel.create(**card_data) + + # 7. Create new ratings rows for each split (idempotency: skip if exists). + display_stats_fn = ( + compute_batter_display_stats if is_batter else compute_pitcher_display_stats + ) + + for vs_hand, boosted_ratings in boosted_splits.items(): + existing_ratings = RatingsModel.get_or_none( + (getattr(RatingsModel, fk_field) == new_card.id) + & (RatingsModel.vs_hand == vs_hand) + ) + if existing_ratings is not None: + continue # Idempotency: already written. + + ratings_data: dict = { + fk_field: new_card.id, + "vs_hand": vs_hand, + } + # Outcome columns (boosted values). + ratings_data.update({col: boosted_ratings[col] for col in outcome_cols}) + + # X-check columns for pitchers (unchanged by boost, copy from boosted dict). + if not is_batter: + for col in PITCHER_XCHECK_COLUMNS: + ratings_data[col] = boosted_ratings[col] + + # Direction rates for batters: copy from source row. + if is_batter: + source_row = next(r for r in ratings_rows if r.vs_hand == vs_hand) + for rate_col in ("pull_rate", "center_rate", "slap_rate"): + ratings_data[rate_col] = getattr(source_row, rate_col) + + # Compute fresh display stats from boosted chance columns. + display_stats = display_stats_fn(boosted_ratings) + ratings_data.update(display_stats) + + RatingsModel.create(**ratings_data) + + # 8. Load card state — needed for atomic state mutations. + card_state = _state_model.get_or_none( + (_state_model.player == player_id) & (_state_model.team == team_id) + ) + if card_state is None: + raise ValueError( + f"No refractor_card_state for player={player_id} team={team_id}" + ) + + # All state mutations in a single atomic block. + with _db.atomic(): + # 8a. Write audit record. + # boost_delta_json stores per-split boosted values including x-check columns + # for pitchers so the full card can be reconstructed from the audit. + audit_data: dict = { + "card_state": card_state.id, + "tier": new_tier, + "variant_created": new_variant, + "boost_delta_json": json.dumps(boosted_splits, default=str), + } + if is_batter: + audit_data["battingcard"] = new_card.id + else: + audit_data["pitchingcard"] = new_card.id + _audit_model.create(**audit_data) + + # 8b. Update RefractorCardState — this is the SOLE tier write on tier-up. + card_state.current_tier = new_tier + card_state.fully_evolved = new_tier >= 4 + card_state.variant = new_variant + card_state.save() + + # 8c. Propagate variant to all Card rows for (player_id, team_id). + _card_model.update(variant=new_variant).where( + (_card_model.player == player_id) & (_card_model.team == team_id) + ).execute() + + logger.debug( + "refractor_boost: applied T%s boost for player=%s team=%s variant=%s", + new_tier, + player_id, + team_id, + new_variant, + ) + + return { + "variant_created": new_variant, + "boost_deltas": dict(boosted_splits), + } diff --git a/app/services/refractor_evaluator.py b/app/services/refractor_evaluator.py index 9c63be5..68a901b 100644 --- a/app/services/refractor_evaluator.py +++ b/app/services/refractor_evaluator.py @@ -9,9 +9,20 @@ evaluate_card() is the main entry point: 4. Compare value to track thresholds to determine new_tier 5. Update card_state.current_value = computed value 6. Update card_state.current_tier = max(current_tier, new_tier) — no regression - 7. Update card_state.fully_evolved = (new_tier >= 4) + (SKIPPED when dry_run=True) + 7. Update card_state.fully_evolved = (current_tier >= 4) + (SKIPPED when dry_run=True) 8. Update card_state.last_evaluated_at = NOW() +When dry_run=True, only steps 5 and 8 are written (current_value and +last_evaluated_at). Steps 6–7 (current_tier and fully_evolved) are intentionally +skipped so that the evaluate-game endpoint can detect a pending tier-up and +delegate the tier write to apply_tier_boost(), which writes tier + variant +atomically. The return dict always includes both "computed_tier" (what the +formula says the tier should be) and "computed_fully_evolved" (whether the +computed tier implies full evolution) so callers can make decisions without +reading the database again. + Idempotent: calling multiple times with the same data produces the same result. Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats), @@ -47,6 +58,7 @@ class _CareerTotals: def evaluate_card( player_id: int, team_id: int, + dry_run: bool = False, _stats_model=None, _state_model=None, _compute_value_fn=None, @@ -56,15 +68,26 @@ def evaluate_card( Sums all BattingSeasonStats or PitchingSeasonStats rows (based on card_type) for (player_id, team_id) across all seasons, then delegates - formula computation and tier classification to the formula engine. The result is written back to refractor_card_state and - returned as a dict. + formula computation and tier classification to the formula engine. The + result is written back to refractor_card_state and returned as a dict. current_tier never decreases (no regression): card_state.current_tier = max(card_state.current_tier, new_tier) + When dry_run=True, only current_value and last_evaluated_at are written — + current_tier and fully_evolved are NOT updated. This allows the caller + (evaluate-game endpoint) to detect a tier-up and delegate the tier write + to apply_tier_boost(), which writes tier + variant atomically. The return + dict always includes "computed_tier" (what the formula says the tier should + be) in addition to "current_tier" (what is actually stored in the DB). + Args: player_id: Player primary key. team_id: Team primary key. + dry_run: When True, skip writing current_tier and fully_evolved so + that apply_tier_boost() can write them atomically with variant + creation. Defaults to False (existing behaviour for the manual + /evaluate endpoint). _stats_model: Override for BattingSeasonStats/PitchingSeasonStats (used in tests to inject a stub model with all stat fields). _state_model: Override for RefractorCardState (used in tests to avoid @@ -75,8 +98,10 @@ def evaluate_card( (used in tests). Returns: - Dict with updated current_tier, current_value, fully_evolved, - last_evaluated_at (ISO-8601 string). + Dict with current_tier, computed_tier, current_value, fully_evolved, + last_evaluated_at (ISO-8601 string). "computed_tier" reflects what + the formula computed; "current_tier" reflects what is stored in the DB + (which may differ when dry_run=True and a tier-up is pending). Raises: ValueError: If no refractor_card_state row exists for (player_id, team_id). @@ -169,21 +194,30 @@ def evaluate_card( value = _compute_value_fn(track.card_type, totals) new_tier = _tier_from_value_fn(value, track) - # 5–8. Update card state (no tier regression) + # 5–8. Update card state. now = datetime.now() + computed_tier = new_tier + computed_fully_evolved = computed_tier >= 4 + + # Always update value and timestamp; current_tier and fully_evolved are + # skipped when dry_run=True so that apply_tier_boost() can write them + # atomically with variant creation on tier-up. card_state.current_value = value - card_state.current_tier = max(card_state.current_tier, new_tier) - card_state.fully_evolved = card_state.current_tier >= 4 card_state.last_evaluated_at = now + if not dry_run: + card_state.current_tier = max(card_state.current_tier, new_tier) + card_state.fully_evolved = card_state.current_tier >= 4 card_state.save() logging.debug( - "refractor_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s", + "refractor_eval: player=%s team=%s value=%.2f computed_tier=%s " + "stored_tier=%s dry_run=%s", player_id, team_id, value, + computed_tier, card_state.current_tier, - card_state.fully_evolved, + dry_run, ) return { @@ -191,6 +225,8 @@ def evaluate_card( "team_id": team_id, "current_value": card_state.current_value, "current_tier": card_state.current_tier, + "computed_tier": computed_tier, + "computed_fully_evolved": computed_fully_evolved, "fully_evolved": card_state.fully_evolved, "last_evaluated_at": card_state.last_evaluated_at.isoformat(), } diff --git a/tests/test_postgame_refractor.py b/tests/test_postgame_refractor.py index 51659a5..774e9de 100644 --- a/tests/test_postgame_refractor.py +++ b/tests/test_postgame_refractor.py @@ -48,35 +48,41 @@ import os os.environ.setdefault("API_TOKEN", "test-token") import app.services.season_stats as _season_stats_module +import app.services.refractor_boost as _refractor_boost_module import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient from peewee import SqliteDatabase from app.db_engine import ( + BattingCard, + BattingCardRatings, Cardset, - RefractorCardState, - RefractorCosmetic, - RefractorTierBoost, - RefractorTrack, + Decision, + Event, MlbPlayer, Pack, PackType, + PitchingCard, + PitchingCardRatings, Player, BattingSeasonStats, PitchingSeasonStats, ProcessedGame, Rarity, + RefractorBoostAudit, + RefractorCardState, + RefractorCosmetic, + RefractorTierBoost, + RefractorTrack, Roster, RosterSlot, ScoutClaim, ScoutOpportunity, StratGame, StratPlay, - Decision, Team, Card, - Event, ) # --------------------------------------------------------------------------- @@ -111,15 +117,22 @@ _WP13_MODELS = [ BattingSeasonStats, PitchingSeasonStats, ProcessedGame, + BattingCard, + BattingCardRatings, + PitchingCard, + PitchingCardRatings, RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic, + RefractorBoostAudit, ] -# Patch the service-layer 'db' reference to use our shared test database so -# that db.atomic() in update_season_stats() operates on the same connection. +# Patch the service-layer 'db' references to use our shared test database so +# that db.atomic() in update_season_stats() and apply_tier_boost() operate on +# the same connection. _season_stats_module.db = _wp13_db +_refractor_boost_module.db = _wp13_db # --------------------------------------------------------------------------- # Auth header used by every authenticated request @@ -323,6 +336,65 @@ def _make_state( ) +# Base batter ratings that sum to exactly 108 for use in tier advancement tests. +# apply_tier_boost() requires a base card (variant=0) with ratings rows to +# create boosted variant cards — tests that push past T1 must set this up. +_WP13_BASE_BATTER_RATINGS = { + "homerun": 3.0, + "bp_homerun": 1.0, + "triple": 0.5, + "double_three": 2.0, + "double_two": 2.0, + "double_pull": 6.0, + "single_two": 4.0, + "single_one": 12.0, + "single_center": 5.0, + "bp_single": 2.0, + "hbp": 3.0, + "walk": 7.0, + "strikeout": 15.0, + "lineout": 3.0, + "popout": 2.0, + "flyout_a": 5.0, + "flyout_bq": 4.0, + "flyout_lf_b": 3.0, + "flyout_rf_b": 9.0, + "groundout_a": 6.0, + "groundout_b": 8.0, + "groundout_c": 5.5, +} + + +def _make_base_batter_card(player): + """Create a BattingCard (variant=0) with two ratings rows for apply_tier_boost().""" + card = BattingCard.create( + player=player, + variant=0, + steal_low=1, + steal_high=6, + steal_auto=False, + steal_jump=0.5, + bunting="C", + hit_and_run="B", + running=3, + offense_col=2, + hand="R", + ) + for vs_hand in ("L", "R"): + BattingCardRatings.create( + battingcard=card, + vs_hand=vs_hand, + pull_rate=0.4, + center_rate=0.35, + slap_rate=0.25, + avg=0.300, + obp=0.370, + slg=0.450, + **_WP13_BASE_BATTER_RATINGS, + ) + return card + + # --------------------------------------------------------------------------- # Tests: POST /api/v2/season-stats/update-game/{game_id} # --------------------------------------------------------------------------- @@ -486,6 +558,8 @@ def test_evaluate_game_tier_advancement(client): game = _make_game(team_a, team_b) track = _make_track(name="WP13 Tier Adv Track") _make_state(batter, team_a, track, current_tier=0, current_value=34.0) + # Phase 2: base card required so apply_tier_boost() can create a variant. + _make_base_batter_card(batter) # Seed prior stats: 34 PA (value = 34; T1 threshold = 37) BattingSeasonStats.create( @@ -567,6 +641,8 @@ def test_evaluate_game_tier_ups_in_response(client): game = _make_game(team_a, team_b) track = _make_track(name="WP13 Tier-Ups Track") _make_state(batter, team_a, track, current_tier=0) + # Phase 2: base card required so apply_tier_boost() can create a variant. + _make_base_batter_card(batter) # Seed prior stats below threshold BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) @@ -798,3 +874,432 @@ def test_evaluate_game_error_isolation(client, monkeypatch): # The failing player must not appear in tier_ups failing_ids = [tu["player_id"] for tu in data["tier_ups"]] assert fail_player_id not in failing_ids + + +# --------------------------------------------------------------------------- +# Base pitcher card ratings that sum to exactly 108 for use in pitcher tier +# advancement tests. +# Variable columns (18): sum to 79. +# X-check columns (9): sum to 29. +# Total: 108. +# --------------------------------------------------------------------------- + +_WP13_BASE_PITCHER_RATINGS = { + # 18 variable outcome columns (sum = 79) + "homerun": 2.0, + "bp_homerun": 1.0, + "triple": 0.5, + "double_three": 1.5, + "double_two": 2.0, + "double_cf": 2.0, + "single_two": 3.0, + "single_one": 4.0, + "single_center": 3.0, + "bp_single": 2.0, + "hbp": 1.0, + "walk": 3.0, + "strikeout": 30.0, + "flyout_lf_b": 4.0, + "flyout_cf_b": 5.0, + "flyout_rf_b": 5.0, + "groundout_a": 5.0, + "groundout_b": 5.0, + # 9 x-check columns (sum = 29) + "xcheck_p": 4.0, + "xcheck_c": 3.0, + "xcheck_1b": 3.0, + "xcheck_2b": 3.0, + "xcheck_3b": 3.0, + "xcheck_ss": 3.0, + "xcheck_lf": 3.0, + "xcheck_cf": 3.0, + "xcheck_rf": 4.0, +} + + +def _make_base_pitcher_card(player): + """Create a PitchingCard (variant=0) with two ratings rows for apply_tier_boost(). + + Analogous to _make_base_batter_card but for pitcher cards. Ratings are + seeded from _WP13_BASE_PITCHER_RATINGS which satisfies the 108-sum invariant + required by apply_tier_boost() (18 variable cols summing to 79 plus 9 + x-check cols summing to 29 = 108 total). + """ + card = PitchingCard.create( + player=player, + variant=0, + balk=1, + wild_pitch=2, + hold=3, + starter_rating=7, + relief_rating=5, + closer_rating=None, + batting=None, + offense_col=1, + hand="R", + ) + for vs_hand in ("L", "R"): + PitchingCardRatings.create( + pitchingcard=card, + vs_hand=vs_hand, + avg=0.250, + obp=0.310, + slg=0.360, + **_WP13_BASE_PITCHER_RATINGS, + ) + return card + + +# --------------------------------------------------------------------------- +# Gap 1: REFRACTOR_BOOST_ENABLED=false kill switch +# --------------------------------------------------------------------------- + + +def test_evaluate_game_boost_disabled_skips_tier_up(client, monkeypatch): + """When REFRACTOR_BOOST_ENABLED=false, tier-ups are not reported even if formula says tier-up. + + What: Seed a batter at tier=0 with stats above T1 (pa=34 prior + 4-PA game + pushes total to 38 > T1 threshold of 37). Set REFRACTOR_BOOST_ENABLED=false + before calling evaluate-game. + + Why: The kill switch must suppress all tier-up notifications and leave + current_tier unchanged so that no variant card is created and no Discord + announcement is sent. If the kill switch is ignored the bot will announce + tier-ups during maintenance windows when card creation is deliberately + disabled. + """ + monkeypatch.setenv("REFRACTOR_BOOST_ENABLED", "false") + + team_a = _make_team("BD1", gmid=20201) + team_b = _make_team("BD2", gmid=20202) + batter = _make_player("WP13 KillSwitch Batter") + pitcher = _make_player("WP13 KillSwitch Pitcher", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track(name="WP13 KillSwitch Track") + _make_state(batter, team_a, track, current_tier=0, current_value=0.0) + _make_base_batter_card(batter) + + # Seed prior stats just below T1 + BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) + + # Game adds 4 PA — total = 38 > T1 (37) + for i in range(4): + _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + + client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) + resp = client.post( + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + # Kill switch: the boost block is bypassed so apply_tier_boost() is never + # called and current_tier must remain 0 in the DB. + state = RefractorCardState.get( + (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) + ) + assert state.current_tier == 0 + + # No BattingCard variant must have been created (boost never ran). + from app.services.refractor_boost import compute_variant_hash + + t1_hash = compute_variant_hash(batter.player_id, 1) + assert ( + BattingCard.get_or_none( + (BattingCard.player == batter) & (BattingCard.variant == t1_hash) + ) + is None + ), "Variant card must not be created when boost is disabled" + + # When boost is disabled, no tier_up notification is sent — the router + # skips the append entirely to prevent false notifications to the bot. + assert len(data["tier_ups"]) == 0 + + +# --------------------------------------------------------------------------- +# Gap 4: Multi-tier jump T0 -> T2 at HTTP layer +# --------------------------------------------------------------------------- + + +def test_evaluate_game_multi_tier_jump(client): + """Player with stats above T2 threshold jumps from T0 to T2 in one game. + + What: Seed a batter at tier=0 with no prior stats. The game itself + provides stats in range [T2=149, T3=448). + Using pa=50, hit=50 (all singles): value = 50 + 50*2 = 150. + + Why: The evaluate-game loop must iterate through each tier from old+1 to + computed_tier, calling apply_tier_boost() once per tier. A multi-tier jump + must produce variant cards for every intermediate tier and report a single + tier_up entry whose new_tier equals the highest tier reached. + + The variant_created in the response must match the T2 hash (not T1), because + the last apply_tier_boost() call returns the T2 variant. + """ + from app.services.refractor_boost import compute_variant_hash + + team_a = _make_team("MJ1", gmid=20211) + team_b = _make_team("MJ2", gmid=20212) + batter = _make_player("WP13 MultiJump Batter") + pitcher = _make_player("WP13 MultiJump Pitcher", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track(name="WP13 MultiJump Track") + _make_state(batter, team_a, track, current_tier=0, current_value=0.0) + _make_base_batter_card(batter) + + # Target value in range [T2=149, T3=448). + # formula: pa + tb*2, tb = singles + 2*doubles + 3*triples + 4*HR. + # 50 PA, 50 hits (all singles): tb = 50; value = 50 + 50*2 = 150. + # 150 >= T2 (149) and < T3 (448) so tier lands exactly at 2. + for i in range(50): + _make_play( + game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0 + ) + + client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) + resp = client.post( + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + # Must have exactly one tier_up entry for this player. + assert len(data["tier_ups"]) == 1 + tu = data["tier_ups"][0] + assert tu["old_tier"] == 0 + assert tu["new_tier"] == 2 + + # The variant_created must match T2 hash (last boost iteration). + expected_t2_hash = compute_variant_hash(batter.player_id, 2) + assert tu["variant_created"] == expected_t2_hash + + # Both T1 and T2 variant BattingCard rows must exist. + t1_hash = compute_variant_hash(batter.player_id, 1) + t2_hash = compute_variant_hash(batter.player_id, 2) + assert ( + BattingCard.get_or_none( + (BattingCard.player == batter) & (BattingCard.variant == t1_hash) + ) + is not None + ), "T1 variant card missing" + assert ( + BattingCard.get_or_none( + (BattingCard.player == batter) & (BattingCard.variant == t2_hash) + ) + is not None + ), "T2 variant card missing" + + # DB state must reflect T2. + state = RefractorCardState.get( + (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) + ) + assert state.current_tier == 2 + + +# --------------------------------------------------------------------------- +# Gap 5: Pitcher through evaluate-game +# --------------------------------------------------------------------------- + + +def test_evaluate_game_pitcher_tier_advancement(client): + """Pitcher reaching T1 through evaluate-game creates a boosted PitchingCard variant. + + What: Create a pitcher player with a PitchingCard + PitchingCardRatings + (variant=0) and a RefractorCardState on the 'sp' track. Seed + PitchingSeasonStats with outs and strikeouts just below T1 (prior season), + then add a game where the pitcher appears and records enough additional outs + to cross the threshold. + + The pitcher formula is: outs/3 + strikeouts. Track thresholds are the same + (t1=37). Prior season: outs=60, strikeouts=16 -> value = 20 + 16 = 36. + Game adds 3 outs + 1 K -> career total outs=63, strikeouts=17 -> 21+17=38. + + Why: Pitcher boost must follow the same evaluate-game flow as batter boost. + If card_type='sp' is not handled, the pitcher track silently skips the boost + and no tier_ups entry is emitted even when the threshold is passed. + """ + team_a = _make_team("PT1", gmid=20221) + team_b = _make_team("PT2", gmid=20222) + pitcher = _make_player("WP13 TierPitcher", pos="SP") + # We need a batter for the play records (pitcher is pitcher side). + batter = _make_player("WP13 PitcherTest Batter") + game = _make_game(team_a, team_b) + + sp_track, _ = RefractorTrack.get_or_create( + name="WP13 SP Track", + defaults=dict( + card_type="sp", + formula="outs / 3 + strikeouts", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ), + ) + _make_state(pitcher, team_a, sp_track, current_tier=0, current_value=0.0) + _make_base_pitcher_card(pitcher) + + # Prior season: outs=60, K=16 -> 60/3 + 16 = 36 (below T1=37) + PitchingSeasonStats.create( + player=pitcher, + team=team_a, + season=10, + outs=60, + strikeouts=16, + ) + + # Game: pitcher records 3 outs (1 inning) and 1 K. + # Career after game: outs=63, K=17 -> 63/3 + 17 = 21 + 17 = 38 > T1=37. + _make_play( + game, + 1, + batter, + team_b, + pitcher, + team_a, + pa=1, + ab=1, + outs=3, + so=1, + ) + + client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) + resp = client.post( + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + # The pitcher must appear in tier_ups. + pitcher_ups = [ + tu for tu in data["tier_ups"] if tu["player_id"] == pitcher.player_id + ] + assert len(pitcher_ups) == 1, ( + f"Expected 1 tier_up for pitcher, got: {data['tier_ups']}" + ) + tu = pitcher_ups[0] + assert tu["old_tier"] == 0 + assert tu["new_tier"] >= 1 + + # A boosted PitchingCard variant must exist in the database. + from app.services.refractor_boost import compute_variant_hash + + t1_hash = compute_variant_hash(pitcher.player_id, 1) + variant_card = PitchingCard.get_or_none( + (PitchingCard.player == pitcher) & (PitchingCard.variant == t1_hash) + ) + assert variant_card is not None, "T1 PitchingCard variant was not created" + + +# --------------------------------------------------------------------------- +# Gap 7: variant_created field in tier_up response +# --------------------------------------------------------------------------- + + +def test_evaluate_game_tier_up_includes_variant_created(client): + """Tier-up response includes variant_created with the correct hash. + + What: Seed a batter at tier=0 with stats that push past T1. After + evaluate-game, the tier_ups entry must contain a 'variant_created' key + whose value matches compute_variant_hash(player_id, 1) and is a positive + non-zero integer. + + Why: The bot reads variant_created to update the card image URL after a + tier-up. A missing or incorrect hash will point the bot at the wrong card + image (or no image at all), breaking the tier-up animation in Discord. + """ + from app.services.refractor_boost import compute_variant_hash + + team_a = _make_team("VC1", gmid=20231) + team_b = _make_team("VC2", gmid=20232) + batter = _make_player("WP13 VariantCreated Batter") + pitcher = _make_player("WP13 VariantCreated Pitcher", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track(name="WP13 VariantCreated Track") + _make_state(batter, team_a, track, current_tier=0, current_value=0.0) + _make_base_batter_card(batter) + + # Prior season: pa=34, well below T1=37 + BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) + + # Game: 4 PA -> total pa=38 > T1=37 + for i in range(4): + _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + + client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) + resp = client.post( + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + assert len(data["tier_ups"]) == 1 + tu = data["tier_ups"][0] + + # variant_created must be present, non-zero, and match the T1 hash. + assert "variant_created" in tu, "variant_created key missing from tier_up entry" + assert isinstance(tu["variant_created"], int) + assert tu["variant_created"] != 0 + + expected_hash = compute_variant_hash(batter.player_id, 1) + assert tu["variant_created"] == expected_hash + + +# --------------------------------------------------------------------------- +# Gap 8: Empty card_type on track produces no tier-up +# --------------------------------------------------------------------------- + + +def test_evaluate_game_skips_boost_when_track_has_no_card_type(client): + """Track with empty card_type produces no tier-up notification. + + What: Create a RefractorTrack with card_type="" (empty string) and seed a + batter with stats above T1. Call evaluate-game. + + Why: apply_tier_boost() requires a valid card_type to know which card model + to use. When card_type is empty or None the boost cannot run. The endpoint + must log a warning and skip the tier-up notification entirely — it must NOT + report a tier-up that was never applied to the database. Reporting a phantom + tier-up would cause the bot to announce a card upgrade that does not exist. + """ + team_a = _make_team("NC1", gmid=20241) + team_b = _make_team("NC2", gmid=20242) + batter = _make_player("WP13 NoCardType Batter") + pitcher = _make_player("WP13 NoCardType Pitcher", pos="SP") + game = _make_game(team_a, team_b) + + # Create track with card_type="" — an intentionally invalid/empty value. + empty_type_track, _ = RefractorTrack.get_or_create( + name="WP13 NoCardType Track", + defaults=dict( + card_type="", + formula="pa + tb * 2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ), + ) + _make_state(batter, team_a, empty_type_track, current_tier=0, current_value=0.0) + + # Prior stats below T1; game pushes past T1. + BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) + + for i in range(4): + _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + + client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER) + resp = client.post( + f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + # No tier-up must be reported when card_type is empty. + assert data["tier_ups"] == [] + + # current_tier must remain 0 — boost was never applied. + state = RefractorCardState.get( + (RefractorCardState.player == batter) & (RefractorCardState.team == team_a) + ) + assert state.current_tier == 0 diff --git a/tests/test_refractor_boost.py b/tests/test_refractor_boost.py index 3c5e97a..0b43971 100644 --- a/tests/test_refractor_boost.py +++ b/tests/test_refractor_boost.py @@ -12,6 +12,8 @@ from app.services.refractor_boost import ( apply_batter_boost, apply_pitcher_boost, compute_variant_hash, + compute_batter_display_stats, + compute_pitcher_display_stats, BATTER_OUTCOME_COLUMNS, PITCHER_OUTCOME_COLUMNS, PITCHER_PRIORITY, @@ -126,11 +128,11 @@ 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. + Full card sums to 108 (79 variable + 29 x-checks). double_cf=2.95 so + the priority algorithm will start there before moving to singles. """ return { - # Variable columns (sum=79) + # Variable columns (79 of 108; x-checks add 29) "homerun": 3.3, "bp_homerun": 2.0, "triple": 0.75, @@ -167,11 +169,11 @@ def _no_doubles_pitcher(): 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. + Variable columns sum to 79 (79 of 108; x-checks add 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) + # Variable columns (79 of 108; x-checks add 29) "homerun": 2.0, "bp_homerun": 1.0, "triple": 0.5, @@ -511,47 +513,50 @@ class TestBatterBoostTruncation: 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. + x-checks sum to 29, full card 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. + the 108-sum card invariant after one boost (variable subset stays at 79). What: Boost the Gibson vL card once. Assert that the 18 variable - columns (PITCHER_OUTCOME_COLUMNS) still sum to 79. + columns (PITCHER_OUTCOME_COLUMNS) still sum to 79 (the variable-column + subset of the 108-total card). 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. + chance is created or destroyed, the variable subset drifts from 79, + breaking the 108-sum card invariant and making game simulation + results 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. + singles instead, and the 18-column variable sum stays at 79 (of 108 total). 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. + variable-column subset (79 of 108) 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. + a negative entry and violating the 108-sum card invariant. """ 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. + """Four successive boosts: variable-column sum==79 (of 108 total) 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. + assert that the 18-column variable 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 @@ -563,7 +568,7 @@ class TestPitcherBoost108Sum: 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}" + f"Variable-column sum (79 of 108) drifted to {total} after tier {tier}" ) for col in PITCHER_OUTCOME_COLUMNS: assert card[col] >= -1e-9, ( @@ -743,8 +748,8 @@ class TestPitcherBoostZeroSkip: 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. + the variable-column subset (79 of 108 total) must be preserved, + strikeout must not change, and every priority column must still be 0.0. """ priority_cols = {col for col, _ in PITCHER_PRIORITY} card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} @@ -764,7 +769,7 @@ class TestPitcherBoostZeroSkip: result = apply_pitcher_boost(card) - # Variable columns still sum to 79 + # Variable columns still sum to 79 (79 of 108 total; x-checks unchanged at 29) 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) @@ -904,3 +909,271 @@ class TestVariantHash: f"Cosmetics order affected hash: ['foil','chrome']={h1}, " f"['chrome','foil']={h2}" ) + + +# --------------------------------------------------------------------------- +# Batter display stats +# --------------------------------------------------------------------------- + + +def _zeroed_batter(outs: float = 108.0) -> dict: + """Return a batter dict where all hit/walk/hbp columns are 0 and all + chances are absorbed by groundout_c. + + The 22-column sum is exactly 108.0 by construction. Helper used to build + clean minimal cards that isolate individual formula terms. + """ + card = {col: 0.0 for col in BATTER_OUTCOME_COLUMNS} + card["groundout_c"] = outs + return card + + +class TestBatterDisplayStats: + """Unit tests for compute_batter_display_stats(ratings) -> dict. + + All tests call the function with plain dicts (no DB, no fixtures). + The function is pure: same input always produces the same output. + + Formula under test (denominator is always 108): + avg = (HR + bp_HR/2 + triple + dbl_3 + dbl_2 + dbl_pull + + sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108 + obp = avg + (hbp + walk) / 108 + slg = (HR*4 + bp_HR*2 + triple*3 + dbl_3*2 + dbl_2*2 + dbl_pull*2 + + sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108 + """ + + def test_avg_reflects_hit_chances(self): + """homerun=9.0 alone (rest in outs summing to 108) yields avg == 9/108. + + What: Build a card where only homerun is non-zero among the hit columns; + all 108 chances are accounted for (9 HR + 99 groundout_c). Assert that + avg equals 9.0/108. + + Why: Verifies that the homerun column enters the avg numerator at full + weight (coefficient 1.0) and that the denominator is 108. + """ + card = _zeroed_batter(99.0) + card["homerun"] = 9.0 + result = compute_batter_display_stats(card) + assert result["avg"] == pytest.approx(9.0 / 108, abs=1e-6) + + def test_bp_homerun_half_weighted_in_avg(self): + """bp_homerun=6.0 contributes only 3.0/108 to avg (half weight). + + What: Card with bp_homerun=6.0, rest in outs. Assert avg == 3.0/108. + + Why: Ballpark home runs are treated as weaker contact events — the + formula halves their contribution to batting average. Getting the + coefficient wrong (using 1.0 instead of 0.5) would inflate avg for + cards with significant bp_homerun values. + """ + card = _zeroed_batter(102.0) + card["bp_homerun"] = 6.0 + result = compute_batter_display_stats(card) + assert result["avg"] == pytest.approx(3.0 / 108, abs=1e-6) + + def test_bp_single_half_weighted_in_avg(self): + """bp_single=8.0 contributes only 4.0/108 to avg (half weight). + + What: Card with bp_single=8.0, rest in outs. Assert avg == 4.0/108. + + Why: Ballpark singles are similarly half-weighted. Confirms the /2 + divisor is applied to bp_single in the avg numerator. + """ + card = _zeroed_batter(100.0) + card["bp_single"] = 8.0 + result = compute_batter_display_stats(card) + assert result["avg"] == pytest.approx(4.0 / 108, abs=1e-6) + + def test_obp_adds_hbp_and_walk_on_top_of_avg(self): + """obp == avg + (hbp + walk) / 108 when homerun=9, hbp=9, walk=9. + + What: Card with homerun=9, hbp=9, walk=9, rest in outs (81 chances). + Compute avg first (9/108), then verify obp == avg + 18/108. + + Why: OBP extends AVG by counting on-base events that are not hits. + If hbp or walk were inadvertently included in the avg numerator, obp + would double-count them. This test confirms they are added only once, + outside the avg sub-expression. + """ + card = _zeroed_batter(81.0) + card["homerun"] = 9.0 + card["hbp"] = 9.0 + card["walk"] = 9.0 + result = compute_batter_display_stats(card) + expected_avg = 9.0 / 108 + expected_obp = expected_avg + 18.0 / 108 + assert result["avg"] == pytest.approx(expected_avg, abs=1e-6) + assert result["obp"] == pytest.approx(expected_obp, abs=1e-6) + + def test_slg_uses_correct_weights(self): + """SLG numerator: HR*4 + triple*3 + double_pull*2 + single_one*1. + + What: Card with homerun=4, triple=3, double_pull=2, single_one=1 (and + 98 outs to sum to 108). Assert slg == (4*4 + 3*3 + 2*2 + 1*1) / 108 + == 30/108. + + Why: Each extra-base hit type carries a different base-advancement + weight in SLG. Any coefficient error (e.g. treating a triple as a + double) would systematically understate or overstate slugging for + power hitters. + """ + card = _zeroed_batter(98.0) + card["homerun"] = 4.0 + card["triple"] = 3.0 + card["double_pull"] = 2.0 + card["single_one"] = 1.0 + result = compute_batter_display_stats(card) + expected_slg = (4 * 4 + 3 * 3 + 2 * 2 + 1 * 1) / 108 + assert result["slg"] == pytest.approx(expected_slg, abs=1e-6) + + def test_all_zeros_returns_zeros(self): + """Card with all hit/walk/hbp columns set to 0 produces avg=obp=slg=0. + + What: Build a card where the 22 outcome columns sum to 108 but every + hit, walk, and hbp column is 0 (all chances in groundout_c). Assert + that avg, obp, and slg are all 0. + + Why: Verifies the function does not produce NaN or raise on a degenerate + all-out card and that the zero numerator path returns clean zeros. + """ + card = _zeroed_batter(108.0) + result = compute_batter_display_stats(card) + assert result["avg"] == pytest.approx(0.0, abs=1e-6) + assert result["obp"] == pytest.approx(0.0, abs=1e-6) + assert result["slg"] == pytest.approx(0.0, abs=1e-6) + + def test_matches_known_card(self): + """Display stats for the silver batter fixture are internally consistent. + + What: Pass the _silver_batter_vr() fixture dict to + compute_batter_display_stats and verify that avg > 0, obp > avg, and + slg > avg — the expected ordering for any hitter with positive hit and + extra-base-hit chances. + + Why: Confirms the function produces the correct relative ordering on a + realistic card. Absolute values are not hard-coded here because the + fixture is designed for boost tests, not display-stat tests; relative + ordering is sufficient to detect sign errors or column swaps. + """ + result = compute_batter_display_stats(_silver_batter_vr()) + assert result["avg"] > 0 + assert result["obp"] > result["avg"] + assert result["slg"] > result["avg"] + + +# --------------------------------------------------------------------------- +# Pitcher display stats +# --------------------------------------------------------------------------- + + +def _zeroed_pitcher(strikeout: float = 79.0) -> dict: + """Return a pitcher dict where all hit/walk/hbp columns are 0 and all + variable chances are in strikeout. + + The 18 PITCHER_OUTCOME_COLUMNS sum to 79 by construction. X-check + columns are not included because compute_pitcher_display_stats only reads + the hit/walk columns from PITCHER_OUTCOME_COLUMNS. + """ + card = {col: 0.0 for col in PITCHER_OUTCOME_COLUMNS} + card["strikeout"] = strikeout + return card + + +class TestPitcherDisplayStats: + """Unit tests for compute_pitcher_display_stats(ratings) -> dict. + + The pitcher formula mirrors the batter formula except that double_pull is + replaced by double_cf (the pitcher-specific double column). All other hit + columns are identical. + + Formula under test (denominator is always 108): + avg = (HR + bp_HR/2 + triple + dbl_3 + dbl_2 + dbl_cf + + sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108 + obp = avg + (hbp + walk) / 108 + slg = (HR*4 + bp_HR*2 + triple*3 + dbl_3*2 + dbl_2*2 + dbl_cf*2 + + sgl_2 + sgl_1 + sgl_ctr + bp_sgl/2) / 108 + """ + + def test_pitcher_uses_double_cf_not_double_pull(self): + """double_cf=6.0 contributes 6.0/108 to pitcher avg; double_pull is absent. + + What: Card with double_cf=6.0 and strikeout=73.0 (sum=79). Assert avg + == 6.0/108. + + Why: The pitcher formula uses double_cf instead of double_pull (which + does not exist on pitching cards). If the implementation accidentally + reads double_pull from a pitcher dict it would raise a KeyError or + silently read 0, producing a wrong avg. + """ + card = _zeroed_pitcher(73.0) + card["double_cf"] = 6.0 + result = compute_pitcher_display_stats(card) + assert result["avg"] == pytest.approx(6.0 / 108, abs=1e-6) + + def test_pitcher_slg_double_cf_costs_2(self): + """double_cf=6.0 alone contributes 6.0*2/108 to pitcher slg. + + What: Same card as above (double_cf=6.0, all else 0). Assert slg + == 12.0/108. + + Why: Doubles carry a weight of 2 in SLG (two total bases). Verifies + that the coefficient is correctly applied to double_cf in the slg + formula. + """ + card = _zeroed_pitcher(73.0) + card["double_cf"] = 6.0 + result = compute_pitcher_display_stats(card) + assert result["slg"] == pytest.approx(12.0 / 108, abs=1e-6) + + def test_pitcher_bp_homerun_half_weighted(self): + """bp_homerun=4.0 contributes only 2.0/108 to pitcher avg (half weight). + + What: Card with bp_homerun=4.0 and strikeout=75.0. Assert avg == 2.0/108. + + Why: Mirrors the batter bp_homerun test — the half-weight rule applies + to both card types. Confirms the /2 divisor is present in the pitcher + formula. + """ + card = _zeroed_pitcher(75.0) + card["bp_homerun"] = 4.0 + result = compute_pitcher_display_stats(card) + assert result["avg"] == pytest.approx(2.0 / 108, abs=1e-6) + + def test_pitcher_obp_formula_matches_batter(self): + """obp == avg + (hbp + walk) / 108, identical structure to batter formula. + + What: Build a pitcher card with homerun=6, hbp=6, walk=6 (strikeout=61 + to reach variable sum of 79). Compute avg = 6/108, then assert obp == + avg + 12/108. + + Why: The obp addend (hbp + walk) / 108 must be present and correct on + pitcher cards, exactly as it is for batters. A formula that + accidentally omits hbp or walk from pitcher obp would understate on-base + percentage for walks-heavy pitchers. + """ + card = _zeroed_pitcher(61.0) + card["homerun"] = 6.0 + card["hbp"] = 6.0 + card["walk"] = 6.0 + result = compute_pitcher_display_stats(card) + expected_avg = 6.0 / 108 + expected_obp = expected_avg + 12.0 / 108 + assert result["avg"] == pytest.approx(expected_avg, abs=1e-6) + assert result["obp"] == pytest.approx(expected_obp, abs=1e-6) + + def test_matches_known_pitcher_card(self): + """Display stats for the Gibson vL fixture are internally consistent. + + What: Pass the _singles_pitcher_vl() fixture dict to + compute_pitcher_display_stats and verify avg > 0, obp > avg, slg > avg. + + Why: The Gibson card has both hit and walk columns, so the correct + relative ordering (obp > avg, slg > avg) must hold. This confirms + the function works end-to-end on a realistic pitcher card rather than + a minimal synthetic one. + """ + result = compute_pitcher_display_stats(_singles_pitcher_vl()) + assert result["avg"] > 0 + assert result["obp"] > result["avg"] + assert result["slg"] > result["avg"] diff --git a/tests/test_refractor_boost_integration.py b/tests/test_refractor_boost_integration.py new file mode 100644 index 0000000..636f3ba --- /dev/null +++ b/tests/test_refractor_boost_integration.py @@ -0,0 +1,1305 @@ +"""Integration tests for apply_tier_boost() orchestration. + +Tests the full flow: create base card + ratings -> apply boost -> verify +variant card created with correct ratings, audit record written, card state +updated, and Card instances propagated. + +Uses a named shared-memory SQLite database (same pattern as +test_postgame_refractor.py) so that db.atomic() inside apply_tier_boost() +and test assertions operate on the same underlying connection. The +refractor_boost module's 'db' reference is patched to point at this +shared-memory database. + +The conftest autouse setup_test_db fixture is overridden by the module-level +setup_boost_int_db fixture (autouse=True) so each test gets a fresh schema. + +BattingCard, BattingCardRatings, PitchingCard, PitchingCardRatings, and +RefractorBoostAudit are included in the model list so apply_tier_boost() can +create rows during tests. + +All pitcher sum assertions use the full 108-sum (18 variable + 9 x-check +columns), not just the 79 variable-column subset, because that is the +card-level invariant apply_tier_boost() must preserve. +""" + +import os + +# Must set before any app imports so SKIP_TABLE_CREATION is True in db_engine. +os.environ["DATABASE_TYPE"] = "postgresql" +os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy") +os.environ.setdefault("API_TOKEN", "test-token") + +import app.services.refractor_boost as _refractor_boost_module +import pytest +from peewee import SqliteDatabase + +from app.db_engine import ( + BattingCard, + BattingCardRatings, + BattingSeasonStats, + Card, + Cardset, + Decision, + Event, + MlbPlayer, + Pack, + PackType, + PitchingCard, + PitchingCardRatings, + PitchingSeasonStats, + Player, + ProcessedGame, + Rarity, + RefractorBoostAudit, + RefractorCardState, + RefractorCosmetic, + RefractorTierBoost, + RefractorTrack, + Roster, + RosterSlot, + ScoutClaim, + ScoutOpportunity, + StratGame, + StratPlay, + Team, +) +from app.services.refractor_boost import ( + BATTER_OUTCOME_COLUMNS, + PITCHER_OUTCOME_COLUMNS, + PITCHER_XCHECK_COLUMNS, + apply_tier_boost, + compute_variant_hash, +) +from app.services.refractor_evaluator import evaluate_card + +# --------------------------------------------------------------------------- +# Named shared-memory SQLite for integration tests. +# A shared-cache URI allows all threads (e.g. TestClient routes and pytest +# fixtures) to share the same in-memory DB. Required because SQLite +# :memory: is per-connection. +# --------------------------------------------------------------------------- +_boost_int_db = SqliteDatabase( + "file:boost_int_test?mode=memory&cache=shared", + uri=True, + pragmas={"foreign_keys": 1}, +) + +# All models in dependency order (parents before children). +_BOOST_INT_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + ScoutOpportunity, + ScoutClaim, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + BattingCard, + BattingCardRatings, + PitchingCard, + PitchingCardRatings, + RefractorTrack, + RefractorCardState, + RefractorTierBoost, + RefractorCosmetic, + RefractorBoostAudit, +] + +# Patch the service-layer 'db' reference so that db.atomic() inside +# apply_tier_boost() operates on the shared-memory SQLite connection. +_refractor_boost_module.db = _boost_int_db + + +# --------------------------------------------------------------------------- +# Database fixture — binds all models to the shared-memory SQLite db. +# autouse=True so every test automatically gets a fresh schema. +# This overrides the conftest autouse setup_test_db fixture for this module. +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def setup_boost_int_db(): + """Bind integration test models to the shared-memory SQLite db. + + Creates tables before each test and drops them in reverse order after. + The autouse=True ensures every test in this module gets an isolated schema + without explicitly requesting the fixture. + """ + _boost_int_db.bind(_BOOST_INT_MODELS) + _boost_int_db.connect(reuse_if_open=True) + _boost_int_db.create_tables(_BOOST_INT_MODELS) + yield _boost_int_db + _boost_int_db.drop_tables(list(reversed(_BOOST_INT_MODELS)), safe=True) + + +# --------------------------------------------------------------------------- +# Shared factory helpers +# --------------------------------------------------------------------------- + + +def _make_rarity(): + r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"}) + return r + + +def _make_player(name="Test Player", pos="1B"): + cs, _ = Cardset.get_or_create( + name="BI Test Set", + defaults={"description": "boost int test", "total_cards": 100}, + ) + return Player.create( + p_name=name, + rarity=_make_rarity(), + cardset=cs, + set_num=1, + pos_1=pos, + image="https://example.com/img.png", + mlbclub="TST", + franchise="TST", + description=f"boost int test: {name}", + ) + + +def _make_team(abbrev="TST", gmid=99001): + return Team.create( + abbrev=abbrev, + sname=abbrev, + lname=f"Team {abbrev}", + gmid=gmid, + gmname=f"gm_{abbrev.lower()}", + gsheet="https://docs.google.com/spreadsheets/bi", + wallet=500, + team_value=1000, + collection_value=1000, + season=11, + is_ai=False, + ) + + +def _make_track(name="Batter Track", card_type="batter"): + track, _ = RefractorTrack.get_or_create( + name=name, + defaults=dict( + card_type=card_type, + formula="pa + tb * 2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ), + ) + return track + + +def _make_state(player, team, track, current_tier=0, current_value=0.0): + return RefractorCardState.create( + player=player, + team=team, + track=track, + current_tier=current_tier, + current_value=current_value, + fully_evolved=False, + last_evaluated_at=None, + ) + + +# Representative batter ratings that sum to exactly 108. +_BASE_BATTER_RATINGS = { + "homerun": 3.0, + "bp_homerun": 1.0, + "triple": 0.5, + "double_three": 2.0, + "double_two": 2.0, + "double_pull": 6.0, + "single_two": 4.0, + "single_one": 12.0, + "single_center": 5.0, + "bp_single": 2.0, + "hbp": 3.0, + "walk": 7.0, + "strikeout": 15.0, + "lineout": 3.0, + "popout": 2.0, + "flyout_a": 5.0, + "flyout_bq": 4.0, + "flyout_lf_b": 3.0, + "flyout_rf_b": 9.0, + "groundout_a": 6.0, + "groundout_b": 8.0, + "groundout_c": 5.5, +} + +# Representative pitcher ratings: 18 variable columns sum to 79, +# 9 x-check columns sum to 29, full card sums to 108. +_BASE_PITCHER_RATINGS = { + # 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 _create_base_batter(player, variant=0): + """Create a BattingCard with variant=0 and two ratings rows (vL, vR).""" + card = BattingCard.create( + player=player, + variant=variant, + steal_low=1, + steal_high=6, + steal_auto=False, + steal_jump=0.5, + bunting="C", + hit_and_run="B", + running=3, + offense_col=2, + hand="R", + ) + for vs_hand in ("L", "R"): + BattingCardRatings.create( + battingcard=card, + vs_hand=vs_hand, + pull_rate=0.4, + center_rate=0.35, + slap_rate=0.25, + avg=0.300, + obp=0.370, + slg=0.450, + **_BASE_BATTER_RATINGS, + ) + return card + + +def _create_base_pitcher(player, variant=0): + """Create a PitchingCard with variant=0 and two ratings rows (vL, vR).""" + card = PitchingCard.create( + player=player, + variant=variant, + balk=1, + wild_pitch=2, + hold=3, + starter_rating=60, + relief_rating=50, + closer_rating=None, + batting=None, + offense_col=1, + hand="R", + ) + for vs_hand in ("L", "R"): + PitchingCardRatings.create( + pitchingcard=card, + vs_hand=vs_hand, + avg=0.250, + obp=0.310, + slg=0.380, + **_BASE_PITCHER_RATINGS, + ) + return card + + +def _injectable_kwargs(model_map: dict) -> dict: + """Build apply_tier_boost() injectable kwargs from a name->model mapping.""" + return { + "_batting_card_model": model_map.get("BattingCard", BattingCard), + "_batting_ratings_model": model_map.get( + "BattingCardRatings", BattingCardRatings + ), + "_pitching_card_model": model_map.get("PitchingCard", PitchingCard), + "_pitching_ratings_model": model_map.get( + "PitchingCardRatings", PitchingCardRatings + ), + "_card_model": model_map.get("Card", Card), + "_state_model": model_map.get("RefractorCardState", RefractorCardState), + "_audit_model": model_map.get("RefractorBoostAudit", RefractorBoostAudit), + } + + +# Default injectable kwargs point at the real models (which are now bound to +# the shared-memory DB via the fixture). +_DEFAULT_KWARGS = _injectable_kwargs({}) + + +# --------------------------------------------------------------------------- +# TestBatterTierUpFlow +# --------------------------------------------------------------------------- + + +class TestBatterTierUpFlow: + """Full batter T1 boost flow: card + ratings created, state updated, base unchanged.""" + + def test_creates_variant_card(self): + """T1 boost creates a new BattingCard row with the correct variant hash. + + What: Set up a player with a base BattingCard (variant=0) and a + RefractorCardState. Call apply_tier_boost for T1. Assert that a new + BattingCard row exists with the expected variant hash. + + Why: The variant card is the persistent record of the boosted card. + If it is not created, the tier-up has no lasting effect on the card's + identity in the database. + """ + player = _make_player() + team = _make_team() + track = _make_track() + _make_state(player, team, track) + _create_base_batter(player) + + expected_variant = compute_variant_hash(player.player_id, 1) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + variant_card = BattingCard.get_or_none( + (BattingCard.player == player) & (BattingCard.variant == expected_variant) + ) + assert variant_card is not None, ( + f"Expected BattingCard with variant={expected_variant} to be created" + ) + + def test_creates_boosted_ratings(self): + """T1 boost creates BattingCardRatings rows with positive deltas applied. + + What: After T1 boost, the variant card's vR ratings must have + homerun > base_homerun (the primary positive delta column is homerun + +0.5 per tier, funded by strikeout -1.5 and groundout_a -0.5). + + Why: If the ratings rows are not created, or if the boost formula is + not applied, the variant card has the same outcomes as the base card + and offers no gameplay advantage. + """ + player = _make_player() + team = _make_team() + track = _make_track() + _make_state(player, team, track) + _create_base_batter(player) + + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + new_variant = compute_variant_hash(player.player_id, 1) + new_card = BattingCard.get( + (BattingCard.player == player) & (BattingCard.variant == new_variant) + ) + vr_ratings = BattingCardRatings.get( + (BattingCardRatings.battingcard == new_card) + & (BattingCardRatings.vs_hand == "R") + ) + + # homerun must be higher than base (base + 0.5 delta) + assert vr_ratings.homerun > _BASE_BATTER_RATINGS["homerun"], ( + f"Expected homerun > {_BASE_BATTER_RATINGS['homerun']}, " + f"got {vr_ratings.homerun}" + ) + # strikeout must be lower (reduced by 1.5) + assert vr_ratings.strikeout < _BASE_BATTER_RATINGS["strikeout"], ( + f"Expected strikeout < {_BASE_BATTER_RATINGS['strikeout']}, " + f"got {vr_ratings.strikeout}" + ) + + def test_ratings_sum_108(self): + """Boosted batter ratings rows sum to exactly 108. + + What: After T1 boost, sum all 22 BATTER_OUTCOME_COLUMNS for both the + vL and vR ratings rows on the variant card. Each must be 108.0. + + Why: The 108-sum is the card-level invariant. Peewee bypasses Pydantic + validators, so apply_tier_boost() must explicitly assert and preserve + this invariant. A sum other than 108 would corrupt game simulation. + """ + player = _make_player() + team = _make_team() + track = _make_track() + _make_state(player, team, track) + _create_base_batter(player) + + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + new_variant = compute_variant_hash(player.player_id, 1) + new_card = BattingCard.get( + (BattingCard.player == player) & (BattingCard.variant == new_variant) + ) + for ratings_row in BattingCardRatings.select().where( + BattingCardRatings.battingcard == new_card + ): + total = sum(getattr(ratings_row, col) for col in BATTER_OUTCOME_COLUMNS) + assert abs(total - 108.0) < 0.01, ( + f"Batter 108-sum violated for vs_hand={ratings_row.vs_hand}: " + f"sum={total:.6f}" + ) + + def test_audit_record_created(self): + """RefractorBoostAudit row is created with correct tier and variant. + + What: After T1 boost, a RefractorBoostAudit row must exist for the + card state with tier=1 and variant_created matching the expected hash. + + Why: The audit record is the permanent log of when and how each tier + boost was applied. Without it, there is no way to debug tier-up + regressions or replay boost history. + """ + player = _make_player() + team = _make_team() + track = _make_track() + state = _make_state(player, team, track) + _create_base_batter(player) + + expected_variant = compute_variant_hash(player.player_id, 1) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + audit = RefractorBoostAudit.get_or_none( + (RefractorBoostAudit.card_state == state) & (RefractorBoostAudit.tier == 1) + ) + assert audit is not None, "Expected RefractorBoostAudit row to be created" + assert audit.variant_created == expected_variant + + def test_card_state_variant_updated(self): + """RefractorCardState.variant and current_tier are updated after boost. + + What: After T1 boost, RefractorCardState.variant must equal the new + variant hash and current_tier must be 1. + + Why: apply_tier_boost() is the sole writer of current_tier on tier-up. + If either field is not updated, the card state is inconsistent with + the variant card that was created. + """ + player = _make_player() + team = _make_team() + track = _make_track() + state = _make_state(player, team, track) + _create_base_batter(player) + + expected_variant = compute_variant_hash(player.player_id, 1) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + state = RefractorCardState.get_by_id(state.id) + assert state.current_tier == 1 + assert state.variant == expected_variant + + def test_base_card_unchanged(self): + """variant=0 BattingCard and BattingCardRatings are not modified. + + What: After T1 boost, the base card (variant=0) and its ratings rows + must be byte-for-byte identical to what they were before the boost. + + Why: The base card is the source of truth for all variant calculations. + If it is modified, subsequent tier-ups will compute incorrect boosts and + the original card identity is lost. + """ + player = _make_player() + team = _make_team() + track = _make_track() + _make_state(player, team, track) + base_card = _create_base_batter(player) + + # Capture base card homerun before boost + base_vr = BattingCardRatings.get( + (BattingCardRatings.battingcard == base_card) + & (BattingCardRatings.vs_hand == "R") + ) + base_homerun_before = base_vr.homerun + + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + # Re-fetch the base card ratings and verify unchanged + base_vr_after = BattingCardRatings.get( + (BattingCardRatings.battingcard == base_card) + & (BattingCardRatings.vs_hand == "R") + ) + assert base_vr_after.homerun == base_homerun_before, ( + f"Base card homerun was modified: " + f"{base_homerun_before} -> {base_vr_after.homerun}" + ) + # Verify there is still only one base card + base_cards = list( + BattingCard.select().where( + (BattingCard.player == player) & (BattingCard.variant == 0) + ) + ) + assert len(base_cards) == 1, f"Expected 1 base card, found {len(base_cards)}" + + +# --------------------------------------------------------------------------- +# TestPitcherTierUpFlow +# --------------------------------------------------------------------------- + + +class TestPitcherTierUpFlow: + """Full pitcher T1 boost flow: x-check columns unchanged, 108-sum preserved.""" + + def test_creates_variant_card(self): + """T1 boost creates a PitchingCard with the correct variant hash. + + What: Set up a player with a base PitchingCard (variant=0) and a + RefractorCardState with card_type='sp'. Call apply_tier_boost for T1. + Assert that a new PitchingCard exists with the expected variant hash. + + Why: Pitcher cards follow a different boost algorithm (TB budget + priority) but must still produce a variant card row. + """ + player = _make_player(pos="SP") + team = _make_team(abbrev="PT1", gmid=99010) + track = _make_track(name="SP Track", card_type="sp") + _make_state(player, team, track) + _create_base_pitcher(player) + + expected_variant = compute_variant_hash(player.player_id, 1) + apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS) + + variant_card = PitchingCard.get_or_none( + (PitchingCard.player == player) & (PitchingCard.variant == expected_variant) + ) + assert variant_card is not None + + def test_xcheck_columns_unchanged(self): + """X-check columns on boosted pitcher ratings are identical to the base card. + + What: After T1 boost, every xcheck_* column on the variant card's + ratings rows must exactly match the corresponding value on the base card. + + Why: X-check columns encode defensive routing weights. The pitcher + boost algorithm is explicitly designed to never touch them. If any + x-check column is modified, game simulation defensive logic breaks and + the 108-sum invariant (variable 79 + x-check 29) is violated. + """ + player = _make_player(pos="SP") + team = _make_team(abbrev="PT2", gmid=99011) + track = _make_track(name="SP Track2", card_type="sp") + _make_state(player, team, track) + base_card = _create_base_pitcher(player) + + apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS) + + new_variant = compute_variant_hash(player.player_id, 1) + new_card = PitchingCard.get( + (PitchingCard.player == player) & (PitchingCard.variant == new_variant) + ) + + for vs_hand in ("L", "R"): + base_row = PitchingCardRatings.get( + (PitchingCardRatings.pitchingcard == base_card) + & (PitchingCardRatings.vs_hand == vs_hand) + ) + new_row = PitchingCardRatings.get( + (PitchingCardRatings.pitchingcard == new_card) + & (PitchingCardRatings.vs_hand == vs_hand) + ) + for col in PITCHER_XCHECK_COLUMNS: + assert getattr(new_row, col) == pytest.approx( + getattr(base_row, col), abs=1e-9 + ), ( + f"X-check column '{col}' was modified for vs_hand={vs_hand}: " + f"{getattr(base_row, col)} -> {getattr(new_row, col)}" + ) + + def test_108_sum_pitcher(self): + """Boosted pitcher ratings sum to exactly 108 (variable 79 + x-check 29). + + What: After T1 boost, sum all 18 PITCHER_OUTCOME_COLUMNS and all 9 + PITCHER_XCHECK_COLUMNS for each vs_hand split on the variant card. + The combined total must be 108.0 for each split. + + Why: The card-level invariant is the full 108 total — not just the 79 + variable-column subset. If the x-check columns are not preserved or + the variable columns drift, the invariant is broken. + """ + player = _make_player(pos="SP") + team = _make_team(abbrev="PT3", gmid=99012) + track = _make_track(name="SP Track3", card_type="sp") + _make_state(player, team, track) + _create_base_pitcher(player) + + apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS) + + new_variant = compute_variant_hash(player.player_id, 1) + new_card = PitchingCard.get( + (PitchingCard.player == player) & (PitchingCard.variant == new_variant) + ) + for row in PitchingCardRatings.select().where( + PitchingCardRatings.pitchingcard == new_card + ): + var_sum = sum(getattr(row, col) for col in PITCHER_OUTCOME_COLUMNS) + xcheck_sum = sum(getattr(row, col) for col in PITCHER_XCHECK_COLUMNS) + total = var_sum + xcheck_sum + assert abs(total - 108.0) < 0.01, ( + f"Pitcher 108-sum (variable + x-check) violated for " + f"vs_hand={row.vs_hand}: var={var_sum:.4f} xcheck={xcheck_sum:.4f} " + f"total={total:.6f}" + ) + + def test_correct_priority_columns_reduced(self): + """T1 boost reduces the highest-priority non-zero column (double_cf). + + What: The base pitcher has double_cf=2.95. After T1 boost the TB + budget algorithm starts with double_cf (cost=2 TB per chance) and + should reduce it by 0.75 chances (1.5 TB / 2 TB-per-chance). + + Why: Validates that the pitcher priority algorithm is applied correctly + in the orchestration layer and that the outcome columns propagate to + the ratings row. + """ + player = _make_player(pos="SP") + team = _make_team(abbrev="PT4", gmid=99013) + track = _make_track(name="SP Track4", card_type="sp") + _make_state(player, team, track) + _create_base_pitcher(player) + + apply_tier_boost(player.player_id, team.id, 1, "sp", **_DEFAULT_KWARGS) + + new_variant = compute_variant_hash(player.player_id, 1) + new_card = PitchingCard.get( + (PitchingCard.player == player) & (PitchingCard.variant == new_variant) + ) + vr_row = PitchingCardRatings.get( + (PitchingCardRatings.pitchingcard == new_card) + & (PitchingCardRatings.vs_hand == "R") + ) + + # Budget=1.5, double_cf cost=2 -> takes 0.75 chances + expected_double_cf = _BASE_PITCHER_RATINGS["double_cf"] - 0.75 + expected_strikeout = _BASE_PITCHER_RATINGS["strikeout"] + 0.75 + assert vr_row.double_cf == pytest.approx(expected_double_cf, abs=1e-4) + assert vr_row.strikeout == pytest.approx(expected_strikeout, abs=1e-4) + + +# --------------------------------------------------------------------------- +# TestCumulativeT1ToT4 +# --------------------------------------------------------------------------- + + +class TestCumulativeT1ToT4: + """Apply all 4 tiers sequentially to a batter; verify cumulative state.""" + + def test_four_variant_cards_exist(self): + """After T1 through T4, four distinct BattingCard rows exist (plus variant=0). + + What: Call apply_tier_boost for tiers 1, 2, 3, 4 sequentially. + Assert that there are exactly 5 BattingCard rows for the player + (1 base + 4 variants). + + Why: Each tier must create exactly one new variant card. If tiers + share a card or skip creating one, the tier progression is broken. + """ + player = _make_player() + team = _make_team(abbrev="CT1", gmid=99020) + track = _make_track(name="Cumulative Track") + _make_state(player, team, track) + _create_base_batter(player) + + for tier in range(1, 5): + apply_tier_boost( + player.player_id, team.id, tier, "batter", **_DEFAULT_KWARGS + ) + + all_cards = list(BattingCard.select().where(BattingCard.player == player)) + assert len(all_cards) == 5, ( + f"Expected 5 BattingCard rows (1 base + 4 variants), got {len(all_cards)}" + ) + variants = {c.variant for c in all_cards} + assert 0 in variants, "Base card (variant=0) should still exist" + + def test_each_tier_sums_to_108(self): + """After each of the four sequential boosts, ratings sum to 108. + + What: Apply T1 through T4 sequentially and after each tier verify + that all ratings rows for the new variant card sum to 108. + + Why: Each boost sources from the previous tier's variant. Any + drift introduced by one tier compounds into subsequent tiers. + Checking after each tier catches drift early rather than only at T4. + """ + player = _make_player() + team = _make_team(abbrev="CT2", gmid=99021) + track = _make_track(name="Cumulative Track2") + _make_state(player, team, track) + _create_base_batter(player) + + for tier in range(1, 5): + apply_tier_boost( + player.player_id, team.id, tier, "batter", **_DEFAULT_KWARGS + ) + new_variant = compute_variant_hash(player.player_id, tier) + new_card = BattingCard.get( + (BattingCard.player == player) & (BattingCard.variant == new_variant) + ) + for row in BattingCardRatings.select().where( + BattingCardRatings.battingcard == new_card + ): + total = sum(getattr(row, col) for col in BATTER_OUTCOME_COLUMNS) + assert abs(total - 108.0) < 0.01, ( + f"108-sum violated at tier {tier} vs_hand={row.vs_hand}: " + f"sum={total:.6f}" + ) + + def test_t4_has_cumulative_deltas(self): + """T4 ratings equal base + 4 cumulative boost deltas. + + What: After applying T1 through T4, verify that the T4 variant's + homerun column is approximately base_homerun + 4 * 0.5 = base + 2.0. + + Why: Each tier applies +0.5 to homerun. Four sequential tiers must + produce a cumulative +2.0 delta. If the algorithm sources from the + wrong base (e.g. always from variant=0 instead of the previous tier's + variant), the cumulative delta would be wrong. + """ + player = _make_player() + team = _make_team(abbrev="CT3", gmid=99022) + track = _make_track(name="Cumulative Track3") + _make_state(player, team, track) + _create_base_batter(player) + + for tier in range(1, 5): + apply_tier_boost( + player.player_id, team.id, tier, "batter", **_DEFAULT_KWARGS + ) + + t4_variant = compute_variant_hash(player.player_id, 4) + t4_card = BattingCard.get( + (BattingCard.player == player) & (BattingCard.variant == t4_variant) + ) + vr_row = BattingCardRatings.get( + (BattingCardRatings.battingcard == t4_card) + & (BattingCardRatings.vs_hand == "R") + ) + + expected_homerun = _BASE_BATTER_RATINGS["homerun"] + 4 * 0.5 + assert vr_row.homerun == pytest.approx(expected_homerun, abs=0.01), ( + f"T4 homerun expected {expected_homerun}, got {vr_row.homerun}" + ) + + +# --------------------------------------------------------------------------- +# TestIdempotency +# --------------------------------------------------------------------------- + + +class TestIdempotency: + """Calling apply_tier_boost twice for the same tier produces no duplicates.""" + + def test_no_duplicate_card(self): + """Second call for the same tier reuses the existing variant card. + + What: Call apply_tier_boost for T1 twice. Assert that there is still + exactly one BattingCard with the T1 variant hash (not two). + + Why: Idempotency is required because evaluate-game may be called + multiple times for the same game if the bot retries. Duplicate cards + would corrupt the inventory and break the unique-variant DB constraint. + """ + player = _make_player() + team = _make_team(abbrev="ID1", gmid=99030) + track = _make_track(name="Idempotency Track") + _make_state(player, team, track) + _create_base_batter(player) + + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + t1_variant = compute_variant_hash(player.player_id, 1) + cards = list( + BattingCard.select().where( + (BattingCard.player == player) & (BattingCard.variant == t1_variant) + ) + ) + assert len(cards) == 1, ( + f"Expected exactly 1 T1 variant card, found {len(cards)}" + ) + + def test_no_duplicate_ratings(self): + """Second call for the same tier creates only one ratings row per split. + + What: Call apply_tier_boost for T1 twice. Assert that each vs_hand + split has exactly one BattingCardRatings row on the variant card. + + Why: Duplicate ratings rows for the same (card, vs_hand) combination + would cause the game engine to pick an arbitrary row, producing + non-deterministic outcomes. + """ + player = _make_player() + team = _make_team(abbrev="ID2", gmid=99031) + track = _make_track(name="Idempotency Track2") + _make_state(player, team, track) + _create_base_batter(player) + + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + t1_variant = compute_variant_hash(player.player_id, 1) + t1_card = BattingCard.get( + (BattingCard.player == player) & (BattingCard.variant == t1_variant) + ) + ratings_count = ( + BattingCardRatings.select() + .where(BattingCardRatings.battingcard == t1_card) + .count() + ) + assert ratings_count == 2, ( + f"Expected 2 ratings rows (vL + vR), found {ratings_count}" + ) + + +# --------------------------------------------------------------------------- +# TestCardVariantPropagation +# --------------------------------------------------------------------------- + + +class TestCardVariantPropagation: + """Card.variant is propagated to all matching (player, team) Card rows.""" + + def test_multiple_cards_updated(self): + """Three Card rows for same player/team all get variant updated after boost. + + What: Create 3 Card rows for the same (player, team) pair with + variant=0. Call apply_tier_boost for T1. Assert that all 3 Card + rows now have variant equal to the T1 hash. + + Why: A player may have multiple Card instances (e.g. from different + cardsets or pack types). All must reflect the current tier's variant + so that any display pathway shows the boosted art. + """ + player = _make_player() + team = _make_team(abbrev="VP1", gmid=99040) + track = _make_track(name="Propagation Track") + _make_state(player, team, track) + _create_base_batter(player) + + cs, _ = Cardset.get_or_create( + name="Prop Test Set", defaults={"description": "prop", "total_cards": 10} + ) + rarity = _make_rarity() + card_ids = [] + for _ in range(3): + c = Card.create( + player=player, + team=team, + variant=0, + cardset=cs, + rarity=rarity, + price=10, + ) + card_ids.append(c.id) + + expected_variant = compute_variant_hash(player.player_id, 1) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + for cid in card_ids: + updated = Card.get_by_id(cid) + assert updated.variant == expected_variant, ( + f"Card id={cid} variant not updated: " + f"expected {expected_variant}, got {updated.variant}" + ) + + def test_other_teams_unaffected(self): + """Card rows for the same player on a different team are NOT updated. + + What: Create a Card for (player, team_a) and a Card for (player, team_b). + Call apply_tier_boost for T1 on team_a only. Assert that the team_b + card's variant is still 0. + + Why: Variant propagation is scoped to (player, team) — applying a + boost for one team must not bleed into another team's card instances. + """ + player = _make_player() + team_a = _make_team(abbrev="VA1", gmid=99041) + team_b = _make_team(abbrev="VA2", gmid=99042) + track = _make_track(name="Propagation Track2") + _make_state(player, team_a, track) + _create_base_batter(player) + + cs, _ = Cardset.get_or_create( + name="Prop Test Set2", defaults={"description": "prop2", "total_cards": 10} + ) + rarity = _make_rarity() + card_a = Card.create( + player=player, team=team_a, variant=0, cardset=cs, rarity=rarity, price=10 + ) + card_b = Card.create( + player=player, team=team_b, variant=0, cardset=cs, rarity=rarity, price=10 + ) + + apply_tier_boost(player.player_id, team_a.id, 1, "batter", **_DEFAULT_KWARGS) + + # team_a card should be updated + updated_a = Card.get_by_id(card_a.id) + assert updated_a.variant != 0 + + # team_b card should still be 0 + updated_b = Card.get_by_id(card_b.id) + assert updated_b.variant == 0, ( + f"team_b card was unexpectedly updated to variant={updated_b.variant}" + ) + + +# --------------------------------------------------------------------------- +# TestCardTypeValidation +# --------------------------------------------------------------------------- + + +class TestCardTypeValidation: + """apply_tier_boost rejects invalid card_type values.""" + + def test_invalid_card_type_raises_value_error(self): + """card_type='dh' is not valid — must be 'batter', 'sp', or 'rp'. + + What: Call apply_tier_boost() with card_type='dh'. Assert that a + ValueError is raised before any DB interaction occurs. + + Why: The card_type guard runs at the top of apply_tier_boost(), before + any model lookup. Passing an unsupported type would silently use the + wrong boost formula; an early ValueError prevents corrupted data. + No DB setup is needed because the raise happens before any model is + touched. + """ + with pytest.raises(ValueError, match=r"Invalid card_type"): + apply_tier_boost(1, 1, 1, "dh", **_DEFAULT_KWARGS) + + def test_empty_card_type_raises_value_error(self): + """Empty string card_type is rejected before any DB interaction. + + What: Call apply_tier_boost() with card_type=''. Assert that a + ValueError is raised before any DB interaction occurs. + + Why: An empty string is not one of the three valid types and must be + caught by the same guard that rejects 'dh'. Ensures the validation + uses an allowlist check rather than a partial-string check that might + accidentally pass an empty value. + """ + with pytest.raises(ValueError, match=r"Invalid card_type"): + apply_tier_boost(1, 1, 1, "", **_DEFAULT_KWARGS) + + +# --------------------------------------------------------------------------- +# TestCrossPlayerIsolation +# --------------------------------------------------------------------------- + + +class TestCrossPlayerIsolation: + """Boosting one player must not affect another player's cards on the same team.""" + + def test_boost_does_not_update_other_players_cards_on_same_team(self): + """Two players on the same team. Boost player_a. Player_b's Card.variant stays 0. + + What: Create two players on the same team, each with a base BattingCard, + a RefractorCardState, and a Card inventory row (variant=0). Call + apply_tier_boost for player_a only. Assert that player_a's Card row + is updated to the new variant hash while player_b's Card row remains + at variant=0. + + Why: The variant propagation query inside apply_tier_boost() filters on + both player_id AND team_id. This test guards against a regression where + only the team_id filter is applied, which would update every player's + Card row on that team. + """ + player_a = _make_player(name="Player A Cross", pos="1B") + player_b = _make_player(name="Player B Cross", pos="2B") + team = _make_team(abbrev="CP1", gmid=99080) + track = _make_track(name="CrossPlayer Track") + + _make_state(player_a, team, track) + _make_state(player_b, team, track) + _create_base_batter(player_a) + _create_base_batter(player_b) + + Card.create(player=player_a, team=team, variant=0) + Card.create(player=player_b, team=team, variant=0) + + apply_tier_boost(player_a.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + card_a = Card.get((Card.player == player_a) & (Card.team == team)) + assert card_a.variant != 0, ( + f"Player A's card variant should have been updated from 0, " + f"got {card_a.variant}" + ) + + card_b = Card.get((Card.player == player_b) & (Card.team == team)) + assert card_b.variant == 0, ( + f"Player B's card variant should still be 0, got {card_b.variant}" + ) + + +# --------------------------------------------------------------------------- +# TestAtomicity +# --------------------------------------------------------------------------- + + +class TestAtomicity: + """Tier increment and variant writes are guarded so a mid-boost failure + does not leave RefractorCardState partially updated. + + One test verifies that a pre-atomic failure (missing source card) does + not touch the state row at all. A second test verifies that a failure + INSIDE the db.atomic() block rolls back the state mutations even though + the card and ratings rows (created before the block) persist. + """ + + def test_missing_source_card_does_not_modify_current_tier(self): + """Failed boost due to missing source card does not modify current_tier. + + What: Set up a RefractorCardState but do NOT create a BattingCard. + Call apply_tier_boost for T1 — it should raise ValueError because + the source card is missing. After the failure, current_tier must + still be 0. + + Why: The ValueError is raised at the source-card fetch step, which + happens BEFORE the db.atomic() block. No write is ever attempted, + so the state row should be completely untouched. This guards against + regressions where early validation logic is accidentally removed and + writes are attempted with bad data. + """ + player = _make_player() + team = _make_team(abbrev="AT1", gmid=99050) + track = _make_track(name="Atomicity Track") + state = _make_state(player, team, track, current_tier=0) + # Intentionally no base card created + + with pytest.raises(ValueError, match=r"No battingcard.*player="): + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + state = RefractorCardState.get_by_id(state.id) + assert state.current_tier == 0, ( + f"current_tier should still be 0 after failed boost, got {state.current_tier}" + ) + + def test_audit_failure_inside_atomic_rolls_back_state_mutations(self): + """Failure inside db.atomic() rolls back card_state tier and variant writes. + + What: Create a valid base BattingCard with ratings so the function + progresses past source-card validation and card/ratings creation. + Override _audit_model with a stub whose .create() method raises + RuntimeError — this triggers inside the db.atomic() block (step 8a), + after the new card and ratings rows have already been written (steps + 6-7, which are outside the atomic block). + + After the exception propagates out, verify that RefractorCardState + still has current_tier==0 and variant==None (the pre-boost values). + The atomic rollback must prevent the card_state.save() and + Card.update() writes from being committed even though card/ratings + rows persist (they were written before the atomic block began). + + Why: db.atomic() guarantees that either ALL writes inside the block + are committed together or NONE are. If this guarantee breaks, a + partially-committed state would show a tier advance without a + corresponding audit record, leaving the card in an inconsistent state + that is invisible to the evaluator's retry logic. + """ + + class _FailingAuditModel: + """Stub that raises on .create() to simulate audit write failure.""" + + @staticmethod + def create(**kwargs): + raise RuntimeError("Simulated audit write failure inside atomic block") + + player = _make_player(name="Atomic Rollback Player", pos="CF") + team = _make_team(abbrev="RB1", gmid=99052) + track = _make_track(name="Atomicity Track3") + state = _make_state(player, team, track, current_tier=0) + _create_base_batter(player) + + failing_kwargs = dict(_DEFAULT_KWARGS) + failing_kwargs["_audit_model"] = _FailingAuditModel + + with pytest.raises(RuntimeError, match="Simulated audit write failure"): + apply_tier_boost(player.player_id, team.id, 1, "batter", **failing_kwargs) + + # The atomic block should have rolled back — state row must be unchanged. + state = RefractorCardState.get_by_id(state.id) + assert state.current_tier == 0, ( + f"current_tier should still be 0 after atomic rollback, got {state.current_tier}" + ) + assert state.variant is None or state.variant == 0, ( + f"variant should not have been updated after atomic rollback, got {state.variant}" + ) + + def test_successful_boost_writes_tier_and_variant_together(self): + """Successful boost atomically writes current_tier and variant. + + What: After a successful T1 boost, both current_tier == 1 AND + variant == compute_variant_hash(player_id, 1) must be true on the + same RefractorCardState row. + + Why: If the writes were not atomic, a read between the tier write + and the variant write could see an inconsistent state. The atomic + block ensures both are committed together. + """ + player = _make_player() + team = _make_team(abbrev="AT2", gmid=99051) + track = _make_track(name="Atomicity Track2") + state = _make_state(player, team, track) + _create_base_batter(player) + + expected_variant = compute_variant_hash(player.player_id, 1) + apply_tier_boost(player.player_id, team.id, 1, "batter", **_DEFAULT_KWARGS) + + state = RefractorCardState.get_by_id(state.id) + assert state.current_tier == 1 + assert state.variant == expected_variant + + +# --------------------------------------------------------------------------- +# TestEvaluateCardDryRun +# --------------------------------------------------------------------------- + + +def _make_stats_for_t1(player, team): + """Create a BattingSeasonStats row to give the player enough value for T1. + + The default track formula is 'pa + tb * 2'. T1 threshold is 37. + We seed 40 PA with 0 extra bases so value = 40 > 37. + """ + return BattingSeasonStats.create( + player=player, + team=team, + season=11, + pa=40, + ) + + +class TestEvaluateCardDryRun: + """evaluate_card(dry_run=True) computes the new tier without writing it.""" + + def test_dry_run_does_not_write_current_tier(self): + """dry_run=True evaluation leaves current_tier at 0 even when formula says tier=1. + + What: Seed enough stats for T1 (pa=40 > threshold 37). Call + evaluate_card with dry_run=True. Assert current_tier is still 0. + + Why: The dry_run=True path must not write current_tier so that + apply_tier_boost() can write it atomically with the variant card. + If current_tier were written here, a subsequent boost failure would + leave the tier advanced but no variant created. + """ + player = _make_player() + team = _make_team(abbrev="DR1", gmid=99060) + track = _make_track(name="DryRun Track") + state = _make_state(player, team, track, current_tier=0) + _make_stats_for_t1(player, team) + + evaluate_card( + player.player_id, + team.id, + dry_run=True, + _state_model=RefractorCardState, + ) + + state = RefractorCardState.get_by_id(state.id) + assert state.current_tier == 0, ( + f"dry_run=True must not write current_tier; got {state.current_tier}" + ) + + def test_dry_run_writes_current_value(self): + """dry_run=True DOES update current_value even though current_tier is skipped. + + What: After evaluate_card(dry_run=True) with pa=40 stats, current_value + must be > 0 (formula: pa + tb*2 = 40). + + Why: current_value must be updated so that progress display (progress_pct) + reflects the latest stats even when boost is pending. + """ + player = _make_player() + team = _make_team(abbrev="DR2", gmid=99061) + track = _make_track(name="DryRun Track2") + state = _make_state(player, team, track, current_tier=0) + _make_stats_for_t1(player, team) + + evaluate_card( + player.player_id, + team.id, + dry_run=True, + _state_model=RefractorCardState, + ) + + state = RefractorCardState.get_by_id(state.id) + assert state.current_value > 0, ( + f"dry_run=True should still update current_value; got {state.current_value}" + ) + + def test_dry_run_returns_computed_tier(self): + """Return dict includes 'computed_tier' reflecting the formula result. + + What: With pa=40 (value=40 > T1 threshold=37), evaluate_card with + dry_run=True must return {'computed_tier': 1, 'current_tier': 0, ...}. + + Why: The evaluate-game endpoint uses computed_tier to detect tier-ups. + If it were absent or equal to current_tier, the endpoint would not + call apply_tier_boost() and no boost would be applied. + """ + player = _make_player() + team = _make_team(abbrev="DR3", gmid=99062) + track = _make_track(name="DryRun Track3") + _make_state(player, team, track, current_tier=0) + _make_stats_for_t1(player, team) + + result = evaluate_card( + player.player_id, + team.id, + dry_run=True, + _state_model=RefractorCardState, + ) + + assert "computed_tier" in result, "Return dict must include 'computed_tier'" + assert result["computed_tier"] >= 1, ( + f"computed_tier should be >= 1 with pa=40, got {result['computed_tier']}" + ) + assert result["current_tier"] == 0, ( + f"current_tier must remain 0 in dry_run mode, got {result['current_tier']}" + ) + + def test_non_dry_run_preserves_existing_behaviour(self): + """dry_run=False (default) writes current_tier as before. + + What: With pa=40 (value=40 > T1 threshold=37), evaluate_card with + dry_run=False must write current_tier=1 to the database. + + Why: The manual /evaluate endpoint uses dry_run=False. Existing + behaviour must be preserved so that cards can still be manually + re-evaluated without a boost cycle. + """ + player = _make_player() + team = _make_team(abbrev="DR4", gmid=99063) + track = _make_track(name="DryRun Track4") + state = _make_state(player, team, track, current_tier=0) + _make_stats_for_t1(player, team) + + evaluate_card( + player.player_id, + team.id, + dry_run=False, + _state_model=RefractorCardState, + ) + + state = RefractorCardState.get_by_id(state.id) + assert state.current_tier >= 1, ( + f"dry_run=False must write current_tier; got {state.current_tier}" + ) diff --git a/tests/test_refractor_evaluator.py b/tests/test_refractor_evaluator.py index 18f3604..ee271eb 100644 --- a/tests/test_refractor_evaluator.py +++ b/tests/test_refractor_evaluator.py @@ -187,10 +187,11 @@ def _make_stats(player_id, team_id, season, **kwargs): ) -def _eval(player_id, team_id): +def _eval(player_id, team_id, dry_run: bool = False): return evaluate_card( player_id, team_id, + dry_run=dry_run, _stats_model=StatsStub, _state_model=CardStateStub, _compute_value_fn=_compute_value, @@ -392,13 +393,20 @@ class TestReturnShape: """Return dict has the expected keys and types.""" def test_return_keys(self, batter_track): - """Result dict contains all expected keys.""" + """Result dict contains all expected keys. + + Phase 2 addition: 'computed_tier' is included alongside 'current_tier' + so that evaluate-game can detect tier-ups without writing the tier + (dry_run=True path). Both keys must always be present. + """ _make_state(1, 1, batter_track) result = _eval(1, 1) assert set(result.keys()) == { "player_id", "team_id", "current_tier", + "computed_tier", + "computed_fully_evolved", "current_value", "fully_evolved", "last_evaluated_at", @@ -621,3 +629,176 @@ class TestMultiTeamStatIsolation: assert result_team2["current_tier"] == 2, ( f"Team 2 tier should be T2 for value=180, got {result_team2['current_tier']}" ) + + +class TestDryRun: + """dry_run=True writes current_value and last_evaluated_at but NOT current_tier + or fully_evolved, allowing apply_tier_boost() to write tier + variant atomically. + + All tests use stats that would produce a tier-up (value=160 → T2) on a card + seeded at tier=0, so the delta between dry and non-dry behaviour is obvious. + + Stub thresholds (batter): T1=37, T2=149, T3=448, T4=896. + value=160 → T2 (149 <= 160 < 448); starting current_tier=0 → tier-up to T2. + """ + + def test_dry_run_does_not_write_current_tier(self, batter_track): + """dry_run=True leaves current_tier unchanged in the database. + + What: Seed a card at tier=0. Provide stats that would advance to T2 + (value=160). Call evaluate_card with dry_run=True. Re-read the DB row + and assert current_tier is still 0. + + Why: The dry_run path must not persist the tier so that apply_tier_boost() + can write tier + variant atomically on the next step. If current_tier + were written here, a boost failure would leave the tier advanced with no + corresponding variant, causing an inconsistent state. + """ + _make_state(1, 1, batter_track, current_tier=0) + _make_stats(1, 1, 1, pa=160) + + _eval(1, 1, dry_run=True) + + reloaded = CardStateStub.get( + (CardStateStub.player_id == 1) & (CardStateStub.team_id == 1) + ) + assert reloaded.current_tier == 0, ( + f"dry_run should not write current_tier; expected 0, got {reloaded.current_tier}" + ) + + def test_dry_run_does_not_write_fully_evolved(self, batter_track): + """dry_run=True leaves fully_evolved=False unchanged in the database. + + What: Seed a card at tier=0 with fully_evolved=False. Provide stats that + would push to T4 (value=900). Call evaluate_card with dry_run=True. + Re-read the DB row and assert fully_evolved is still False. + + Why: fully_evolved follows current_tier and must be written atomically + by apply_tier_boost(). Writing it here would let the flag get out of + sync with the tier if the boost subsequently fails. + """ + _make_state(1, 1, batter_track, current_tier=0) + _make_stats(1, 1, 1, pa=900) # value=900 → T4 → fully_evolved=True normally + + _eval(1, 1, dry_run=True) + + reloaded = CardStateStub.get( + (CardStateStub.player_id == 1) & (CardStateStub.team_id == 1) + ) + assert reloaded.fully_evolved is False, ( + "dry_run should not write fully_evolved; expected False, " + f"got {reloaded.fully_evolved}" + ) + + def test_dry_run_writes_current_value(self, batter_track): + """dry_run=True DOES update current_value in the database. + + What: Seed a card with current_value=0. Provide stats giving value=160. + Call evaluate_card with dry_run=True. Re-read the DB row and assert + current_value has been updated to 160.0. + + Why: current_value tracks formula progress and is safe to write + at any time — it does not affect game logic atomicity, so it is + always persisted regardless of dry_run. + """ + _make_state(1, 1, batter_track, current_value=0.0) + _make_stats(1, 1, 1, pa=160) + + _eval(1, 1, dry_run=True) + + reloaded = CardStateStub.get( + (CardStateStub.player_id == 1) & (CardStateStub.team_id == 1) + ) + assert reloaded.current_value == 160.0, ( + f"dry_run should still write current_value; expected 160.0, " + f"got {reloaded.current_value}" + ) + + def test_dry_run_writes_last_evaluated_at(self, batter_track): + """dry_run=True DOES update last_evaluated_at in the database. + + What: Seed a card with last_evaluated_at=None. Call evaluate_card with + dry_run=True. Re-read the DB row and assert last_evaluated_at is now a + non-None datetime. + + Why: last_evaluated_at is a bookkeeping field used for scheduling and + audit purposes. It is safe to update independently of tier writes + and should always reflect the most recent evaluation attempt. + """ + _make_state(1, 1, batter_track) + _make_stats(1, 1, 1, pa=160) + + _eval(1, 1, dry_run=True) + + reloaded = CardStateStub.get( + (CardStateStub.player_id == 1) & (CardStateStub.team_id == 1) + ) + assert reloaded.last_evaluated_at is not None, ( + "dry_run should still write last_evaluated_at; got None" + ) + + def test_dry_run_returns_computed_tier(self, batter_track): + """dry_run=True return dict has computed_tier=T2 while current_tier stays 0. + + What: Seed at tier=0. Stats → value=160 → T2. Call dry_run=True. + Assert: + - result["computed_tier"] == 2 (what the formula says) + - result["current_tier"] == 0 (what is stored; unchanged) + + Why: Callers use the divergence between computed_tier and current_tier + to detect a pending tier-up. Both keys must be present and correct for + the evaluate-game endpoint to gate apply_tier_boost() correctly. + """ + _make_state(1, 1, batter_track, current_tier=0) + _make_stats(1, 1, 1, pa=160) + + result = _eval(1, 1, dry_run=True) + + assert result["computed_tier"] == 2, ( + f"computed_tier should reflect formula result T2; got {result['computed_tier']}" + ) + assert result["current_tier"] == 0, ( + f"current_tier should reflect unchanged DB value 0; got {result['current_tier']}" + ) + + def test_dry_run_returns_computed_fully_evolved(self, batter_track): + """dry_run=True sets computed_fully_evolved correctly in the return dict. + + What: Two sub-cases: + - Stats → value=160 → T2: computed_fully_evolved should be False. + - Stats → value=900 → T4: computed_fully_evolved should be True. + In both cases fully_evolved in the DB remains False (tier not written). + + Why: computed_fully_evolved lets callers know whether the pending tier-up + will result in a fully-evolved card without having to re-query the DB + or recalculate the tier themselves. It must match (computed_tier >= 4), + not the stored fully_evolved value. + """ + # Sub-case 1: computed T2 → computed_fully_evolved=False + _make_state(1, 1, batter_track, current_tier=0) + _make_stats(1, 1, 1, pa=160) + + result = _eval(1, 1, dry_run=True) + + assert result["computed_fully_evolved"] is False, ( + f"computed_fully_evolved should be False for T2; got {result['computed_fully_evolved']}" + ) + assert result["fully_evolved"] is False, ( + "stored fully_evolved should remain False after dry_run" + ) + + # Reset for sub-case 2: computed T4 → computed_fully_evolved=True + CardStateStub.delete().execute() + StatsStub.delete().execute() + + _make_state(1, 1, batter_track, current_tier=0) + _make_stats(1, 1, 1, pa=900) # value=900 → T4 + + result2 = _eval(1, 1, dry_run=True) + + assert result2["computed_fully_evolved"] is True, ( + f"computed_fully_evolved should be True for T4; got {result2['computed_fully_evolved']}" + ) + assert result2["fully_evolved"] is False, ( + "stored fully_evolved should remain False after dry_run even at T4" + )