diff --git a/app/db_engine.py b/app/db_engine.py index 4183bb9..0b44ed1 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1152,9 +1152,25 @@ pitss_player_season_index = ModelIndex( PitchingSeasonStats.add_index(pitss_player_season_index) +class ProcessedGame(BaseModel): + game = ForeignKeyField(StratGame, primary_key=True) + processed_at = DateTimeField(default=datetime.now) + + class Meta: + database = db + table_name = "processed_game" + + if not SKIP_TABLE_CREATION: db.create_tables( - [StratGame, StratPlay, Decision, BattingSeasonStats, PitchingSeasonStats], + [ + StratGame, + StratPlay, + Decision, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + ], safe=True, ) @@ -1194,6 +1210,86 @@ if not SKIP_TABLE_CREATION: db.create_tables([ScoutOpportunity, ScoutClaim], safe=True) +class EvolutionTrack(BaseModel): + name = CharField(unique=True) + card_type = CharField() # 'batter', 'sp', 'rp' + formula = CharField() # e.g. "pa + tb * 2" + t1_threshold = IntegerField() + t2_threshold = IntegerField() + t3_threshold = IntegerField() + t4_threshold = IntegerField() + + class Meta: + database = db + table_name = "evolution_track" + + +class EvolutionCardState(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + track = ForeignKeyField(EvolutionTrack) + current_tier = IntegerField(default=0) # 0-4 + current_value = FloatField(default=0.0) + fully_evolved = BooleanField(default=False) + last_evaluated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "evolution_card_state" + + +evolution_card_state_index = ModelIndex( + EvolutionCardState, + (EvolutionCardState.player, EvolutionCardState.team), + unique=True, +) +EvolutionCardState.add_index(evolution_card_state_index) + + +class EvolutionTierBoost(BaseModel): + track = ForeignKeyField(EvolutionTrack) + tier = IntegerField() # 1-4 + boost_type = CharField() # e.g. 'rating', 'stat' + boost_target = CharField() # e.g. 'contact_vl', 'power_vr' + boost_value = FloatField(default=0.0) + + class Meta: + database = db + table_name = "evolution_tier_boost" + + +evolution_tier_boost_index = ModelIndex( + EvolutionTierBoost, + ( + EvolutionTierBoost.track, + EvolutionTierBoost.tier, + EvolutionTierBoost.boost_type, + EvolutionTierBoost.boost_target, + ), + unique=True, +) +EvolutionTierBoost.add_index(evolution_tier_boost_index) + + +class EvolutionCosmetic(BaseModel): + name = CharField(unique=True) + tier_required = IntegerField(default=0) + cosmetic_type = CharField() # 'frame', 'badge', 'theme' + css_class = CharField(null=True) + asset_url = CharField(null=True) + + class Meta: + database = db + table_name = "evolution_cosmetic" + + +if not SKIP_TABLE_CREATION: + db.create_tables( + [EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic], + safe=True, + ) + + db.close() # scout_db = SqliteDatabase( diff --git a/app/main.py b/app/main.py index aa7c52a..2949642 100644 --- a/app/main.py +++ b/app/main.py @@ -17,9 +17,9 @@ logging.basicConfig( # from fastapi.staticfiles import StaticFiles # from fastapi.templating import Jinja2Templates -from .db_engine import db -from .routers_v2.players import get_browser, shutdown_browser -from .routers_v2 import ( +from .db_engine import db # noqa: E402 +from .routers_v2.players import get_browser, shutdown_browser # noqa: E402 +from .routers_v2 import ( # noqa: E402 current, awards, teams, @@ -52,6 +52,7 @@ from .routers_v2 import ( scout_opportunities, scout_claims, evolution, + season_stats, ) @@ -107,6 +108,7 @@ app.include_router(decisions.router) app.include_router(scout_opportunities.router) app.include_router(scout_claims.router) app.include_router(evolution.router) +app.include_router(season_stats.router) @app.middleware("http") diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index 7d3e0d0..a8614fc 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -6,6 +6,7 @@ from pandas import DataFrame from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist from ..dependencies import oauth2_scheme, valid_token +from ..services.evolution_init import _determine_card_type, initialize_card_evolution router = APIRouter(prefix="/api/v2/cards", tags=["cards"]) @@ -80,7 +81,7 @@ async def get_cards( raise HTTPException( status_code=400, detail="Dupe checking must include a team_id" ) - logging.debug(f"dupe check") + logging.debug("dupe check") p_query = Card.select(Card.player).where(Card.team_id == team_id) seen = set() dupes = [] @@ -176,9 +177,6 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): status_code=401, detail="You are not authorized to post cards. This event has been logged.", ) - last_card = Card.select(Card.id).order_by(-Card.id).limit(1) - lc_id = last_card[0].id - new_cards = [] player_ids = [] inc_dex = True @@ -209,6 +207,19 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): cost_query.execute() # sheets.post_new_cards(SHEETS_AUTH, lc_id) + # WP-10: initialize evolution state for each new card (fire-and-forget) + for x in cards.cards: + try: + this_player = Player.get_by_id(x.player_id) + card_type = _determine_card_type(this_player) + initialize_card_evolution(x.player_id, x.team_id, card_type) + except Exception: + logging.exception( + "evolution hook: unexpected error for player_id=%s team_id=%s", + x.player_id, + x.team_id, + ) + raise HTTPException( status_code=200, detail=f"{len(new_cards)} cards have been added" ) @@ -307,7 +318,7 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)): try: this_team = Team.get_by_id(team_id) - except DoesNotExist as e: + except DoesNotExist: logging.error(f'/cards/wipe-team/{team_id} - could not find team') raise HTTPException(status_code=404, detail=f'Team {team_id} not found') diff --git a/app/routers_v2/evolution.py b/app/routers_v2/evolution.py index f7d9b86..d08e528 100644 --- a/app/routers_v2/evolution.py +++ b/app/routers_v2/evolution.py @@ -5,8 +5,54 @@ from typing import Optional from ..db_engine import model_to_dict from ..dependencies import oauth2_scheme, valid_token +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"]) +# Tier -> threshold attribute name. Index = current_tier; value is the +# attribute on EvolutionTrack whose value is the *next* threshold to reach. +# Tier 4 is fully evolved so there is no next threshold (None sentinel). +_NEXT_THRESHOLD_ATTR = { + 0: "t1_threshold", + 1: "t2_threshold", + 2: "t3_threshold", + 3: "t4_threshold", + 4: None, +} + + +def _build_card_state_response(state) -> dict: + """Serialise an EvolutionCardState into the standard API response shape. + + Produces a flat dict with player_id and team_id as plain integers, + a nested 'track' dict with all threshold fields, and a computed + 'next_threshold' field: + - For tiers 0-3: the threshold value for the tier immediately above. + - For tier 4 (fully evolved): None. + + Uses model_to_dict(recurse=False) internally so FK fields are returned + as IDs rather than nested objects, then promotes the needed IDs up to + the top level. + """ + track = state.track + track_dict = model_to_dict(track, recurse=False) + + next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier) + next_threshold = getattr(track, next_attr) if next_attr else None + + return { + "player_id": state.player_id, + "team_id": state.team_id, + "current_tier": state.current_tier, + "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 + ), + "track": track_dict, + "next_threshold": next_threshold, + } + @router.get("/tracks") async def list_tracks( @@ -41,3 +87,145 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=404, detail=f"Track {track_id} not found") return model_to_dict(track, recurse=False) + + +@router.get("/cards/{card_id}") +async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)): + """Return the EvolutionCardState for a card identified by its Card.id. + + Resolves card_id -> (player_id, team_id) via the Card table, then looks + up the matching EvolutionCardState row. Because duplicate cards for the + same player+team share one state row (unique-(player,team) constraint), + any card_id belonging to that player on that team returns the same state. + + Returns 404 when: + - The card_id does not exist in the Card table. + - The card exists but has no corresponding EvolutionCardState yet. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import Card, EvolutionCardState, EvolutionTrack, DoesNotExist + + # Resolve card_id to player+team + try: + card = Card.get_by_id(card_id) + except DoesNotExist: + raise HTTPException(status_code=404, detail=f"Card {card_id} not found") + + # Look up the evolution state for this (player, team) pair, joining the + # track so a single query resolves both rows. + try: + state = ( + EvolutionCardState.select(EvolutionCardState, EvolutionTrack) + .join(EvolutionTrack) + .where( + (EvolutionCardState.player == card.player_id) + & (EvolutionCardState.team == card.team_id) + ) + .get() + ) + except DoesNotExist: + raise HTTPException( + status_code=404, + detail=f"No evolution state for card {card_id}", + ) + + return _build_card_state_response(state) + + +@router.post("/cards/{card_id}/evaluate") +async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)): + """Force-recalculate evolution state for a card from career stats. + + Resolves card_id to (player_id, team_id), then recomputes the evolution + tier from all player_season_stats rows for that pair. Idempotent. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import Card + from ..services.evolution_evaluator import evaluate_card as _evaluate + + try: + card = Card.get_by_id(card_id) + except Exception: + raise HTTPException(status_code=404, detail=f"Card {card_id} not found") + + try: + result = _evaluate(card.player_id, card.team_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + return result + + +@router.post("/evaluate-game/{game_id}") +async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)): + """Evaluate evolution state for all players who appeared in a game. + + Finds all unique (player_id, team_id) pairs from the game's StratPlay rows, + then for each pair that has an EvolutionCardState, re-computes the evolution + tier. Pairs without a state row are silently skipped. Per-player errors are + logged but do not abort the batch. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionCardState, EvolutionTrack, Player, StratPlay + from ..services.evolution_evaluator import evaluate_card + + plays = list(StratPlay.select().where(StratPlay.game == game_id)) + + pairs: set[tuple[int, int]] = set() + for play in plays: + if play.batter_id is not None: + pairs.add((play.batter_id, play.batter_team_id)) + if play.pitcher_id is not None: + pairs.add((play.pitcher_id, play.pitcher_team_id)) + + evaluated = 0 + tier_ups = [] + + for player_id, team_id in pairs: + try: + state = EvolutionCardState.get_or_none( + (EvolutionCardState.player_id == player_id) + & (EvolutionCardState.team_id == team_id) + ) + if state is None: + continue + + old_tier = state.current_tier + result = evaluate_card(player_id, team_id) + evaluated += 1 + + new_tier = result.get("current_tier", old_tier) + if new_tier > old_tier: + player_name = "Unknown" + try: + p = Player.get_by_id(player_id) + player_name = p.p_name + 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", + } + ) + except Exception as exc: + logger.warning( + f"Evolution eval failed for player={player_id} team={team_id}: {exc}" + ) + + return {"evaluated": evaluated, "tier_ups": tier_ups} diff --git a/app/routers_v2/season_stats.py b/app/routers_v2/season_stats.py index c5d48c3..eb87e59 100644 --- a/app/routers_v2/season_stats.py +++ b/app/routers_v2/season_stats.py @@ -3,230 +3,67 @@ Covers WP-13 (Post-Game Callback Integration): POST /api/v2/season-stats/update-game/{game_id} -Aggregates BattingStat and PitchingStat rows for a completed game and -increments the corresponding batting_season_stats / pitching_season_stats -rows via an additive upsert. +Delegates to app.services.season_stats.update_season_stats() which +recomputes full-season stats from all StratPlay and Decision rows for +every player who appeared in the game, then writes those totals into +batting_season_stats and pitching_season_stats. + +Idempotency is enforced by the service layer: re-delivery of the same +game_id returns {"updated": 0, "skipped": true} without modifying stats. +Pass force=true to bypass the idempotency guard and force recalculation. """ import logging from fastapi import APIRouter, Depends, HTTPException -from ..db_engine import db from ..dependencies import oauth2_scheme, valid_token router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"]) - -def _ip_to_outs(ip: float) -> int: - """Convert innings-pitched float (e.g. 6.1) to integer outs (e.g. 19). - - Baseball stores IP as whole.partial where the fractional digit is outs - (0, 1, or 2), not tenths. 6.1 = 6 innings + 1 out = 19 outs. - """ - whole = int(ip) - partial = round((ip - whole) * 10) - return whole * 3 + partial +logger = logging.getLogger(__name__) @router.post("/update-game/{game_id}") -async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)): - """Increment season stats with batting and pitching deltas from a game. +async def update_game_season_stats( + game_id: int, force: bool = False, token: str = Depends(oauth2_scheme) +): + """Recalculate season stats from all StratPlay and Decision rows for a game. - Queries BattingStat and PitchingStat rows for game_id, aggregates by - (player_id, team_id, season), then performs an additive ON CONFLICT upsert - into batting_season_stats and pitching_season_stats respectively. + Calls update_season_stats(game_id, force=force) from the service layer which: + - Recomputes full-season totals from all StratPlay rows for each player + - Aggregates Decision rows for pitching win/loss/save/hold stats + - Writes totals into batting_season_stats and pitching_season_stats + - Guards against redundant work via the ProcessedGame ledger - Replaying the same game_id will double-count stats, so callers must ensure - this is only called once per game. + Query params: + - force: if true, bypasses the idempotency guard and reprocesses a + previously seen game_id (useful for correcting stats after data fixes) - Response: {"updated": N} where N is the number of player rows touched. + Response: {"updated": N, "skipped": false} + - N: total player_season_stats rows upserted (batters + pitchers) + - skipped: true when this game_id was already processed and force=false + + Errors from the service are logged but re-raised as 500 so the bot + knows to retry. """ if not valid_token(token): logging.warning("Bad Token: [REDACTED]") raise HTTPException(status_code=401, detail="Unauthorized") - updated = 0 + from ..services.season_stats import update_season_stats - # --- Batting --- - bat_rows = list( - db.execute_sql( - """ - SELECT c.player_id, bs.team_id, bs.season, - SUM(bs.pa), SUM(bs.ab), SUM(bs.run), SUM(bs.hit), - SUM(bs.double), SUM(bs.triple), SUM(bs.hr), SUM(bs.rbi), - SUM(bs.bb), SUM(bs.so), SUM(bs.hbp), SUM(bs.sac), - SUM(bs.ibb), SUM(bs.gidp), SUM(bs.sb), SUM(bs.cs) - FROM battingstat bs - JOIN card c ON bs.card_id = c.id - WHERE bs.game_id = %s - GROUP BY c.player_id, bs.team_id, bs.season - """, - (game_id,), + try: + result = update_season_stats(game_id, force=force) + except Exception as exc: + logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Season stats update failed for game {game_id}: {exc}", ) - ) - for row in bat_rows: - ( - player_id, - team_id, - season, - pa, - ab, - runs, - hits, - doubles, - triples, - hr, - rbi, - bb, - strikeouts, - hbp, - sac, - ibb, - gidp, - sb, - cs, - ) = row - db.execute_sql( - """ - INSERT INTO batting_season_stats - (player_id, team_id, season, - pa, ab, runs, hits, doubles, triples, hr, rbi, - bb, strikeouts, hbp, sac, ibb, gidp, sb, cs) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (player_id, team_id, season) DO UPDATE SET - pa = batting_season_stats.pa + EXCLUDED.pa, - ab = batting_season_stats.ab + EXCLUDED.ab, - runs = batting_season_stats.runs + EXCLUDED.runs, - hits = batting_season_stats.hits + EXCLUDED.hits, - doubles = batting_season_stats.doubles + EXCLUDED.doubles, - triples = batting_season_stats.triples + EXCLUDED.triples, - hr = batting_season_stats.hr + EXCLUDED.hr, - rbi = batting_season_stats.rbi + EXCLUDED.rbi, - bb = batting_season_stats.bb + EXCLUDED.bb, - strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts, - hbp = batting_season_stats.hbp + EXCLUDED.hbp, - sac = batting_season_stats.sac + EXCLUDED.sac, - ibb = batting_season_stats.ibb + EXCLUDED.ibb, - gidp = batting_season_stats.gidp + EXCLUDED.gidp, - sb = batting_season_stats.sb + EXCLUDED.sb, - cs = batting_season_stats.cs + EXCLUDED.cs - """, - ( - player_id, - team_id, - season, - pa, - ab, - runs, - hits, - doubles, - triples, - hr, - rbi, - bb, - strikeouts, - hbp, - sac, - ibb, - gidp, - sb, - cs, - ), - ) - updated += 1 - - # --- Pitching --- - pit_rows = list( - db.execute_sql( - """ - SELECT c.player_id, ps.team_id, ps.season, - SUM(ps.ip), SUM(ps.so), SUM(ps.hit), SUM(ps.run), SUM(ps.erun), - SUM(ps.bb), SUM(ps.hbp), SUM(ps.wp), SUM(ps.balk), SUM(ps.hr), - SUM(ps.gs), SUM(ps.win), SUM(ps.loss), SUM(ps.hold), - SUM(ps.sv), SUM(ps.bsv) - FROM pitchingstat ps - JOIN card c ON ps.card_id = c.id - WHERE ps.game_id = %s - GROUP BY c.player_id, ps.team_id, ps.season - """, - (game_id,), - ) - ) - - for row in pit_rows: - ( - player_id, - team_id, - season, - ip, - strikeouts, - hits_allowed, - runs_allowed, - earned_runs, - bb, - hbp, - wild_pitches, - balks, - hr_allowed, - games_started, - wins, - losses, - holds, - saves, - blown_saves, - ) = row - outs = _ip_to_outs(float(ip)) - db.execute_sql( - """ - INSERT INTO pitching_season_stats - (player_id, team_id, season, - outs, strikeouts, hits_allowed, runs_allowed, earned_runs, - bb, hbp, wild_pitches, balks, hr_allowed, - games_started, wins, losses, holds, saves, blown_saves) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (player_id, team_id, season) DO UPDATE SET - outs = pitching_season_stats.outs + EXCLUDED.outs, - strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts, - hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed, - runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed, - earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs, - bb = pitching_season_stats.bb + EXCLUDED.bb, - hbp = pitching_season_stats.hbp + EXCLUDED.hbp, - wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches, - balks = pitching_season_stats.balks + EXCLUDED.balks, - hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed, - games_started= pitching_season_stats.games_started+ EXCLUDED.games_started, - wins = pitching_season_stats.wins + EXCLUDED.wins, - losses = pitching_season_stats.losses + EXCLUDED.losses, - holds = pitching_season_stats.holds + EXCLUDED.holds, - saves = pitching_season_stats.saves + EXCLUDED.saves, - blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves - """, - ( - player_id, - team_id, - season, - outs, - strikeouts, - hits_allowed, - runs_allowed, - earned_runs, - bb, - hbp, - wild_pitches, - balks, - hr_allowed, - games_started, - wins, - losses, - holds, - saves, - blown_saves, - ), - ) - updated += 1 - - logging.info(f"update-game/{game_id}: updated {updated} season stats rows") - return {"updated": updated} + updated = result.get("batters_updated", 0) + result.get("pitchers_updated", 0) + return { + "updated": updated, + "skipped": result.get("skipped", False), + } diff --git a/app/routers_v2/teams.py b/app/routers_v2/teams.py index c39057a..58394f7 100644 --- a/app/routers_v2/teams.py +++ b/app/routers_v2/teams.py @@ -135,15 +135,15 @@ async def get_teams( if has_guide is not None: # Use boolean comparison (PostgreSQL-compatible) if not has_guide: - all_teams = all_teams.where(Team.has_guide == False) + all_teams = all_teams.where(Team.has_guide == False) # noqa: E712 else: - all_teams = all_teams.where(Team.has_guide == True) + all_teams = all_teams.where(Team.has_guide == True) # noqa: E712 if is_ai is not None: if not is_ai: - all_teams = all_teams.where(Team.is_ai == False) + all_teams = all_teams.where(Team.is_ai == False) # noqa: E712 else: - all_teams = all_teams.where(Team.is_ai == True) + all_teams = all_teams.where(Team.is_ai == True) # noqa: E712 if event_id is not None: all_teams = all_teams.where(Team.event_id == event_id) @@ -254,24 +254,24 @@ def get_scouting_dfs(allowed_players, position: str): if position in ["LF", "CF", "RF"]: series_list.append( pd.Series( - dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm OF" + dict([(x.player.player_id, x.arm) for x in positions]), name="Arm OF" ) ) elif position == "C": series_list.append( pd.Series( - dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm C" + dict([(x.player.player_id, x.arm) for x in positions]), name="Arm C" ) ) series_list.append( pd.Series( - dict([(x.player.player_id, x.pb) for x in positions]), name=f"PB C" + dict([(x.player.player_id, x.pb) for x in positions]), name="PB C" ) ) series_list.append( pd.Series( dict([(x.player.player_id, x.overthrow) for x in positions]), - name=f"Throw C", + name="Throw C", ) ) @@ -314,11 +314,11 @@ async def get_team_lineup( all_players = Player.select().where(Player.franchise == this_team.sname) if difficulty_name == "exhibition": - logging.info(f"pulling an exhibition lineup") + logging.info("pulling an exhibition lineup") if cardset_id is None: raise HTTPException( status_code=400, - detail=f"Must provide at least one cardset_id for exhibition lineups", + detail="Must provide at least one cardset_id for exhibition lineups", ) legal_players = all_players.where(Player.cardset_id << cardset_id) @@ -404,17 +404,17 @@ async def get_team_lineup( # if x.battingcard.player.p_name not in player_names: # starting_nine['DH'] = x.battingcard.player # break - logging.debug(f"Searching for a DH!") + logging.debug("Searching for a DH!") dh_query = legal_players.order_by(Player.cost.desc()) for x in dh_query: logging.debug(f"checking {x.p_name} for {position}") if x.p_name not in player_names and "P" not in x.pos_1: - logging.debug(f"adding!") + logging.debug("adding!") starting_nine["DH"]["player"] = model_to_dict(x) try: vl, vr, total_ops = get_bratings(x.player_id) - except AttributeError as e: - logging.debug(f"Could not find batting lines") + except AttributeError: + logging.debug("Could not find batting lines") else: # starting_nine[position]['vl'] = vl # starting_nine[position]['vr'] = vr @@ -429,12 +429,12 @@ async def get_team_lineup( for x in dh_query: logging.debug(f"checking {x.p_name} for {position}") if x.p_name not in player_names: - logging.debug(f"adding!") + logging.debug("adding!") starting_nine["DH"]["player"] = model_to_dict(x) try: vl, vr, total_ops = get_bratings(x.player_id) - except AttributeError as e: - logging.debug(f"Could not find batting lines") + except AttributeError: + logging.debug("Could not find batting lines") else: vl, vr, total_ops = get_bratings(x.player_id) starting_nine[position]["vl"] = vl["obp"] + vl["slg"] @@ -464,7 +464,7 @@ async def get_team_lineup( x.player.p_name not in player_names and x.player.p_name.lower() != pitcher_name ): - logging.debug(f"adding!") + logging.debug("adding!") starting_nine[position]["player"] = model_to_dict(x.player) vl, vr, total_ops = get_bratings(x.player.player_id) starting_nine[position]["vl"] = vl @@ -542,7 +542,7 @@ async def get_team_lineup( x.player.p_name not in player_names and x.player.p_name.lower() != pitcher_name ): - logging.debug(f"adding!") + logging.debug("adding!") starting_nine[position]["player"] = model_to_dict(x.player) vl, vr, total_ops = get_bratings(x.player.player_id) starting_nine[position]["vl"] = vl["obp"] + vl["slg"] @@ -649,11 +649,11 @@ async def get_team_sp( all_players = Player.select().where(Player.franchise == this_team.sname) if difficulty_name == "exhibition": - logging.info(f"pulling an exhibition lineup") + logging.info("pulling an exhibition lineup") if cardset_id is None: raise HTTPException( status_code=400, - detail=f"Must provide at least one cardset_id for exhibition lineups", + detail="Must provide at least one cardset_id for exhibition lineups", ) legal_players = all_players.where(Player.cardset_id << cardset_id) @@ -778,11 +778,11 @@ async def get_team_rp( ) if difficulty_name == "exhibition": - logging.info(f"pulling an exhibition RP") + logging.info("pulling an exhibition RP") if cardset_id is None: raise HTTPException( status_code=400, - detail=f"Must provide at least one cardset_id for exhibition lineups", + detail="Must provide at least one cardset_id for exhibition lineups", ) legal_players = all_players.where(Player.cardset_id << cardset_id) @@ -934,7 +934,7 @@ async def get_team_rp( ) return this_player - logging.info(f"Falling to last chance pitcher") + logging.info("Falling to last chance pitcher") all_relievers = sort_pitchers( PitchingCard.select() .join(Player) @@ -957,7 +957,7 @@ async def get_team_record(team_id: int, season: int): all_games = StratGame.select().where( ((StratGame.away_team_id == team_id) | (StratGame.home_team_id == team_id)) & (StratGame.season == season) - & (StratGame.short_game == False) + & (StratGame.short_game == False) # noqa: E712 ) template = { @@ -1049,8 +1049,6 @@ async def team_buy_players(team_id: int, ids: str, ts: str): detail=f"You are not authorized to buy {this_team.abbrev} cards. This event has been logged.", ) - last_card = Card.select(Card.id).order_by(-Card.id).limit(1) - lc_id = last_card[0].id all_ids = ids.split(",") conf_message = "" @@ -1098,7 +1096,7 @@ async def team_buy_players(team_id: int, ids: str, ts: str): if this_player.rarity.value >= 2: new_notif = Notification( created=datetime.now(), - title=f"Price Change", + title="Price Change", desc="Modified by buying and selling", field_name=f"{this_player.description} " f"{this_player.p_name if this_player.p_name not in this_player.description else ''}", @@ -1242,7 +1240,7 @@ async def team_sell_cards(team_id: int, ids: str, ts: str): if this_player.rarity.value >= 2: new_notif = Notification( created=datetime.now(), - title=f"Price Change", + title="Price Change", desc="Modified by buying and selling", field_name=f"{this_player.description} " f"{this_player.p_name if this_player.p_name not in this_player.description else ''}", @@ -1293,7 +1291,7 @@ async def get_team_cards(team_id, csv: Optional[bool] = True): .order_by(-Card.player.rarity.value, Card.player.p_name) ) if all_cards.count() == 0: - raise HTTPException(status_code=404, detail=f"No cards found") + raise HTTPException(status_code=404, detail="No cards found") card_vals = [model_to_dict(x) for x in all_cards] @@ -1391,7 +1389,7 @@ async def team_season_update(new_season: int, token: str = Depends(oauth2_scheme detail="You are not authorized to post teams. This event has been logged.", ) - r_query = Team.update( + Team.update( ranking=1000, season=new_season, wallet=Team.wallet + 250, has_guide=False ).execute() current = Current.latest() @@ -1531,3 +1529,57 @@ async def delete_team(team_id, token: str = Depends(oauth2_scheme)): raise HTTPException(status_code=200, detail=f"Team {team_id} has been deleted") else: raise HTTPException(status_code=500, detail=f"Team {team_id} was not deleted") + + +@router.get("/{team_id}/evolutions") +async def list_team_evolutions( + team_id: int, + card_type: Optional[str] = Query(default=None), + tier: Optional[int] = Query(default=None), + page: int = Query(default=1, ge=1), + per_page: int = Query(default=10, ge=1, le=100), + token: str = Depends(oauth2_scheme), +): + """List all EvolutionCardState rows for a team, with optional filters. + + Joins EvolutionCardState to EvolutionTrack so that card_type filtering + works without a second query. Results are paginated via page/per_page + (1-indexed pages); items are ordered by player_id for stable ordering. + + Query parameters: + card_type -- filter to states whose track.card_type matches (e.g. 'batter', 'sp') + tier -- filter to states at a specific current_tier (0-4) + page -- 1-indexed page number (default 1) + per_page -- items per page (default 10, max 100) + + Response shape: + {"count": N, "items": [card_state_with_threshold_context, ...]} + + Each item in 'items' has the same shape as GET /evolution/cards/{card_id}. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionCardState, EvolutionTrack + from ..routers_v2.evolution import _build_card_state_response + + query = ( + EvolutionCardState.select(EvolutionCardState, EvolutionTrack) + .join(EvolutionTrack) + .where(EvolutionCardState.team == team_id) + .order_by(EvolutionCardState.player_id) + ) + + if card_type is not None: + query = query.where(EvolutionTrack.card_type == card_type) + + if tier is not None: + query = query.where(EvolutionCardState.current_tier == tier) + + total = query.count() + offset = (page - 1) * per_page + page_query = query.offset(offset).limit(per_page) + + items = [_build_card_state_response(state) for state in page_query] + return {"count": total, "items": items} diff --git a/app/seed/evolution_tracks.json b/app/seed/evolution_tracks.json index a4bd1f0..4f06142 100644 --- a/app/seed/evolution_tracks.json +++ b/app/seed/evolution_tracks.json @@ -1,5 +1,29 @@ [ - {"name": "Batter", "card_type": "batter", "formula": "pa+tb*2", "t1": 37, "t2": 149, "t3": 448, "t4": 896}, - {"name": "Starting Pitcher", "card_type": "sp", "formula": "ip+k", "t1": 10, "t2": 40, "t3": 120, "t4": 240}, - {"name": "Relief Pitcher", "card_type": "rp", "formula": "ip+k", "t1": 3, "t2": 12, "t3": 35, "t4": 70} + { + "name": "Batter Track", + "card_type": "batter", + "formula": "pa + tb * 2", + "t1_threshold": 37, + "t2_threshold": 149, + "t3_threshold": 448, + "t4_threshold": 896 + }, + { + "name": "Starting Pitcher Track", + "card_type": "sp", + "formula": "ip + k", + "t1_threshold": 10, + "t2_threshold": 40, + "t3_threshold": 120, + "t4_threshold": 240 + }, + { + "name": "Relief Pitcher Track", + "card_type": "rp", + "formula": "ip + k", + "t1_threshold": 3, + "t2_threshold": 12, + "t3_threshold": 35, + "t4_threshold": 70 + } ] diff --git a/app/seed/evolution_tracks.py b/app/seed/evolution_tracks.py index 178f68e..3314a97 100644 --- a/app/seed/evolution_tracks.py +++ b/app/seed/evolution_tracks.py @@ -1,41 +1,66 @@ -"""Seed data fixture for EvolutionTrack. +"""Seed script for EvolutionTrack records. -Inserts the three universal evolution tracks (Batter, Starting Pitcher, -Relief Pitcher) if they do not already exist. Safe to call multiple times -thanks to get_or_create — depends on WP-01 (EvolutionTrack model) to run. +Loads track definitions from evolution_tracks.json and upserts them into the +database using get_or_create keyed on name. Existing tracks have their +thresholds and formula updated to match the JSON in case values have changed. + +Can be run standalone: + python -m app.seed.evolution_tracks """ import json -import os +import logging +from pathlib import Path -_JSON_PATH = os.path.join(os.path.dirname(__file__), "evolution_tracks.json") +from app.db_engine import EvolutionTrack + +logger = logging.getLogger(__name__) + +_JSON_PATH = Path(__file__).parent / "evolution_tracks.json" -def load_tracks(): - """Return the locked list of evolution track dicts from the JSON fixture.""" - with open(_JSON_PATH) as fh: - return json.load(fh) +def seed_evolution_tracks() -> list[EvolutionTrack]: + """Upsert evolution tracks from JSON seed data. - -def seed(model_class=None): - """Insert evolution tracks that are not yet in the database. - - Args: - model_class: Peewee model with get_or_create support. Defaults to - ``app.db_engine.EvolutionTrack`` (imported lazily so this module - can be imported before WP-01 lands). - - Returns: - List of (instance, created) tuples from get_or_create. + Returns a list of EvolutionTrack instances that were created or updated. """ - if model_class is None: - from app.db_engine import EvolutionTrack as model_class # noqa: PLC0415 + raw = _JSON_PATH.read_text(encoding="utf-8") + track_defs = json.loads(raw) - results = [] - for track in load_tracks(): - instance, created = model_class.get_or_create( - card_type=track["card_type"], - defaults=track, + results: list[EvolutionTrack] = [] + + for defn in track_defs: + track, created = EvolutionTrack.get_or_create( + name=defn["name"], + defaults={ + "card_type": defn["card_type"], + "formula": defn["formula"], + "t1_threshold": defn["t1_threshold"], + "t2_threshold": defn["t2_threshold"], + "t3_threshold": defn["t3_threshold"], + "t4_threshold": defn["t4_threshold"], + }, ) - results.append((instance, created)) + + if not created: + # Update mutable fields in case the JSON values changed. + track.card_type = defn["card_type"] + track.formula = defn["formula"] + track.t1_threshold = defn["t1_threshold"] + track.t2_threshold = defn["t2_threshold"] + track.t3_threshold = defn["t3_threshold"] + track.t4_threshold = defn["t4_threshold"] + track.save() + + action = "created" if created else "updated" + logger.info("[%s] %s (card_type=%s)", action, track.name, track.card_type) + results.append(track) + return results + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + logger.info("Seeding evolution tracks...") + tracks = seed_evolution_tracks() + logger.info("Done. %d track(s) processed.", len(tracks)) diff --git a/app/services/evolution_evaluator.py b/app/services/evolution_evaluator.py new file mode 100644 index 0000000..fa5961e --- /dev/null +++ b/app/services/evolution_evaluator.py @@ -0,0 +1,196 @@ +"""Evolution evaluator service (WP-08). + +Force-recalculates a card's evolution state from career totals. + +evaluate_card() is the main entry point: + 1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id) + 2. Determine track from card_state.track + 3. Compute formula value (delegated to formula engine, WP-09) + 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) + 8. Update card_state.last_evaluated_at = NOW() + +Idempotent: calling multiple times with the same data produces the same result. + +Depends on WP-05 (EvolutionCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats), +and WP-09 (formula engine). Models and formula functions are imported lazily so +this module can be imported before those PRs merge. +""" + +from datetime import datetime +import logging + + +class _CareerTotals: + """Aggregated career stats for a (player_id, team_id) pair. + + Passed to the formula engine as a stats-duck-type object with the attributes + required by compute_value_for_track: + batter: pa, hits, doubles, triples, hr + sp/rp: outs, strikeouts + """ + + __slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts") + + def __init__(self, pa, hits, doubles, triples, hr, outs, strikeouts): + self.pa = pa + self.hits = hits + self.doubles = doubles + self.triples = triples + self.hr = hr + self.outs = outs + self.strikeouts = strikeouts + + +def evaluate_card( + player_id: int, + team_id: int, + _stats_model=None, + _state_model=None, + _compute_value_fn=None, + _tier_from_value_fn=None, +) -> dict: + """Force-recalculate a card's evolution tier from career stats. + + 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 evolution_card_state and + returned as a dict. + + current_tier never decreases (no regression): + card_state.current_tier = max(card_state.current_tier, new_tier) + + Args: + player_id: Player primary key. + team_id: Team primary key. + _stats_model: Override for BattingSeasonStats/PitchingSeasonStats + (used in tests to inject a stub model with all stat fields). + _state_model: Override for EvolutionCardState (used in tests to avoid + importing from db_engine before WP-05 merges). + _compute_value_fn: Override for formula_engine.compute_value_for_track + (used in tests to avoid importing formula_engine before WP-09 merges). + _tier_from_value_fn: Override for formula_engine.tier_from_value + (used in tests). + + Returns: + Dict with updated current_tier, current_value, fully_evolved, + last_evaluated_at (ISO-8601 string). + + Raises: + ValueError: If no evolution_card_state row exists for (player_id, team_id). + """ + if _state_model is None: + from app.db_engine import EvolutionCardState as _state_model # noqa: PLC0415 + + if _compute_value_fn is None or _tier_from_value_fn is None: + from app.services.formula_engine import ( # noqa: PLC0415 + compute_value_for_track, + tier_from_value, + ) + + if _compute_value_fn is None: + _compute_value_fn = compute_value_for_track + if _tier_from_value_fn is None: + _tier_from_value_fn = tier_from_value + + # 1. Load card state + card_state = _state_model.get_or_none( + (_state_model.player_id == player_id) & (_state_model.team_id == team_id) + ) + if card_state is None: + raise ValueError( + f"No evolution_card_state for player_id={player_id} team_id={team_id}" + ) + + # 2. Load career totals from the appropriate season stats table + if _stats_model is not None: + # Test override: use the injected stub model for all fields + rows = list( + _stats_model.select().where( + (_stats_model.player_id == player_id) + & (_stats_model.team_id == team_id) + ) + ) + totals = _CareerTotals( + pa=sum(r.pa for r in rows), + hits=sum(r.hits for r in rows), + doubles=sum(r.doubles for r in rows), + triples=sum(r.triples for r in rows), + hr=sum(r.hr for r in rows), + outs=sum(r.outs for r in rows), + strikeouts=sum(r.strikeouts for r in rows), + ) + else: + from app.db_engine import ( + BattingSeasonStats, + PitchingSeasonStats, + ) # noqa: PLC0415 + + card_type = card_state.track.card_type + if card_type == "batter": + rows = list( + BattingSeasonStats.select().where( + (BattingSeasonStats.player == player_id) + & (BattingSeasonStats.team == team_id) + ) + ) + totals = _CareerTotals( + pa=sum(r.pa for r in rows), + hits=sum(r.hits for r in rows), + doubles=sum(r.doubles for r in rows), + triples=sum(r.triples for r in rows), + hr=sum(r.hr for r in rows), + outs=0, + strikeouts=sum(r.strikeouts for r in rows), + ) + else: + rows = list( + PitchingSeasonStats.select().where( + (PitchingSeasonStats.player == player_id) + & (PitchingSeasonStats.team == team_id) + ) + ) + totals = _CareerTotals( + pa=0, + hits=0, + doubles=0, + triples=0, + hr=0, + outs=sum(r.outs for r in rows), + strikeouts=sum(r.strikeouts for r in rows), + ) + + # 3. Determine track + track = card_state.track + + # 4. Compute formula value and new tier + value = _compute_value_fn(track.card_type, totals) + new_tier = _tier_from_value_fn(value, track) + + # 5–8. Update card state (no tier regression) + now = datetime.now() + 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 + card_state.save() + + logging.debug( + "evolution_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s", + player_id, + team_id, + value, + card_state.current_tier, + card_state.fully_evolved, + ) + + return { + "player_id": player_id, + "team_id": team_id, + "current_value": card_state.current_value, + "current_tier": card_state.current_tier, + "fully_evolved": card_state.fully_evolved, + "last_evaluated_at": card_state.last_evaluated_at.isoformat(), + } diff --git a/app/services/evolution_init.py b/app/services/evolution_init.py new file mode 100644 index 0000000..cac9b7b --- /dev/null +++ b/app/services/evolution_init.py @@ -0,0 +1,138 @@ +""" +WP-10: Pack opening hook — evolution_card_state initialization. + +Public API +---------- +initialize_card_evolution(player_id, team_id, card_type) + Get-or-create an EvolutionCardState for the (player_id, team_id) pair. + Returns the state instance on success, or None if initialization fails + (missing track, integrity error, etc.). Never raises. + +_determine_card_type(player) + Pure function: inspect player.pos_1 and return 'sp', 'rp', or 'batter'. + Exported so the cards router and tests can call it directly. + +Design notes +------------ +- The function is intentionally fire-and-forget from the caller's perspective. + All exceptions are caught and logged; pack opening is never blocked. +- No EvolutionProgress rows are created here. Progress accumulation is a + separate concern handled by the stats-update pipeline (WP-07/WP-08). +- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern); + we do NOT replicate that exclusion here — all teams get an evolution state + so that future rule changes don't require back-filling. +""" + +import logging +from typing import Optional + +from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack + +logger = logging.getLogger(__name__) + + +def _determine_card_type(player) -> str: + """Map a player's primary position to an evolution card_type string. + + Rules (from WP-10 spec): + - pos_1 contains 'SP' -> 'sp' + - pos_1 contains 'RP' or 'CP' -> 'rp' + - anything else -> 'batter' + + Args: + player: Any object with a ``pos_1`` attribute (Player model or stub). + + Returns: + One of the strings 'batter', 'sp', 'rp'. + """ + pos = (player.pos_1 or "").upper() + if "SP" in pos: + return "sp" + if "RP" in pos or "CP" in pos: + return "rp" + return "batter" + + +def initialize_card_evolution( + player_id: int, + team_id: int, + card_type: str, +) -> Optional[EvolutionCardState]: + """Get-or-create an EvolutionCardState for a newly acquired card. + + Called by the cards POST endpoint after each card is inserted. The + function is idempotent: if a state row already exists for the + (player_id, team_id) pair it is returned unchanged — existing + evolution progress is never reset. + + Args: + player_id: Primary key of the Player row (Player.player_id). + team_id: Primary key of the Team row (Team.id). + card_type: One of 'batter', 'sp', 'rp'. Determines which + EvolutionTrack is assigned to the new state. + + Returns: + The existing or newly created EvolutionCardState instance, or + None if initialization could not complete (missing track seed + data, unexpected DB error, etc.). + """ + try: + track = EvolutionTrack.get(EvolutionTrack.card_type == card_type) + except DoesNotExist: + logger.warning( + "evolution_init: no EvolutionTrack found for card_type=%r " + "(player_id=%s, team_id=%s) — skipping state creation", + card_type, + player_id, + team_id, + ) + return None + except Exception: + logger.exception( + "evolution_init: unexpected error fetching track " + "(card_type=%r, player_id=%s, team_id=%s)", + card_type, + player_id, + team_id, + ) + return None + + try: + state, created = EvolutionCardState.get_or_create( + player_id=player_id, + team_id=team_id, + defaults={ + "track": track, + "current_tier": 0, + "current_value": 0.0, + "fully_evolved": False, + }, + ) + if created: + logger.debug( + "evolution_init: created EvolutionCardState id=%s " + "(player_id=%s, team_id=%s, card_type=%r)", + state.id, + player_id, + team_id, + card_type, + ) + else: + logger.debug( + "evolution_init: state already exists id=%s " + "(player_id=%s, team_id=%s) — no-op", + state.id, + player_id, + team_id, + ) + return state + + except Exception: + logger.exception( + "evolution_init: failed to get_or_create state " + "(player_id=%s, team_id=%s, card_type=%r)", + player_id, + team_id, + card_type, + ) + return None diff --git a/app/services/formula_engine.py b/app/services/formula_engine.py index c863051..2e55a65 100644 --- a/app/services/formula_engine.py +++ b/app/services/formula_engine.py @@ -90,13 +90,23 @@ def tier_from_value(value: float, track) -> int: Args: value: Computed formula value. - track: Object (or dict-like) with t1, t2, t3, t4 attributes/keys. + track: Object (or dict-like) with t1_threshold..t4_threshold attributes/keys. """ # Support both attribute-style (Peewee model) and dict (seed fixture) if isinstance(track, dict): - t1, t2, t3, t4 = track["t1"], track["t2"], track["t3"], track["t4"] + t1, t2, t3, t4 = ( + track["t1_threshold"], + track["t2_threshold"], + track["t3_threshold"], + track["t4_threshold"], + ) else: - t1, t2, t3, t4 = track.t1, track.t2, track.t3, track.t4 + t1, t2, t3, t4 = ( + track.t1_threshold, + track.t2_threshold, + track.t3_threshold, + track.t4_threshold, + ) if value >= t4: return 4 diff --git a/app/services/season_stats.py b/app/services/season_stats.py new file mode 100644 index 0000000..bf0e06e --- /dev/null +++ b/app/services/season_stats.py @@ -0,0 +1,453 @@ +""" +season_stats.py — Full-recalculation BattingSeasonStats and PitchingSeasonStats update logic. + +Called once per completed StratGame to recompute the full season batting and +pitching statistics for every player who appeared in that game, then write +those totals to the batting_season_stats and pitching_season_stats tables. + +Unlike the previous incremental (delta) approach, each call recomputes totals +from scratch by aggregating all StratPlay rows for the player+team+season +triple. This eliminates double-counting on re-delivery and makes every row a +faithful snapshot of the full season to date. + +Idempotency: re-delivery of a game is detected via the ProcessedGame ledger +table, keyed on game_id. +- First call: records the ledger entry and proceeds with recalculation. +- Subsequent calls without force=True: return early with "skipped": True. +- force=True: skips the early-return check and recalculates anyway (useful + for correcting data after retroactive stat adjustments). + +Upsert strategy: get_or_create + field assignment + save(). Because we are +writing the full recomputed total rather than adding a delta, there is no +risk of concurrent-write skew between games. A single unified path works for +both SQLite and PostgreSQL. +""" + +import logging +from datetime import datetime + +from peewee import Case, fn + +from app.db_engine import ( + db, + BattingSeasonStats, + Decision, + PitchingSeasonStats, + ProcessedGame, + StratGame, + StratPlay, +) + +logger = logging.getLogger(__name__) + + +def _get_player_pairs(game_id: int) -> tuple[set, set]: + """ + Return the sets of (player_id, team_id) pairs that appeared in the game. + + Queries StratPlay for all rows belonging to game_id and extracts: + - batting_pairs: set of (batter_id, batter_team_id), excluding rows where + batter_id is None (e.g. automatic outs, walk-off plays without a PA). + - pitching_pairs: set of (pitcher_id, pitcher_team_id) from all plays + (pitcher is always present), plus any pitchers from the Decision table + who may not have StratPlay rows (rare edge case). + + Args: + game_id: Primary key of the StratGame to query. + + Returns: + Tuple of (batting_pairs, pitching_pairs) where each element is a set + of (int, int) tuples. + """ + plays = ( + StratPlay.select( + StratPlay.batter, + StratPlay.batter_team, + StratPlay.pitcher, + StratPlay.pitcher_team, + ) + .where(StratPlay.game == game_id) + .tuples() + ) + + batting_pairs: set[tuple[int, int]] = set() + pitching_pairs: set[tuple[int, int]] = set() + + for batter_id, batter_team_id, pitcher_id, pitcher_team_id in plays: + if batter_id is not None: + batting_pairs.add((batter_id, batter_team_id)) + if pitcher_id is not None: + pitching_pairs.add((pitcher_id, pitcher_team_id)) + + # Include pitchers who have a Decision but no StratPlay rows for this game + # (rare edge case, e.g. a pitcher credited with a decision without recording + # any plays — the old code handled this explicitly in _apply_decisions). + decision_pitchers = ( + Decision.select(Decision.pitcher, Decision.pitcher_team) + .where(Decision.game == game_id) + .tuples() + ) + for pitcher_id, pitcher_team_id in decision_pitchers: + pitching_pairs.add((pitcher_id, pitcher_team_id)) + + return batting_pairs, pitching_pairs + + +def _recalc_batting(player_id: int, team_id: int, season: int) -> dict: + """ + Recompute full-season batting totals for a player+team+season triple. + + Aggregates every StratPlay row where batter == player_id and + batter_team == team_id across all games in the given season. + + games counts only games where the player had at least one official PA + (pa > 0). The COUNT(DISTINCT ...) with a CASE expression achieves this: + NULL values from the CASE are ignored by COUNT, so only game IDs where + pa > 0 contribute. + + Args: + player_id: FK to the player record. + team_id: FK to the team record. + season: Integer season year. + + Returns: + Dict with keys matching BattingSeasonStats columns; all values are + native Python ints (defaulting to 0 if no rows matched). + """ + row = ( + StratPlay.select( + fn.COUNT( + Case(None, [(StratPlay.pa > 0, StratPlay.game)], None).distinct() + ).alias("games"), + fn.SUM(StratPlay.pa).alias("pa"), + fn.SUM(StratPlay.ab).alias("ab"), + fn.SUM(StratPlay.hit).alias("hits"), + fn.SUM(StratPlay.double).alias("doubles"), + fn.SUM(StratPlay.triple).alias("triples"), + fn.SUM(StratPlay.homerun).alias("hr"), + fn.SUM(StratPlay.rbi).alias("rbi"), + fn.SUM(StratPlay.run).alias("runs"), + fn.SUM(StratPlay.bb).alias("bb"), + fn.SUM(StratPlay.so).alias("strikeouts"), + fn.SUM(StratPlay.hbp).alias("hbp"), + fn.SUM(StratPlay.sac).alias("sac"), + fn.SUM(StratPlay.ibb).alias("ibb"), + fn.SUM(StratPlay.gidp).alias("gidp"), + fn.SUM(StratPlay.sb).alias("sb"), + fn.SUM(StratPlay.cs).alias("cs"), + ) + .join(StratGame, on=(StratPlay.game == StratGame.id)) + .where( + StratPlay.batter == player_id, + StratPlay.batter_team == team_id, + StratGame.season == season, + ) + .dicts() + .first() + ) + + if row is None: + row = {} + + return { + "games": row.get("games") or 0, + "pa": row.get("pa") or 0, + "ab": row.get("ab") or 0, + "hits": row.get("hits") or 0, + "doubles": row.get("doubles") or 0, + "triples": row.get("triples") or 0, + "hr": row.get("hr") or 0, + "rbi": row.get("rbi") or 0, + "runs": row.get("runs") or 0, + "bb": row.get("bb") or 0, + "strikeouts": row.get("strikeouts") or 0, + "hbp": row.get("hbp") or 0, + "sac": row.get("sac") or 0, + "ibb": row.get("ibb") or 0, + "gidp": row.get("gidp") or 0, + "sb": row.get("sb") or 0, + "cs": row.get("cs") or 0, + } + + +def _recalc_pitching(player_id: int, team_id: int, season: int) -> dict: + """ + Recompute full-season pitching totals for a player+team+season triple. + + Aggregates every StratPlay row where pitcher == player_id and + pitcher_team == team_id across all games in the given season. games counts + all distinct games in which the pitcher appeared (any play qualifies). + + Stats derived from StratPlay (from the batter-perspective columns): + - outs = SUM(outs) + - strikeouts = SUM(so) — batter SO = pitcher K + - hits_allowed = SUM(hit) + - bb = SUM(bb) — walks allowed + - hbp = SUM(hbp) + - hr_allowed = SUM(homerun) + - wild_pitches = SUM(wild_pitch) + - balks = SUM(balk) + + Fields not available from StratPlay (runs_allowed, earned_runs) default + to 0. Decision-level fields (wins, losses, etc.) are populated separately + by _recalc_decisions() and merged in the caller. + + Args: + player_id: FK to the player record. + team_id: FK to the team record. + season: Integer season year. + + Returns: + Dict with keys matching PitchingSeasonStats columns (excluding + decision fields, which are filled by _recalc_decisions). + """ + row = ( + StratPlay.select( + fn.COUNT(StratPlay.game.distinct()).alias("games"), + fn.SUM(StratPlay.outs).alias("outs"), + fn.SUM(StratPlay.so).alias("strikeouts"), + fn.SUM(StratPlay.hit).alias("hits_allowed"), + fn.SUM(StratPlay.bb).alias("bb"), + fn.SUM(StratPlay.hbp).alias("hbp"), + fn.SUM(StratPlay.homerun).alias("hr_allowed"), + fn.SUM(StratPlay.wild_pitch).alias("wild_pitches"), + fn.SUM(StratPlay.balk).alias("balks"), + ) + .join(StratGame, on=(StratPlay.game == StratGame.id)) + .where( + StratPlay.pitcher == player_id, + StratPlay.pitcher_team == team_id, + StratGame.season == season, + ) + .dicts() + .first() + ) + + if row is None: + row = {} + + return { + "games": row.get("games") or 0, + "outs": row.get("outs") or 0, + "strikeouts": row.get("strikeouts") or 0, + "hits_allowed": row.get("hits_allowed") or 0, + "bb": row.get("bb") or 0, + "hbp": row.get("hbp") or 0, + "hr_allowed": row.get("hr_allowed") or 0, + "wild_pitches": row.get("wild_pitches") or 0, + "balks": row.get("balks") or 0, + # Not available from play-by-play data + "runs_allowed": 0, + "earned_runs": 0, + } + + +def _recalc_decisions(player_id: int, team_id: int, season: int) -> dict: + """ + Recompute full-season decision totals for a pitcher+team+season triple. + + Aggregates all Decision rows for the pitcher across the season. Decision + rows are keyed by (pitcher, pitcher_team, season) independently of the + StratPlay table, so this query is separate from _recalc_pitching(). + + Decision.is_start is a BooleanField; CAST to INTEGER before summing to + ensure correct arithmetic across SQLite (True/False) and PostgreSQL + (boolean). + + Args: + player_id: FK to the player record (pitcher). + team_id: FK to the team record. + season: Integer season year. + + Returns: + Dict with keys: wins, losses, holds, saves, blown_saves, + games_started. All values are native Python ints. + """ + row = ( + Decision.select( + fn.SUM(Decision.win).alias("wins"), + fn.SUM(Decision.loss).alias("losses"), + fn.SUM(Decision.hold).alias("holds"), + fn.SUM(Decision.is_save).alias("saves"), + fn.SUM(Decision.b_save).alias("blown_saves"), + fn.SUM(Decision.is_start.cast("INTEGER")).alias("games_started"), + ) + .where( + Decision.pitcher == player_id, + Decision.pitcher_team == team_id, + Decision.season == season, + ) + .dicts() + .first() + ) + + if row is None: + row = {} + + return { + "wins": row.get("wins") or 0, + "losses": row.get("losses") or 0, + "holds": row.get("holds") or 0, + "saves": row.get("saves") or 0, + "blown_saves": row.get("blown_saves") or 0, + "games_started": row.get("games_started") or 0, + } + + +def update_season_stats(game_id: int, force: bool = False) -> dict: + """ + Recompute full-season batting and pitching stats for every player in the game. + + Unlike the previous incremental approach, this function recalculates each + player's season totals from scratch by querying all StratPlay rows for + the player+team+season triple. The resulting totals replace whatever was + previously stored — no additive delta is applied. + + Algorithm: + 1. Fetch StratGame to get the season. + 2. Check the ProcessedGame ledger: + - If already processed and force=False, return early (skipped=True). + - If already processed and force=True, continue (overwrite allowed). + - If not yet processed, create the ledger entry. + 3. Determine (player_id, team_id) pairs via _get_player_pairs(). + 4. For each batting pair: recompute season totals, then get_or_create + BattingSeasonStats and overwrite all fields. + 5. For each pitching pair: recompute season play totals and decision + totals, merge, then get_or_create PitchingSeasonStats and overwrite + all fields. + + Args: + game_id: Primary key of the StratGame to process. + force: If True, re-process even if the game was previously recorded + in the ProcessedGame ledger. Useful for correcting stats after + retroactive data adjustments. + + Returns: + Dict with keys: + game_id — echoed back + season — season integer from StratGame + batters_updated — number of BattingSeasonStats rows written + pitchers_updated — number of PitchingSeasonStats rows written + skipped — True only when the game was already processed + and force=False; absent otherwise. + + Raises: + StratGame.DoesNotExist: If no StratGame row matches game_id. + """ + logger.info("update_season_stats: starting for game_id=%d force=%s", game_id, force) + + game = StratGame.get_by_id(game_id) + season = game.season + + with db.atomic(): + # Idempotency check via ProcessedGame ledger. + _, created = ProcessedGame.get_or_create(game_id=game_id) + + if not created and not force: + logger.info( + "update_season_stats: game_id=%d already processed, skipping", + game_id, + ) + return { + "game_id": game_id, + "season": season, + "batters_updated": 0, + "pitchers_updated": 0, + "skipped": True, + } + + if not created and force: + logger.info( + "update_season_stats: game_id=%d already processed, force=True — recalculating", + game_id, + ) + + batting_pairs, pitching_pairs = _get_player_pairs(game_id) + logger.debug( + "update_season_stats: game_id=%d found %d batting pairs, %d pitching pairs", + game_id, + len(batting_pairs), + len(pitching_pairs), + ) + + now = datetime.now() + + # Recompute and overwrite batting season stats for each batter. + batters_updated = 0 + for player_id, team_id in batting_pairs: + stats = _recalc_batting(player_id, team_id, season) + + obj, _ = BattingSeasonStats.get_or_create( + player_id=player_id, + team_id=team_id, + season=season, + ) + obj.games = stats["games"] + obj.pa = stats["pa"] + obj.ab = stats["ab"] + obj.hits = stats["hits"] + obj.doubles = stats["doubles"] + obj.triples = stats["triples"] + obj.hr = stats["hr"] + obj.rbi = stats["rbi"] + obj.runs = stats["runs"] + obj.bb = stats["bb"] + obj.strikeouts = stats["strikeouts"] + obj.hbp = stats["hbp"] + obj.sac = stats["sac"] + obj.ibb = stats["ibb"] + obj.gidp = stats["gidp"] + obj.sb = stats["sb"] + obj.cs = stats["cs"] + obj.last_game_id = game_id + obj.last_updated_at = now + obj.save() + batters_updated += 1 + + # Recompute and overwrite pitching season stats for each pitcher. + pitchers_updated = 0 + for player_id, team_id in pitching_pairs: + play_stats = _recalc_pitching(player_id, team_id, season) + decision_stats = _recalc_decisions(player_id, team_id, season) + + obj, _ = PitchingSeasonStats.get_or_create( + player_id=player_id, + team_id=team_id, + season=season, + ) + obj.games = play_stats["games"] + obj.games_started = decision_stats["games_started"] + obj.outs = play_stats["outs"] + obj.strikeouts = play_stats["strikeouts"] + obj.bb = play_stats["bb"] + obj.hits_allowed = play_stats["hits_allowed"] + obj.runs_allowed = play_stats["runs_allowed"] + obj.earned_runs = play_stats["earned_runs"] + obj.hr_allowed = play_stats["hr_allowed"] + obj.hbp = play_stats["hbp"] + obj.wild_pitches = play_stats["wild_pitches"] + obj.balks = play_stats["balks"] + obj.wins = decision_stats["wins"] + obj.losses = decision_stats["losses"] + obj.holds = decision_stats["holds"] + obj.saves = decision_stats["saves"] + obj.blown_saves = decision_stats["blown_saves"] + obj.last_game_id = game_id + obj.last_updated_at = now + obj.save() + pitchers_updated += 1 + + logger.info( + "update_season_stats: game_id=%d complete — " + "batters_updated=%d pitchers_updated=%d", + game_id, + batters_updated, + pitchers_updated, + ) + + return { + "game_id": game_id, + "season": season, + "batters_updated": batters_updated, + "pitchers_updated": pitchers_updated, + } diff --git a/migrations/2026-03-17_add_evolution_tables.sql b/migrations/2026-03-17_add_evolution_tables.sql new file mode 100644 index 0000000..1eb768a --- /dev/null +++ b/migrations/2026-03-17_add_evolution_tables.sql @@ -0,0 +1,241 @@ +-- Migration: Add card evolution tables and column extensions +-- Date: 2026-03-17 +-- Issue: WP-04 +-- Purpose: Support the Card Evolution system — creates batting_season_stats +-- and pitching_season_stats for per-player stat accumulation, plus +-- evolution tracks with tier thresholds, per-card evolution state, +-- tier-based stat boosts, and cosmetic unlocks. Also extends the +-- card, battingcard, and pitchingcard tables with variant and +-- image_url columns required by the evolution display layer. +-- +-- Run on dev first, verify with: +-- SELECT count(*) FROM batting_season_stats; +-- SELECT count(*) FROM pitching_season_stats; +-- SELECT count(*) FROM evolution_track; +-- SELECT count(*) FROM evolution_card_state; +-- SELECT count(*) FROM evolution_tier_boost; +-- SELECT count(*) FROM evolution_cosmetic; +-- SELECT column_name FROM information_schema.columns +-- WHERE table_name IN ('card', 'battingcard', 'pitchingcard') +-- AND column_name IN ('variant', 'image_url') +-- ORDER BY table_name, column_name; +-- +-- Rollback: See DROP/ALTER statements at bottom of file + +-- ============================================ +-- FORWARD MIGRATION +-- ============================================ + +BEGIN; + +-- -------------------------------------------- +-- Table 1: batting_season_stats +-- Accumulates per-player per-team per-season +-- batting totals for evolution formula evaluation +-- and leaderboard queries. +-- -------------------------------------------- +CREATE TABLE IF NOT EXISTS batting_season_stats ( + id SERIAL PRIMARY KEY, + player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE, + team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE, + season INTEGER NOT NULL, + games INTEGER NOT NULL DEFAULT 0, + pa INTEGER NOT NULL DEFAULT 0, + ab INTEGER NOT NULL DEFAULT 0, + hits INTEGER NOT NULL DEFAULT 0, + doubles INTEGER NOT NULL DEFAULT 0, + triples INTEGER NOT NULL DEFAULT 0, + hr INTEGER NOT NULL DEFAULT 0, + rbi INTEGER NOT NULL DEFAULT 0, + runs INTEGER NOT NULL DEFAULT 0, + bb INTEGER NOT NULL DEFAULT 0, + strikeouts INTEGER NOT NULL DEFAULT 0, + hbp INTEGER NOT NULL DEFAULT 0, + sac INTEGER NOT NULL DEFAULT 0, + ibb INTEGER NOT NULL DEFAULT 0, + gidp INTEGER NOT NULL DEFAULT 0, + sb INTEGER NOT NULL DEFAULT 0, + cs INTEGER NOT NULL DEFAULT 0, + last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL, + last_updated_at TIMESTAMP +); + +-- One row per player per team per season +CREATE UNIQUE INDEX IF NOT EXISTS batting_season_stats_player_team_season_uniq + ON batting_season_stats (player_id, team_id, season); + +-- Fast lookup by team + season (e.g. leaderboard queries) +CREATE INDEX IF NOT EXISTS batting_season_stats_team_season_idx + ON batting_season_stats (team_id, season); + +-- Fast lookup by player across seasons +CREATE INDEX IF NOT EXISTS batting_season_stats_player_season_idx + ON batting_season_stats (player_id, season); + +-- -------------------------------------------- +-- Table 2: pitching_season_stats +-- Accumulates per-player per-team per-season +-- pitching totals for evolution formula evaluation +-- and leaderboard queries. +-- -------------------------------------------- +CREATE TABLE IF NOT EXISTS pitching_season_stats ( + id SERIAL PRIMARY KEY, + player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE, + team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE, + season INTEGER NOT NULL, + games INTEGER NOT NULL DEFAULT 0, + games_started INTEGER NOT NULL DEFAULT 0, + outs INTEGER NOT NULL DEFAULT 0, + strikeouts INTEGER NOT NULL DEFAULT 0, + bb INTEGER NOT NULL DEFAULT 0, + hits_allowed INTEGER NOT NULL DEFAULT 0, + runs_allowed INTEGER NOT NULL DEFAULT 0, + earned_runs INTEGER NOT NULL DEFAULT 0, + hr_allowed INTEGER NOT NULL DEFAULT 0, + hbp INTEGER NOT NULL DEFAULT 0, + wild_pitches INTEGER NOT NULL DEFAULT 0, + balks INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + holds INTEGER NOT NULL DEFAULT 0, + saves INTEGER NOT NULL DEFAULT 0, + blown_saves INTEGER NOT NULL DEFAULT 0, + last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL, + last_updated_at TIMESTAMP +); + +-- One row per player per team per season +CREATE UNIQUE INDEX IF NOT EXISTS pitching_season_stats_player_team_season_uniq + ON pitching_season_stats (player_id, team_id, season); + +-- Fast lookup by team + season (e.g. leaderboard queries) +CREATE INDEX IF NOT EXISTS pitching_season_stats_team_season_idx + ON pitching_season_stats (team_id, season); + +-- Fast lookup by player across seasons +CREATE INDEX IF NOT EXISTS pitching_season_stats_player_season_idx + ON pitching_season_stats (player_id, season); + +-- -------------------------------------------- +-- Table 3: evolution_track +-- Defines the available evolution tracks +-- (e.g. "HR Mastery", "Ace SP"), their +-- metric formula, and the four tier thresholds. +-- -------------------------------------------- +CREATE TABLE IF NOT EXISTS evolution_track ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + card_type VARCHAR(50) NOT NULL, -- 'batter', 'sp', or 'rp' + formula VARCHAR(255) NOT NULL, -- e.g. 'hr', 'k_per_9', 'ops' + t1_threshold INTEGER NOT NULL, + t2_threshold INTEGER NOT NULL, + t3_threshold INTEGER NOT NULL, + t4_threshold INTEGER NOT NULL +); + +-- -------------------------------------------- +-- Table 4: evolution_card_state +-- Records each card's current evolution tier, +-- running metric value, and the track it +-- belongs to. One state row per card (player +-- + team combination uniquely identifies a +-- card in a given season). +-- -------------------------------------------- +CREATE TABLE IF NOT EXISTS evolution_card_state ( + id SERIAL PRIMARY KEY, + player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE, + team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE, + track_id INTEGER NOT NULL REFERENCES evolution_track(id) ON DELETE CASCADE, + current_tier INTEGER NOT NULL DEFAULT 0, + current_value DOUBLE PRECISION NOT NULL DEFAULT 0.0, + fully_evolved BOOLEAN NOT NULL DEFAULT FALSE, + last_evaluated_at TIMESTAMP +); + +-- One evolution state per card (player + team) +CREATE UNIQUE INDEX IF NOT EXISTS evolution_card_state_player_team_uniq + ON evolution_card_state (player_id, team_id); + +-- -------------------------------------------- +-- Table 5: evolution_tier_boost +-- Defines the stat boosts unlocked at each +-- tier within a track. A single tier may +-- grant multiple boosts (e.g. +1 HR and +-- +1 power rating). +-- -------------------------------------------- +CREATE TABLE IF NOT EXISTS evolution_tier_boost ( + id SERIAL PRIMARY KEY, + track_id INTEGER NOT NULL REFERENCES evolution_track(id) ON DELETE CASCADE, + tier INTEGER NOT NULL, -- 1-4 + boost_type VARCHAR(50) NOT NULL, -- e.g. 'rating_bump', 'display_only' + boost_target VARCHAR(50) NOT NULL, -- e.g. 'hr_rating', 'contact_rating' + boost_value DOUBLE PRECISION NOT NULL DEFAULT 0.0 +); + +-- Prevent duplicate boost definitions for the same track/tier/type/target +CREATE UNIQUE INDEX IF NOT EXISTS evolution_tier_boost_track_tier_type_target_uniq + ON evolution_tier_boost (track_id, tier, boost_type, boost_target); + +-- -------------------------------------------- +-- Table 6: evolution_cosmetic +-- Catalogue of unlockable visual treatments +-- (borders, foils, badges, etc.) tied to +-- minimum tier requirements. +-- -------------------------------------------- +CREATE TABLE IF NOT EXISTS evolution_cosmetic ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + tier_required INTEGER NOT NULL DEFAULT 0, + cosmetic_type VARCHAR(50) NOT NULL, -- e.g. 'border', 'foil', 'badge' + css_class VARCHAR(255), + asset_url VARCHAR(500) +); + +-- -------------------------------------------- +-- Column extensions for existing tables +-- -------------------------------------------- + +-- Track which visual variant a card is displaying +-- (NULL = base card, 1+ = evolved variants) +ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL; + +-- Store pre-rendered or externally-hosted card image URLs +ALTER TABLE battingcard ADD COLUMN IF NOT EXISTS image_url VARCHAR(500); +ALTER TABLE pitchingcard ADD COLUMN IF NOT EXISTS image_url VARCHAR(500); + +COMMIT; + +-- ============================================ +-- VERIFICATION QUERIES +-- ============================================ +-- \d batting_season_stats +-- \d pitching_season_stats +-- \d evolution_track +-- \d evolution_card_state +-- \d evolution_tier_boost +-- \d evolution_cosmetic +-- SELECT indexname FROM pg_indexes +-- WHERE tablename IN ( +-- 'batting_season_stats', +-- 'pitching_season_stats', +-- 'evolution_card_state', +-- 'evolution_tier_boost' +-- ) +-- ORDER BY tablename, indexname; +-- SELECT column_name, data_type FROM information_schema.columns +-- WHERE table_name IN ('card', 'battingcard', 'pitchingcard') +-- AND column_name IN ('variant', 'image_url') +-- ORDER BY table_name, column_name; + +-- ============================================ +-- ROLLBACK (if needed) +-- ============================================ +-- ALTER TABLE pitchingcard DROP COLUMN IF EXISTS image_url; +-- ALTER TABLE battingcard DROP COLUMN IF EXISTS image_url; +-- ALTER TABLE card DROP COLUMN IF EXISTS variant; +-- DROP TABLE IF EXISTS evolution_cosmetic CASCADE; +-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE; +-- DROP TABLE IF EXISTS evolution_card_state CASCADE; +-- DROP TABLE IF EXISTS evolution_track CASCADE; +-- DROP TABLE IF EXISTS pitching_season_stats CASCADE; +-- DROP TABLE IF EXISTS batting_season_stats CASCADE; diff --git a/migrations/2026-03-18_add_processed_game.sql b/migrations/2026-03-18_add_processed_game.sql new file mode 100644 index 0000000..c338e54 --- /dev/null +++ b/migrations/2026-03-18_add_processed_game.sql @@ -0,0 +1,26 @@ +-- Migration: Add processed_game ledger for full update_season_stats() idempotency +-- Date: 2026-03-18 +-- Issue: #105 +-- Purpose: Replace the last_game FK check in update_season_stats() with an +-- atomic INSERT into processed_game. This prevents out-of-order +-- re-delivery (game G re-delivered after G+1 was already processed) +-- from bypassing the guard and double-counting stats. + +BEGIN; + +CREATE TABLE IF NOT EXISTS processed_game ( + game_id INTEGER PRIMARY KEY REFERENCES stratgame(id) ON DELETE CASCADE, + processed_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +COMMIT; + +-- ============================================ +-- VERIFICATION QUERIES +-- ============================================ +-- \d processed_game + +-- ============================================ +-- ROLLBACK (if needed) +-- ============================================ +-- DROP TABLE IF EXISTS processed_game; diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..0dbfb5e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,3 @@ +[lint] +# db_engine.py uses `from peewee import *` intentionally — suppress star-import warnings +ignore = ["F403", "F405"] diff --git a/tests/conftest.py b/tests/conftest.py index 8d61378..22b3d10 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,212 @@ -"""Pytest configuration for the paper-dynasty-database test suite. +""" +Shared test fixtures for the Paper Dynasty database test suite. -Sets DATABASE_TYPE=postgresql before any app module is imported so that -db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the -production SQLite file during test collection. Each test module is -responsible for binding models to its own in-memory database. +Uses in-memory SQLite with foreign_keys pragma enabled. Each test +gets a fresh set of tables via the setup_test_db fixture (autouse). + +All models are bound to the in-memory database before table creation +so that no connection to the real storage/pd_master.db occurs during +tests. """ import os +import pytest +import psycopg2 +from peewee import SqliteDatabase +# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION +# flag is True. This prevents db_engine.py from calling create_tables() +# against the real storage/pd_master.db during import — those calls would +# fail if indexes already exist and would also contaminate the dev database. +# The PooledPostgresqlDatabase object is created but never actually connects +# because our fixture rebinds all models to an in-memory SQLite db before +# any query is executed. os.environ["DATABASE_TYPE"] = "postgresql" # Provide dummy credentials so PooledPostgresqlDatabase can be instantiated # without raising a configuration error (it will not actually be used). os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy") + +from app.db_engine import ( + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, + ScoutOpportunity, + ScoutClaim, +) + +_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1}) + +# All models in dependency order (parents before children) so that +# create_tables and drop_tables work without FK violations. +_TEST_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + ScoutOpportunity, + ScoutClaim, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +] + + +@pytest.fixture(autouse=True) +def setup_test_db(): + """Bind all models to in-memory SQLite and create tables. + + The fixture is autouse so every test automatically gets a fresh, + isolated database schema without needing to request it explicitly. + Tables are dropped in reverse dependency order after each test to + keep the teardown clean and to catch any accidental FK reference + direction bugs early. + """ + _test_db.bind(_TEST_MODELS) + _test_db.connect() + _test_db.create_tables(_TEST_MODELS) + yield _test_db + _test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True) + _test_db.close() + + +# --------------------------------------------------------------------------- +# Minimal shared fixtures — create just enough data for FK dependencies +# --------------------------------------------------------------------------- + + +@pytest.fixture +def rarity(): + """A single Common rarity row used as FK seed for Player rows.""" + return Rarity.create(value=1, name="Common", color="#ffffff") + + +@pytest.fixture +def player(rarity): + """A minimal Player row with all required (non-nullable) columns filled. + + Player.p_name is the real column name (not 'name'). All FK and + non-nullable varchar fields are provided so SQLite's NOT NULL + constraints are satisfied even with foreign_keys=ON. + """ + cardset = Cardset.create( + name="Test Set", + description="Test cardset", + total_cards=100, + ) + return Player.create( + p_name="Test Player", + rarity=rarity, + cardset=cardset, + set_num=1, + pos_1="1B", + image="https://example.com/image.png", + mlbclub="TST", + franchise="TST", + description="A test player", + ) + + +@pytest.fixture +def team(): + """A minimal Team row. + + Team uses abbrev/lname/sname/gmid/gmname/gsheet/wallet/team_value/ + collection_value — not the 'name'/'user_id' shorthand described in + the spec, which referred to the real underlying columns by + simplified names. + """ + return Team.create( + abbrev="TST", + sname="Test", + lname="Test Team", + gmid=100000001, + gmname="testuser", + gsheet="https://docs.google.com/spreadsheets/test", + wallet=500, + team_value=1000, + collection_value=1000, + season=11, + is_ai=False, + ) + + +@pytest.fixture +def track(): + """A minimal EvolutionTrack for batter cards.""" + return EvolutionTrack.create( + name="Batter Track", + card_type="batter", + formula="pa + tb * 2", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + + +# --------------------------------------------------------------------------- +# PostgreSQL integration fixture (used by test_evolution_*_api.py) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def pg_conn(): + """Open a psycopg2 connection to the PostgreSQL instance for integration tests. + + Reads connection parameters from the standard POSTGRES_* env vars that the + CI workflow injects when a postgres service container is running. Skips the + entire session (via pytest.skip) when POSTGRES_HOST is not set, keeping + local runs clean. + + The connection is shared for the whole session (scope="session") because + the integration test modules use module-scoped fixtures that rely on it; + creating a new connection per test would break those module-scoped fixtures. + + Teardown: the connection is closed once all tests have finished. + """ + host = os.environ.get("POSTGRES_HOST") + if not host: + pytest.skip("POSTGRES_HOST not set — PostgreSQL integration tests skipped") + + conn = psycopg2.connect( + host=host, + port=int(os.environ.get("POSTGRES_PORT", "5432")), + dbname=os.environ.get("POSTGRES_DB", "paper_dynasty"), + user=os.environ.get("POSTGRES_USER", "postgres"), + password=os.environ.get("POSTGRES_PASSWORD", ""), + ) + conn.autocommit = False + yield conn + conn.close() diff --git a/tests/test_evolution_evaluator.py b/tests/test_evolution_evaluator.py new file mode 100644 index 0000000..abbefdf --- /dev/null +++ b/tests/test_evolution_evaluator.py @@ -0,0 +1,361 @@ +"""Tests for the evolution evaluator service (WP-08). + +Unit tests verify tier assignment, advancement, partial progress, idempotency, +full evolution, and no-regression behaviour without touching any database, +using stub Peewee models bound to an in-memory SQLite database. + +The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported +from db_engine/formula_engine; instead the tests supply minimal stubs and +inject them via the _stats_model, _state_model, _compute_value_fn, and +_tier_from_value_fn overrides on evaluate_card(). + +Stub track thresholds (batter): + T1: 37 T2: 149 T3: 448 T4: 896 + +Useful reference values: + value=30 → T0 (below T1=37) + value=50 → T1 (37 <= 50 < 149) + value=100 → T1 (stays T1; T2 threshold is 149) + value=160 → T2 (149 <= 160 < 448) + value=900 → T4 (>= 896) → fully_evolved +""" + +import pytest +from datetime import datetime +from peewee import ( + BooleanField, + CharField, + DateTimeField, + FloatField, + ForeignKeyField, + IntegerField, + Model, + SqliteDatabase, +) + +from app.services.evolution_evaluator import evaluate_card + +# --------------------------------------------------------------------------- +# Stub models — mirror WP-01/WP-04/WP-07 schema without importing db_engine +# --------------------------------------------------------------------------- + +_test_db = SqliteDatabase(":memory:") + + +class TrackStub(Model): + """Minimal EvolutionTrack stub for evaluator tests.""" + + card_type = CharField(unique=True) + t1_threshold = IntegerField() + t2_threshold = IntegerField() + t3_threshold = IntegerField() + t4_threshold = IntegerField() + + class Meta: + database = _test_db + table_name = "evolution_track" + + +class CardStateStub(Model): + """Minimal EvolutionCardState stub for evaluator tests.""" + + player_id = IntegerField() + team_id = IntegerField() + track = ForeignKeyField(TrackStub) + current_tier = IntegerField(default=0) + current_value = FloatField(default=0.0) + fully_evolved = BooleanField(default=False) + last_evaluated_at = DateTimeField(null=True) + + class Meta: + database = _test_db + table_name = "evolution_card_state" + indexes = ((("player_id", "team_id"), True),) + + +class StatsStub(Model): + """Minimal PlayerSeasonStats stub for evaluator tests.""" + + player_id = IntegerField() + team_id = IntegerField() + season = IntegerField() + pa = IntegerField(default=0) + hits = IntegerField(default=0) + doubles = IntegerField(default=0) + triples = IntegerField(default=0) + hr = IntegerField(default=0) + outs = IntegerField(default=0) + strikeouts = IntegerField(default=0) + + class Meta: + database = _test_db + table_name = "player_season_stats" + + +# --------------------------------------------------------------------------- +# Formula stubs — avoid importing app.services.formula_engine before WP-09 +# --------------------------------------------------------------------------- + + +def _compute_value(card_type: str, stats) -> float: + """Stub compute_value_for_track: returns pa for batter, outs/3+k for pitchers.""" + if card_type == "batter": + singles = stats.hits - stats.doubles - stats.triples - stats.hr + tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr + return float(stats.pa + tb * 2) + return stats.outs / 3 + stats.strikeouts + + +def _tier_from_value(value: float, track) -> int: + """Stub tier_from_value using TrackStub fields t1_threshold/t2_threshold/etc.""" + if isinstance(track, dict): + t1, t2, t3, t4 = ( + track["t1_threshold"], + track["t2_threshold"], + track["t3_threshold"], + track["t4_threshold"], + ) + else: + t1, t2, t3, t4 = ( + track.t1_threshold, + track.t2_threshold, + track.t3_threshold, + track.t4_threshold, + ) + if value >= t4: + return 4 + if value >= t3: + return 3 + if value >= t2: + return 2 + if value >= t1: + return 1 + return 0 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _db(): + """Create tables before each test and drop them afterwards.""" + _test_db.connect(reuse_if_open=True) + _test_db.create_tables([TrackStub, CardStateStub, StatsStub]) + yield + _test_db.drop_tables([StatsStub, CardStateStub, TrackStub]) + + +@pytest.fixture() +def batter_track(): + return TrackStub.create( + card_type="batter", + t1_threshold=37, + t2_threshold=149, + t3_threshold=448, + t4_threshold=896, + ) + + +@pytest.fixture() +def sp_track(): + return TrackStub.create( + card_type="sp", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + + +def _make_state(player_id, team_id, track, current_tier=0, current_value=0.0): + return CardStateStub.create( + player_id=player_id, + team_id=team_id, + track=track, + current_tier=current_tier, + current_value=current_value, + fully_evolved=False, + last_evaluated_at=None, + ) + + +def _make_stats(player_id, team_id, season, **kwargs): + return StatsStub.create( + player_id=player_id, team_id=team_id, season=season, **kwargs + ) + + +def _eval(player_id, team_id): + return evaluate_card( + player_id, + team_id, + _stats_model=StatsStub, + _state_model=CardStateStub, + _compute_value_fn=_compute_value, + _tier_from_value_fn=_tier_from_value, + ) + + +# --------------------------------------------------------------------------- +# Unit tests +# --------------------------------------------------------------------------- + + +class TestTierAssignment: + """Tier assigned from computed value against track thresholds.""" + + def test_value_below_t1_stays_t0(self, batter_track): + """value=30 is below T1 threshold (37) → tier stays 0.""" + _make_state(1, 1, batter_track) + # pa=30, no extra hits → value = 30 + 0 = 30 < 37 + _make_stats(1, 1, 1, pa=30) + result = _eval(1, 1) + assert result["current_tier"] == 0 + + def test_value_at_t1_threshold_assigns_tier_1(self, batter_track): + """value=50 → T1 (37 <= 50 < 149).""" + _make_state(1, 1, batter_track) + # pa=50, no hits → value = 50 + 0 = 50 + _make_stats(1, 1, 1, pa=50) + result = _eval(1, 1) + assert result["current_tier"] == 1 + + def test_tier_advancement_to_t2(self, batter_track): + """value=160 → T2 (149 <= 160 < 448).""" + _make_state(1, 1, batter_track) + # pa=160, no hits → value = 160 + _make_stats(1, 1, 1, pa=160) + result = _eval(1, 1) + assert result["current_tier"] == 2 + + def test_partial_progress_stays_t1(self, batter_track): + """value=100 with T2=149 → stays T1, does not advance to T2.""" + _make_state(1, 1, batter_track) + # pa=100 → value = 100, T2 threshold = 149 → tier 1 + _make_stats(1, 1, 1, pa=100) + result = _eval(1, 1) + assert result["current_tier"] == 1 + assert result["fully_evolved"] is False + + def test_fully_evolved_at_t4(self, batter_track): + """value >= T4 (896) → tier=4 and fully_evolved=True.""" + _make_state(1, 1, batter_track) + # pa=900 → value = 900 >= 896 + _make_stats(1, 1, 1, pa=900) + result = _eval(1, 1) + assert result["current_tier"] == 4 + assert result["fully_evolved"] is True + + +class TestNoRegression: + """current_tier never decreases.""" + + def test_tier_never_decreases(self, batter_track): + """If current_tier=2 and new value only warrants T1, tier stays 2.""" + # Seed state at tier 2 + _make_state(1, 1, batter_track, current_tier=2, current_value=160.0) + # Sparse stats: value=50 → would be T1, but current is T2 + _make_stats(1, 1, 1, pa=50) + result = _eval(1, 1) + assert result["current_tier"] == 2 # no regression + + def test_tier_advances_when_value_improves(self, batter_track): + """If current_tier=1 and new value warrants T3, tier advances to 3.""" + _make_state(1, 1, batter_track, current_tier=1, current_value=50.0) + # pa=500 → value = 500 >= 448 → T3 + _make_stats(1, 1, 1, pa=500) + result = _eval(1, 1) + assert result["current_tier"] == 3 + + +class TestIdempotency: + """Calling evaluate_card twice with same stats returns the same result.""" + + def test_idempotent_same_result(self, batter_track): + """Two evaluations with identical stats produce the same tier and value.""" + _make_state(1, 1, batter_track) + _make_stats(1, 1, 1, pa=160) + result1 = _eval(1, 1) + result2 = _eval(1, 1) + assert result1["current_tier"] == result2["current_tier"] + assert result1["current_value"] == result2["current_value"] + assert result1["fully_evolved"] == result2["fully_evolved"] + + def test_idempotent_at_fully_evolved(self, batter_track): + """Repeated evaluation at T4 remains fully_evolved=True.""" + _make_state(1, 1, batter_track) + _make_stats(1, 1, 1, pa=900) + _eval(1, 1) + result = _eval(1, 1) + assert result["current_tier"] == 4 + assert result["fully_evolved"] is True + + +class TestCareerTotals: + """Stats are summed across all seasons for the player/team pair.""" + + def test_multi_season_stats_summed(self, batter_track): + """Stats from two seasons are aggregated into a single career total.""" + _make_state(1, 1, batter_track) + # Season 1: pa=80, Season 2: pa=90 → total pa=170 → value=170 → T2 + _make_stats(1, 1, 1, pa=80) + _make_stats(1, 1, 2, pa=90) + result = _eval(1, 1) + assert result["current_tier"] == 2 + assert result["current_value"] == 170.0 + + def test_zero_stats_stays_t0(self, batter_track): + """No stats rows → all zeros → value=0 → tier=0.""" + _make_state(1, 1, batter_track) + result = _eval(1, 1) + assert result["current_tier"] == 0 + assert result["current_value"] == 0.0 + + def test_other_team_stats_not_included(self, batter_track): + """Stats for the same player on a different team are not counted.""" + _make_state(1, 1, batter_track) + _make_stats(1, 1, 1, pa=50) + # Same player, different team — should not count + _make_stats(1, 2, 1, pa=200) + result = _eval(1, 1) + # Only pa=50 counted → value=50 → T1 + assert result["current_tier"] == 1 + assert result["current_value"] == 50.0 + + +class TestMissingState: + """ValueError when no card state exists for (player_id, team_id).""" + + def test_missing_state_raises(self, batter_track): + """evaluate_card raises ValueError when no state row exists.""" + # No card state created + with pytest.raises(ValueError, match="No evolution_card_state"): + _eval(99, 99) + + +class TestReturnShape: + """Return dict has the expected keys and types.""" + + def test_return_keys(self, batter_track): + """Result dict contains all expected keys.""" + _make_state(1, 1, batter_track) + result = _eval(1, 1) + assert set(result.keys()) == { + "player_id", + "team_id", + "current_tier", + "current_value", + "fully_evolved", + "last_evaluated_at", + } + + def test_last_evaluated_at_is_iso_string(self, batter_track): + """last_evaluated_at is a non-empty ISO-8601 string.""" + _make_state(1, 1, batter_track) + result = _eval(1, 1) + ts = result["last_evaluated_at"] + assert isinstance(ts, str) and len(ts) > 0 + # Must be parseable as a datetime + datetime.fromisoformat(ts) diff --git a/tests/test_evolution_init.py b/tests/test_evolution_init.py new file mode 100644 index 0000000..cfbabb0 --- /dev/null +++ b/tests/test_evolution_init.py @@ -0,0 +1,326 @@ +""" +Tests for WP-10: evolution_card_state initialization on pack opening. + +Covers `app/services/evolution_init.py` — the `initialize_card_evolution` +function that creates an EvolutionCardState row when a card is first acquired. + +Test strategy: + - Unit tests for `_determine_card_type` cover all three branches (batter, + SP, RP/CP) using plain objects so no database round-trip is needed. + - Integration tests run against the in-memory SQLite database (conftest.py + autouse fixture) and exercise the full get_or_create path. + +Why we test idempotency: + Pack-opening can post duplicate cards (e.g. the same player ID appears in + two separate pack insertions). The get_or_create guarantee means the second + call must be a no-op — it must not reset current_tier/current_value of a + card that has already started evolving. + +Why we test cross-player isolation: + Two different players with the same team must each get their own + EvolutionCardState row. A bug that checked only team_id would share state + across players, so we assert that state.player_id matches. +""" + +import pytest + +from app.db_engine import ( + Cardset, + EvolutionCardState, + EvolutionTrack, + Player, +) +from app.services.evolution_init import _determine_card_type, initialize_card_evolution + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakePlayer: + """Minimal stand-in for a Player instance used in unit tests. + + We only need pos_1 for card-type determination; real FK fields are + not required by the pure function under test. + """ + + def __init__(self, pos_1: str): + self.pos_1 = pos_1 + + +def _make_player(rarity, pos_1: str) -> Player: + """Create a minimal Player row with the given pos_1 value. + + A fresh Cardset is created per call so that players are independent + of each other and can be iterated over in separate test cases without + FK conflicts. + """ + cardset = Cardset.create( + name=f"Set-{pos_1}-{id(pos_1)}", + description="Test", + total_cards=1, + ) + return Player.create( + p_name=f"Player {pos_1}", + rarity=rarity, + cardset=cardset, + set_num=1, + pos_1=pos_1, + image="https://example.com/img.png", + mlbclub="TST", + franchise="TST", + description="test", + ) + + +def _make_track(card_type: str) -> EvolutionTrack: + """Create an EvolutionTrack for the given card_type. + + Thresholds are kept small and arbitrary; the unit under test only + cares about card_type when selecting the track. + """ + return EvolutionTrack.create( + name=f"Track-{card_type}", + card_type=card_type, + formula="pa", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + + +# --------------------------------------------------------------------------- +# Unit tests — _determine_card_type (no DB required) +# --------------------------------------------------------------------------- + + +class TestDetermineCardType: + """Unit tests for _determine_card_type, the pure position-to-type mapper. + + The function receives a Player (or any object with a pos_1 attribute) and + returns one of the three strings 'batter', 'sp', or 'rp'. These unit + tests use _FakePlayer so no database is touched and failures are fast. + """ + + def test_starting_pitcher(self): + """pos_1 == 'SP' maps to card_type 'sp'. + + SP is the canonical starting-pitcher position string stored in + Player.pos_1 by the card-creation pipeline. + """ + assert _determine_card_type(_FakePlayer("SP")) == "sp" + + def test_relief_pitcher(self): + """pos_1 == 'RP' maps to card_type 'rp'. + + Relief pitchers carry the 'RP' position flag and must follow a + separate evolution track with lower thresholds. + """ + assert _determine_card_type(_FakePlayer("RP")) == "rp" + + def test_closer_pitcher(self): + """pos_1 == 'CP' maps to card_type 'rp'. + + Closers share the RP evolution track; the spec explicitly lists 'CP' + as an rp-track position. + """ + assert _determine_card_type(_FakePlayer("CP")) == "rp" + + def test_infielder_is_batter(self): + """pos_1 == '1B' maps to card_type 'batter'. + + Any non-pitcher position (1B, 2B, 3B, SS, OF, C, DH, etc.) should + fall through to the batter track. + """ + assert _determine_card_type(_FakePlayer("1B")) == "batter" + + def test_catcher_is_batter(self): + """pos_1 == 'C' maps to card_type 'batter'.""" + assert _determine_card_type(_FakePlayer("C")) == "batter" + + def test_dh_is_batter(self): + """pos_1 == 'DH' maps to card_type 'batter'. + + Designated hitters have no defensive rating but accumulate batting + stats, so they belong on the batter track. + """ + assert _determine_card_type(_FakePlayer("DH")) == "batter" + + def test_outfielder_is_batter(self): + """pos_1 == 'CF' maps to card_type 'batter'.""" + assert _determine_card_type(_FakePlayer("CF")) == "batter" + + +# --------------------------------------------------------------------------- +# Integration tests — initialize_card_evolution +# --------------------------------------------------------------------------- + + +class TestInitializeCardEvolution: + """Integration tests for initialize_card_evolution against in-memory SQLite. + + Each test relies on the conftest autouse fixture to get a clean database. + We create tracks for all three card types so the function can always find + a matching track regardless of which player position is used. + """ + + @pytest.fixture(autouse=True) + def seed_tracks(self): + """Create one EvolutionTrack per card_type before each test. + + initialize_card_evolution does a DB lookup for a track matching the + card_type. If no track exists the function must not crash (it should + log and return None), but having tracks present lets us verify the + happy path for all three types without repeating setup in every test. + """ + self.batter_track = _make_track("batter") + self.sp_track = _make_track("sp") + self.rp_track = _make_track("rp") + + def test_first_card_creates_state(self, rarity, team): + """First acquisition creates an EvolutionCardState with zeroed values. + + Acceptance criteria from WP-10: + - current_tier == 0 + - current_value == 0.0 + - fully_evolved == False + - track matches the player's card_type (batter here) + """ + player = _make_player(rarity, "2B") + state = initialize_card_evolution(player.player_id, team.id, "batter") + + assert state is not None + assert state.player_id == player.player_id + assert state.team_id == team.id + assert state.track_id == self.batter_track.id + assert state.current_tier == 0 + assert state.current_value == 0.0 + assert state.fully_evolved is False + + def test_duplicate_card_skips_creation(self, rarity, team): + """Second call for the same (player_id, team_id) is a no-op. + + The get_or_create guarantee: if a state row already exists it must + not be overwritten. This protects cards that have already started + evolving — their current_tier and current_value must be preserved. + """ + player = _make_player(rarity, "SS") + # First call creates the state + state1 = initialize_card_evolution(player.player_id, team.id, "batter") + assert state1 is not None + + # Simulate partial evolution progress + state1.current_tier = 2 + state1.current_value = 250.0 + state1.save() + + # Second call (duplicate card) must not reset progress + state2 = initialize_card_evolution(player.player_id, team.id, "batter") + assert state2 is not None + + # Exactly one row in the database + count = ( + EvolutionCardState.select() + .where( + EvolutionCardState.player == player, + EvolutionCardState.team == team, + ) + .count() + ) + assert count == 1 + + # Progress was NOT reset + refreshed = EvolutionCardState.get_by_id(state1.id) + assert refreshed.current_tier == 2 + assert refreshed.current_value == 250.0 + + def test_different_player_creates_new_state(self, rarity, team): + """Two different players on the same team each get their own state row. + + Cross-player isolation: the (player_id, team_id) uniqueness means + player A and player B must have separate rows even though team_id is + the same. + """ + player_a = _make_player(rarity, "LF") + player_b = _make_player(rarity, "RF") + + state_a = initialize_card_evolution(player_a.player_id, team.id, "batter") + state_b = initialize_card_evolution(player_b.player_id, team.id, "batter") + + assert state_a is not None + assert state_b is not None + assert state_a.id != state_b.id + assert state_a.player_id == player_a.player_id + assert state_b.player_id == player_b.player_id + + def test_sp_card_gets_sp_track(self, rarity, team): + """A starting pitcher is assigned the 'sp' EvolutionTrack. + + Track selection is driven by card_type, which in turn comes from + pos_1. This test passes card_type='sp' explicitly (mirroring the + router hook that calls _determine_card_type first) and confirms the + state links to the sp track, not the batter track. + """ + player = _make_player(rarity, "SP") + state = initialize_card_evolution(player.player_id, team.id, "sp") + + assert state is not None + assert state.track_id == self.sp_track.id + + def test_rp_card_gets_rp_track(self, rarity, team): + """A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack.""" + player = _make_player(rarity, "RP") + state = initialize_card_evolution(player.player_id, team.id, "rp") + + assert state is not None + assert state.track_id == self.rp_track.id + + def test_missing_track_returns_none(self, rarity, team): + """If no track exists for the card_type, the function returns None. + + This is the safe-failure path: the function must not raise an + exception if the evolution system is misconfigured (e.g. track seed + data missing). It logs the problem and returns None so that the + caller (the cards router) can proceed with pack opening unaffected. + + We use a fictional card_type that has no matching seed row. + """ + player = _make_player(rarity, "SP") + # Delete the sp track to simulate missing seed data + self.sp_track.delete_instance() + + result = initialize_card_evolution(player.player_id, team.id, "sp") + assert result is None + + def test_card_type_from_pos1_batter(self, rarity, team): + """_determine_card_type is wired correctly for a batter position. + + End-to-end: pass the player object directly and verify the state + ends up on the batter track based solely on pos_1. + """ + player = _make_player(rarity, "3B") + card_type = _determine_card_type(player) + state = initialize_card_evolution(player.player_id, team.id, card_type) + + assert state is not None + assert state.track_id == self.batter_track.id + + def test_card_type_from_pos1_sp(self, rarity, team): + """_determine_card_type is wired correctly for a starting pitcher.""" + player = _make_player(rarity, "SP") + card_type = _determine_card_type(player) + state = initialize_card_evolution(player.player_id, team.id, card_type) + + assert state is not None + assert state.track_id == self.sp_track.id + + def test_card_type_from_pos1_rp(self, rarity, team): + """_determine_card_type correctly routes CP to the rp track.""" + player = _make_player(rarity, "CP") + card_type = _determine_card_type(player) + state = initialize_card_evolution(player.player_id, team.id, card_type) + + assert state is not None + assert state.track_id == self.rp_track.id diff --git a/tests/test_evolution_models.py b/tests/test_evolution_models.py new file mode 100644 index 0000000..4479b9f --- /dev/null +++ b/tests/test_evolution_models.py @@ -0,0 +1,328 @@ +""" +Tests for evolution-related models and BattingSeasonStats. + +Covers WP-01 acceptance criteria: + - EvolutionTrack: CRUD and unique-name constraint + - EvolutionCardState: CRUD, defaults, unique-(player,team) constraint, + and FK resolution back to EvolutionTrack + - EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target) + - EvolutionCosmetic: CRUD and unique-name constraint + - BattingSeasonStats: CRUD with defaults, unique-(player, team, season), + and in-place stat accumulation + +Each test class is self-contained: fixtures from conftest.py supply the +minimal parent rows needed to satisfy FK constraints, and every assertion +targets a single, clearly-named behaviour so failures are easy to trace. +""" + +import pytest +from peewee import IntegrityError +from playhouse.shortcuts import model_to_dict + +from app.db_engine import ( + BattingSeasonStats, + EvolutionCardState, + EvolutionCosmetic, + EvolutionTierBoost, + EvolutionTrack, +) + +# --------------------------------------------------------------------------- +# EvolutionTrack +# --------------------------------------------------------------------------- + + +class TestEvolutionTrack: + """Tests for the EvolutionTrack model. + + EvolutionTrack defines a named progression path (formula + + tier thresholds) for a card type. The name column carries a + UNIQUE constraint so that accidental duplicates are caught at + the database level. + """ + + def test_create_track(self, track): + """Creating a track persists all fields and they round-trip correctly. + + Reads back via model_to_dict (recurse=False) to verify the raw + column values, not Python-object representations, match what was + inserted. + """ + data = model_to_dict(track, recurse=False) + assert data["name"] == "Batter Track" + assert data["card_type"] == "batter" + assert data["formula"] == "pa + tb * 2" + assert data["t1_threshold"] == 37 + assert data["t2_threshold"] == 149 + assert data["t3_threshold"] == 448 + assert data["t4_threshold"] == 896 + + def test_track_unique_name(self, track): + """Inserting a second track with the same name raises IntegrityError. + + The UNIQUE constraint on EvolutionTrack.name must prevent two + tracks from sharing the same identifier, as the name is used as + a human-readable key throughout the evolution system. + """ + with pytest.raises(IntegrityError): + EvolutionTrack.create( + name="Batter Track", # duplicate + card_type="sp", + formula="outs * 3", + t1_threshold=10, + t2_threshold=40, + t3_threshold=120, + t4_threshold=240, + ) + + +# --------------------------------------------------------------------------- +# EvolutionCardState +# --------------------------------------------------------------------------- + + +class TestEvolutionCardState: + """Tests for EvolutionCardState, which tracks per-player evolution progress. + + Each row represents one card (player) owned by one team, linked to a + specific EvolutionTrack. The model records the current tier (0-4), + accumulated progress value, and whether the card is fully evolved. + """ + + def test_create_card_state(self, player, team, track): + """Creating a card state stores all fields and defaults are correct. + + Defaults under test: + current_tier → 0 (fresh card, no tier unlocked yet) + current_value → 0.0 (no formula progress accumulated) + fully_evolved → False (evolution is not complete at creation) + last_evaluated_at → None (never evaluated yet) + """ + state = EvolutionCardState.create(player=player, team=team, track=track) + + fetched = EvolutionCardState.get_by_id(state.id) + assert fetched.player_id == player.player_id + assert fetched.team_id == team.id + assert fetched.track_id == track.id + assert fetched.current_tier == 0 + assert fetched.current_value == 0.0 + assert fetched.fully_evolved is False + assert fetched.last_evaluated_at is None + + def test_card_state_unique_player_team(self, player, team, track): + """A second card state for the same (player, team) pair raises IntegrityError. + + The unique index on (player, team) enforces that each player card + has at most one evolution state per team roster slot, preventing + duplicate evolution progress rows for the same physical card. + """ + EvolutionCardState.create(player=player, team=team, track=track) + with pytest.raises(IntegrityError): + EvolutionCardState.create(player=player, team=team, track=track) + + def test_card_state_fk_track(self, player, team, track): + """Accessing card_state.track returns the original EvolutionTrack instance. + + This confirms the FK is correctly wired and that Peewee resolves + the relationship, returning an object with the same primary key and + name as the track used during creation. + """ + state = EvolutionCardState.create(player=player, team=team, track=track) + fetched = EvolutionCardState.get_by_id(state.id) + resolved_track = fetched.track + assert resolved_track.id == track.id + assert resolved_track.name == "Batter Track" + + +# --------------------------------------------------------------------------- +# EvolutionTierBoost +# --------------------------------------------------------------------------- + + +class TestEvolutionTierBoost: + """Tests for EvolutionTierBoost, the per-tier stat/rating bonus table. + + Each row maps a (track, tier) combination to a single boost — the + specific stat or rating column to buff and by how much. The four- + column unique constraint prevents double-booking the same boost slot. + """ + + def test_create_tier_boost(self, track): + """Creating a boost row persists all fields accurately. + + Verifies boost_type, boost_target, and boost_value are stored + and retrieved without modification. + """ + boost = EvolutionTierBoost.create( + track=track, + tier=1, + boost_type="rating", + boost_target="contact_vl", + boost_value=1.5, + ) + fetched = EvolutionTierBoost.get_by_id(boost.id) + assert fetched.track_id == track.id + assert fetched.tier == 1 + assert fetched.boost_type == "rating" + assert fetched.boost_target == "contact_vl" + assert fetched.boost_value == 1.5 + + def test_tier_boost_unique_constraint(self, track): + """Duplicate (track, tier, boost_type, boost_target) raises IntegrityError. + + The four-column unique index ensures that a single boost slot + (e.g. Tier-1 contact_vl rating) cannot be defined twice for the + same track, which would create ambiguity during evolution evaluation. + """ + EvolutionTierBoost.create( + track=track, + tier=2, + boost_type="rating", + boost_target="power_vr", + boost_value=2.0, + ) + with pytest.raises(IntegrityError): + EvolutionTierBoost.create( + track=track, + tier=2, + boost_type="rating", + boost_target="power_vr", + boost_value=3.0, # different value, same identity columns + ) + + +# --------------------------------------------------------------------------- +# EvolutionCosmetic +# --------------------------------------------------------------------------- + + +class TestEvolutionCosmetic: + """Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers. + + Cosmetics are purely visual rewards (frames, badges, themes) that a + card unlocks when it reaches a required tier. The name column is + the stable identifier and carries a UNIQUE constraint. + """ + + def test_create_cosmetic(self): + """Creating a cosmetic persists all fields correctly. + + Verifies all columns including optional ones (css_class, asset_url) + are stored and retrieved. + """ + cosmetic = EvolutionCosmetic.create( + name="Gold Frame", + tier_required=2, + cosmetic_type="frame", + css_class="evo-frame-gold", + asset_url="https://cdn.example.com/frames/gold.png", + ) + fetched = EvolutionCosmetic.get_by_id(cosmetic.id) + assert fetched.name == "Gold Frame" + assert fetched.tier_required == 2 + assert fetched.cosmetic_type == "frame" + assert fetched.css_class == "evo-frame-gold" + assert fetched.asset_url == "https://cdn.example.com/frames/gold.png" + + def test_cosmetic_unique_name(self): + """Inserting a second cosmetic with the same name raises IntegrityError. + + The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate + cosmetic definitions that could cause ambiguous tier unlock lookups. + """ + EvolutionCosmetic.create( + name="Silver Badge", + tier_required=1, + cosmetic_type="badge", + ) + with pytest.raises(IntegrityError): + EvolutionCosmetic.create( + name="Silver Badge", # duplicate + tier_required=3, + cosmetic_type="badge", + ) + + +# --------------------------------------------------------------------------- +# BattingSeasonStats +# --------------------------------------------------------------------------- + + +class TestBattingSeasonStats: + """Tests for BattingSeasonStats, the per-season batting accumulation table. + + Each row aggregates game-by-game batting stats for one player on one + team in one season. The three-column unique constraint prevents + double-counting and ensures a single authoritative row for each + (player, team, season) combination. + """ + + def test_create_season_stats(self, player, team): + """Creating a stats row with explicit values stores everything correctly. + + Also verifies the integer stat defaults (all 0) for columns that + are not provided, which is the initial state before any games are + processed. + """ + stats = BattingSeasonStats.create( + player=player, + team=team, + season=11, + games=5, + pa=20, + ab=18, + hits=6, + doubles=1, + triples=0, + hr=2, + bb=2, + hbp=0, + strikeouts=4, + rbi=5, + runs=3, + sb=1, + cs=0, + ) + fetched = BattingSeasonStats.get_by_id(stats.id) + assert fetched.player_id == player.player_id + assert fetched.team_id == team.id + assert fetched.season == 11 + assert fetched.games == 5 + assert fetched.pa == 20 + assert fetched.hits == 6 + assert fetched.hr == 2 + assert fetched.strikeouts == 4 + # Nullable meta fields + assert fetched.last_game is None + assert fetched.last_updated_at is None + + def test_season_stats_unique_constraint(self, player, team): + """A second row for the same (player, team, season) raises IntegrityError. + + The unique index on these three columns guarantees that each + player-team-season combination has exactly one accumulation row, + preventing duplicate stat aggregation that would inflate totals. + """ + BattingSeasonStats.create(player=player, team=team, season=11) + with pytest.raises(IntegrityError): + BattingSeasonStats.create(player=player, team=team, season=11) + + def test_season_stats_increment(self, player, team): + """Manually incrementing hits on an existing row persists the change. + + Simulates the common pattern used by the stats accumulator: + fetch the row, add the game delta, save. Verifies that save() + writes back to the database and that subsequent reads reflect the + updated value. + """ + stats = BattingSeasonStats.create( + player=player, + team=team, + season=11, + hits=10, + ) + stats.hits += 3 + stats.save() + + refreshed = BattingSeasonStats.get_by_id(stats.id) + assert refreshed.hits == 13 diff --git a/tests/test_evolution_seed.py b/tests/test_evolution_seed.py index 8aed49c..a3d1842 100644 --- a/tests/test_evolution_seed.py +++ b/tests/test_evolution_seed.py @@ -1,119 +1,159 @@ -"""Tests for the evolution track seed data fixture (WP-03). +""" +Tests for app/seed/evolution_tracks.py — seed_evolution_tracks(). -Unit tests verify the JSON fixture is correctly formed without touching any -database. The integration test binds a minimal in-memory EvolutionTrack -model (mirroring the schema WP-01 will add to db_engine) to an in-memory -SQLite database, calls seed(), and verifies idempotency. +What: Verify that the JSON-driven seed function correctly creates, counts, +and idempotently updates EvolutionTrack rows in the database. + +Why: The seed is the single source of truth for track configuration. A +regression here (duplicates, wrong thresholds, missing formula) would +silently corrupt evolution scoring for every card in the system. + +Each test operates on a fresh in-memory SQLite database provided by the +autouse `setup_test_db` fixture in conftest.py. The seed reads its data +from `app/seed/evolution_tracks.json` on disk, so the tests also serve as +a light integration check between the JSON file and the Peewee model. """ +import json +from pathlib import Path + import pytest -from peewee import CharField, IntegerField, Model, SqliteDatabase -from app.seed.evolution_tracks import load_tracks, seed +from app.db_engine import EvolutionTrack +from app.seed.evolution_tracks import seed_evolution_tracks -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -_test_db = SqliteDatabase(":memory:") +# Path to the JSON fixture that the seed reads from at runtime +_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json" -class EvolutionTrackStub(Model): - """Minimal EvolutionTrack model for integration tests. +@pytest.fixture +def json_tracks(): + """Load the raw JSON definitions so tests can assert against them. - Mirrors the schema that WP-01 will add to db_engine so the integration - test can run without WP-01 being merged. + This avoids hardcoding expected values — if the JSON changes, tests + automatically follow without needing manual updates. """ - - name = CharField() - card_type = CharField(unique=True) - formula = CharField() - t1 = IntegerField() - t2 = IntegerField() - t3 = IntegerField() - t4 = IntegerField() - - class Meta: - database = _test_db - table_name = "evolution_track" + return json.loads(_JSON_PATH.read_text(encoding="utf-8")) -@pytest.fixture(autouse=True) -def _db(): - """Bind and create the stub table; drop it after each test.""" - _test_db.connect(reuse_if_open=True) - _test_db.create_tables([EvolutionTrackStub]) - yield - _test_db.drop_tables([EvolutionTrackStub]) +def test_seed_creates_three_tracks(json_tracks): + """After one seed call, exactly 3 EvolutionTrack rows must exist. - -# --------------------------------------------------------------------------- -# Unit tests — JSON fixture only, no database -# --------------------------------------------------------------------------- - - -def test_three_tracks_in_seed_data(): - """load_tracks() must return exactly 3 evolution tracks.""" - assert len(load_tracks()) == 3 - - -def test_card_types_are_exactly_batter_sp_rp(): - """The set of card_type values must be exactly {'batter', 'sp', 'rp'}.""" - types = {t["card_type"] for t in load_tracks()} - assert types == {"batter", "sp", "rp"} - - -def test_all_thresholds_positive_and_ascending(): - """Each track must have t1 < t2 < t3 < t4, all positive.""" - for track in load_tracks(): - assert track["t1"] > 0 - assert track["t1"] < track["t2"] < track["t3"] < track["t4"] - - -def test_all_tracks_have_non_empty_formula(): - """Every track must have a non-empty formula string.""" - for track in load_tracks(): - assert isinstance(track["formula"], str) and track["formula"].strip() - - -def test_tier_thresholds_match_locked_values(): - """Threshold values must exactly match the locked design spec.""" - tracks = {t["card_type"]: t for t in load_tracks()} - - assert tracks["batter"]["t1"] == 37 - assert tracks["batter"]["t2"] == 149 - assert tracks["batter"]["t3"] == 448 - assert tracks["batter"]["t4"] == 896 - - assert tracks["sp"]["t1"] == 10 - assert tracks["sp"]["t2"] == 40 - assert tracks["sp"]["t3"] == 120 - assert tracks["sp"]["t4"] == 240 - - assert tracks["rp"]["t1"] == 3 - assert tracks["rp"]["t2"] == 12 - assert tracks["rp"]["t3"] == 35 - assert tracks["rp"]["t4"] == 70 - - -# --------------------------------------------------------------------------- -# Integration test — uses the stub model + in-memory SQLite -# --------------------------------------------------------------------------- - - -def test_seed_is_idempotent(): - """Calling seed() twice must not create duplicate rows (get_or_create). - - First call: all three tracks created (created=True for each). - Second call: all three already exist (created=False for each). - Both calls succeed without error. + Why: The JSON currently defines three card-type tracks (batter, sp, rp). + If the count is wrong the system would either be missing tracks + (evolution disabled for a card type) or have phantom extras. """ - results_first = seed(model_class=EvolutionTrackStub) - assert len(results_first) == 3 - assert all(created for _, created in results_first) + seed_evolution_tracks() + assert EvolutionTrack.select().count() == 3 - results_second = seed(model_class=EvolutionTrackStub) - assert len(results_second) == 3 - assert not any(created for _, created in results_second) - assert EvolutionTrackStub.select().count() == 3 +def test_seed_correct_card_types(json_tracks): + """The set of card_type values persisted must match the JSON exactly. + + Why: card_type is used as a discriminator throughout the evolution engine. + An unexpected value (e.g. 'pitcher' instead of 'sp') would cause + track-lookup misses and silently skip evolution scoring for that role. + """ + seed_evolution_tracks() + expected_types = {d["card_type"] for d in json_tracks} + actual_types = {t.card_type for t in EvolutionTrack.select()} + assert actual_types == expected_types + + +def test_seed_thresholds_ascending(): + """For every track, t1 < t2 < t3 < t4. + + Why: The evolution engine uses these thresholds to determine tier + boundaries. If they are not strictly ascending, tier comparisons + would produce incorrect or undefined results (e.g. a player could + simultaneously satisfy tier 3 and not satisfy tier 2). + """ + seed_evolution_tracks() + for track in EvolutionTrack.select(): + assert ( + track.t1_threshold < track.t2_threshold + ), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})" + assert ( + track.t2_threshold < track.t3_threshold + ), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})" + assert ( + track.t3_threshold < track.t4_threshold + ), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})" + + +def test_seed_thresholds_positive(): + """All tier threshold values must be strictly greater than zero. + + Why: A zero or negative threshold would mean a card starts the game + already evolved (tier >= 1 at 0 accumulated stat points), which would + bypass the entire progression system. + """ + seed_evolution_tracks() + for track in EvolutionTrack.select(): + assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive" + assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive" + assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive" + assert track.t4_threshold > 0, f"{track.name}: t4_threshold is not positive" + + +def test_seed_formula_present(): + """Every persisted track must have a non-empty formula string. + + Why: The formula is evaluated at runtime to compute a player's evolution + score. An empty formula would cause either a Python eval error or + silently produce 0 for every player, halting all evolution progress. + """ + seed_evolution_tracks() + for track in EvolutionTrack.select(): + assert ( + track.formula and track.formula.strip() + ), f"{track.name}: formula is empty or whitespace-only" + + +def test_seed_idempotent(): + """Calling seed_evolution_tracks() twice must still yield exactly 3 rows. + + Why: The seed is designed to be safe to re-run (e.g. as part of a + migration or CI bootstrap). If it inserts duplicates on a second call, + the unique constraint on EvolutionTrack.name would raise an IntegrityError + in PostgreSQL, and in SQLite it would silently create phantom rows that + corrupt tier-lookup joins. + """ + seed_evolution_tracks() + seed_evolution_tracks() + assert EvolutionTrack.select().count() == 3 + + +def test_seed_updates_on_rerun(json_tracks): + """A second seed call must restore any manually changed threshold to the JSON value. + + What: Seed once, manually mutate a threshold in the DB, then seed again. + Assert that the threshold is now back to the JSON-defined value. + + Why: The seed must act as the authoritative source of truth. If + re-seeding does not overwrite local changes, configuration drift can + build up silently and the production database would diverge from the + checked-in JSON without any visible error. + """ + seed_evolution_tracks() + + # Pick the first track and corrupt its t1_threshold + first_def = json_tracks[0] + track = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + original_t1 = track.t1_threshold + corrupted_value = original_t1 + 9999 + track.t1_threshold = corrupted_value + track.save() + + # Confirm the corruption took effect before re-seeding + track_check = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + assert track_check.t1_threshold == corrupted_value + + # Re-seed — should restore the JSON value + seed_evolution_tracks() + + restored = EvolutionTrack.get(EvolutionTrack.name == first_def["name"]) + assert restored.t1_threshold == first_def["t1_threshold"], ( + f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, " + f"got {restored.t1_threshold}" + ) diff --git a/tests/test_evolution_state_api.py b/tests/test_evolution_state_api.py new file mode 100644 index 0000000..a9b7e47 --- /dev/null +++ b/tests/test_evolution_state_api.py @@ -0,0 +1,609 @@ +"""Integration tests for the evolution card state API endpoints (WP-07). + +Tests cover: + GET /api/v2/teams/{team_id}/evolutions + GET /api/v2/evolution/cards/{card_id} + +All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and +assume the evolution schema migration (WP-04) has already been applied. +Tests auto-skip when POSTGRES_HOST is not set. + +Test data is inserted via psycopg2 before each module fixture runs and +cleaned up in teardown so the tests are repeatable. ON CONFLICT / CASCADE +clauses keep the table clean even if a previous run did not complete teardown. + +Object graph built by fixtures +------------------------------- + rarity_row -- a seeded rarity row + cardset_row -- a seeded cardset row + player_row -- a seeded player row (FK: rarity, cardset) + team_row -- a seeded team row + track_row -- a seeded evolution_track row (batter) + card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset) + state_row -- a seeded evolution_card_state row (FK: player, team, track) + +Test matrix +----------- + test_list_team_evolutions -- baseline: returns count + items for a team + test_list_filter_by_card_type -- card_type query param filters by track.card_type + test_list_filter_by_tier -- tier query param filters by current_tier + test_list_pagination -- page/per_page params slice results correctly + test_get_card_state_shape -- single card returns all required response fields + test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current + test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState + test_get_card_404_no_state -- card with no EvolutionCardState returns 404 + test_duplicate_cards_share_state -- two cards same player+team return the same state row + test_auth_required -- missing token returns 401 on both endpoints +""" + +import os + +import pytest +from fastapi.testclient import TestClient + +POSTGRES_HOST = os.environ.get("POSTGRES_HOST") +_skip_no_pg = pytest.mark.skipif( + not POSTGRES_HOST, reason="POSTGRES_HOST not set — integration tests skipped" +) + +AUTH_HEADER = {"Authorization": f"Bearer {os.environ.get('API_TOKEN', 'test-token')}"} + +# --------------------------------------------------------------------------- +# Shared fixtures: seed and clean up the full object graph +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def seeded_data(pg_conn): + """Insert all rows needed for state API tests; delete them after the module. + + Returns a dict with the integer IDs of every inserted row so individual + test functions can reference them by key. + + Insertion order respects FK dependencies: + rarity -> cardset -> player + pack_type (needs cardset) -> pack (needs team + pack_type) -> card + evolution_track -> evolution_card_state + """ + cur = pg_conn.cursor() + + # Rarity + cur.execute( + """ + INSERT INTO rarity (value, name, color) + VALUES (99, 'WP07TestRarity', '#123456') + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value + RETURNING id + """ + ) + rarity_id = cur.fetchone()[0] + + # Cardset + cur.execute( + """ + INSERT INTO cardset (name, description, total_cards) + VALUES ('WP07 Test Set', 'evo state api tests', 1) + ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description + RETURNING id + """ + ) + cardset_id = cur.fetchone()[0] + + # Player 1 (batter) + cur.execute( + """ + INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1, + image, mlbclub, franchise, description) + VALUES ('WP07 Batter', %s, %s, 901, '1B', + 'https://example.com/wp07_b.png', 'TST', 'TST', 'wp07 test batter') + RETURNING player_id + """, + (rarity_id, cardset_id), + ) + player_id = cur.fetchone()[0] + + # Player 2 (sp) for cross-card_type filter test + cur.execute( + """ + INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1, + image, mlbclub, franchise, description) + VALUES ('WP07 Pitcher', %s, %s, 902, 'SP', + 'https://example.com/wp07_p.png', 'TST', 'TST', 'wp07 test pitcher') + RETURNING player_id + """, + (rarity_id, cardset_id), + ) + player2_id = cur.fetchone()[0] + + # Team + cur.execute( + """ + INSERT INTO team (abbrev, sname, lname, gmid, gmname, gsheet, + wallet, team_value, collection_value, season, is_ai) + VALUES ('WP7', 'WP07', 'WP07 Test Team', 700000001, 'wp07user', + 'https://docs.google.com/wp07', 0, 0, 0, 11, false) + RETURNING id + """ + ) + team_id = cur.fetchone()[0] + + # Evolution tracks + cur.execute( + """ + INSERT INTO evolution_track (name, card_type, formula, + t1_threshold, t2_threshold, + t3_threshold, t4_threshold) + VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896) + ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type + RETURNING id + """ + ) + batter_track_id = cur.fetchone()[0] + + cur.execute( + """ + INSERT INTO evolution_track (name, card_type, formula, + t1_threshold, t2_threshold, + t3_threshold, t4_threshold) + VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240) + ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type + RETURNING id + """ + ) + sp_track_id = cur.fetchone()[0] + + # Pack type + pack (needed as FK parent for Card) + cur.execute( + """ + INSERT INTO pack_type (name, cost, card_count, cardset_id) + VALUES ('WP07 Pack Type', 100, 5, %s) + RETURNING id + """, + (cardset_id,), + ) + pack_type_id = cur.fetchone()[0] + + cur.execute( + """ + INSERT INTO pack (team_id, pack_type_id) + VALUES (%s, %s) + RETURNING id + """, + (team_id, pack_type_id), + ) + pack_id = cur.fetchone()[0] + + # Card linking batter player to team + cur.execute( + """ + INSERT INTO card (player_id, team_id, pack_id, value) + VALUES (%s, %s, %s, 0) + RETURNING id + """, + (player_id, team_id, pack_id), + ) + card_id = cur.fetchone()[0] + + # Second card for same player+team (shared-state test) + cur.execute( + """ + INSERT INTO pack (team_id, pack_type_id) + VALUES (%s, %s) + RETURNING id + """, + (team_id, pack_type_id), + ) + pack2_id = cur.fetchone()[0] + + cur.execute( + """ + INSERT INTO card (player_id, team_id, pack_id, value) + VALUES (%s, %s, %s, 0) + RETURNING id + """, + (player_id, team_id, pack2_id), + ) + card2_id = cur.fetchone()[0] + + # Card with NO state (404 test) + cur.execute( + """ + INSERT INTO pack (team_id, pack_type_id) + VALUES (%s, %s) + RETURNING id + """, + (team_id, pack_type_id), + ) + pack3_id = cur.fetchone()[0] + + cur.execute( + """ + INSERT INTO card (player_id, team_id, pack_id, value) + VALUES (%s, %s, %s, 0) + RETURNING id + """, + (player2_id, team_id, pack3_id), + ) + card_no_state_id = cur.fetchone()[0] + + # Evolution card states + # Batter player at tier 1, value 87.5 + cur.execute( + """ + INSERT INTO evolution_card_state + (player_id, team_id, track_id, current_tier, current_value, + fully_evolved, last_evaluated_at) + VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z') + RETURNING id + """, + (player_id, team_id, batter_track_id), + ) + state_id = cur.fetchone()[0] + + pg_conn.commit() + + yield { + "rarity_id": rarity_id, + "cardset_id": cardset_id, + "player_id": player_id, + "player2_id": player2_id, + "team_id": team_id, + "batter_track_id": batter_track_id, + "sp_track_id": sp_track_id, + "pack_type_id": pack_type_id, + "card_id": card_id, + "card2_id": card2_id, + "card_no_state_id": card_no_state_id, + "state_id": state_id, + } + + # Teardown: delete in reverse FK order + cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (state_id,)) + cur.execute( + "DELETE FROM card WHERE id = ANY(%s)", + ([card_id, card2_id, card_no_state_id],), + ) + cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],)) + cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,)) + cur.execute( + "DELETE FROM evolution_track WHERE id = ANY(%s)", + ([batter_track_id, sp_track_id],), + ) + cur.execute( + "DELETE FROM player WHERE player_id = ANY(%s)", ([player_id, player2_id],) + ) + cur.execute("DELETE FROM team WHERE id = %s", (team_id,)) + cur.execute("DELETE FROM cardset WHERE id = %s", (cardset_id,)) + cur.execute("DELETE FROM rarity WHERE id = %s", (rarity_id,)) + pg_conn.commit() + + +@pytest.fixture(scope="module") +def client(): + """FastAPI TestClient backed by the real PostgreSQL database.""" + from app.main import app + + with TestClient(app) as c: + yield c + + +# --------------------------------------------------------------------------- +# Tests: GET /api/v2/teams/{team_id}/evolutions +# --------------------------------------------------------------------------- + + +@_skip_no_pg +def test_list_team_evolutions(client, seeded_data): + """GET /teams/{id}/evolutions returns count=1 and one item for the seeded state. + + Verifies the basic list response shape: a dict with 'count' and 'items', + and that the single item contains player_id, team_id, and current_tier. + """ + team_id = seeded_data["team_id"] + resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER) + assert resp.status_code == 200 + data = resp.json() + assert data["count"] == 1 + assert len(data["items"]) == 1 + item = data["items"][0] + assert item["player_id"] == seeded_data["player_id"] + assert item["team_id"] == team_id + assert item["current_tier"] == 1 + + +@_skip_no_pg +def test_list_filter_by_card_type(client, seeded_data, pg_conn): + """card_type filter includes states whose track.card_type matches and excludes others. + + Seeds a second evolution_card_state for player2 (sp track) then queries + card_type=batter (returns 1) and card_type=sp (returns 1). + Verifies the JOIN to evolution_track and the WHERE predicate on card_type. + """ + cur = pg_conn.cursor() + # Add a state for the sp player so we have two types in this team + cur.execute( + """ + INSERT INTO evolution_card_state + (player_id, team_id, track_id, current_tier, current_value, fully_evolved) + VALUES (%s, %s, %s, 0, 0.0, false) + RETURNING id + """, + (seeded_data["player2_id"], seeded_data["team_id"], seeded_data["sp_track_id"]), + ) + sp_state_id = cur.fetchone()[0] + pg_conn.commit() + + try: + team_id = seeded_data["team_id"] + + resp_batter = client.get( + f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER + ) + assert resp_batter.status_code == 200 + batter_data = resp_batter.json() + assert batter_data["count"] == 1 + assert batter_data["items"][0]["player_id"] == seeded_data["player_id"] + + resp_sp = client.get( + f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER + ) + assert resp_sp.status_code == 200 + sp_data = resp_sp.json() + assert sp_data["count"] == 1 + assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"] + finally: + cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,)) + pg_conn.commit() + + +@_skip_no_pg +def test_list_filter_by_tier(client, seeded_data, pg_conn): + """tier filter includes only states at the specified current_tier. + + The base fixture has player1 at tier=1. This test temporarily advances + it to tier=2, then queries tier=1 (should return 0) and tier=2 (should + return 1). Restores to tier=1 after assertions. + """ + cur = pg_conn.cursor() + + # Advance to tier 2 + cur.execute( + "UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", + (seeded_data["state_id"],), + ) + pg_conn.commit() + + try: + team_id = seeded_data["team_id"] + + resp_t1 = client.get( + f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER + ) + assert resp_t1.status_code == 200 + assert resp_t1.json()["count"] == 0 + + resp_t2 = client.get( + f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER + ) + assert resp_t2.status_code == 200 + t2_data = resp_t2.json() + assert t2_data["count"] == 1 + assert t2_data["items"][0]["current_tier"] == 2 + finally: + cur.execute( + "UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s", + (seeded_data["state_id"],), + ) + pg_conn.commit() + + +@_skip_no_pg +def test_list_pagination(client, seeded_data, pg_conn): + """page/per_page params slice the full result set correctly. + + Temporarily inserts a second state (for player2 on the same team) so + the list has 2 items. With per_page=1, page=1 returns item 1 and + page=2 returns item 2; they must be different players. + """ + cur = pg_conn.cursor() + cur.execute( + """ + INSERT INTO evolution_card_state + (player_id, team_id, track_id, current_tier, current_value, fully_evolved) + VALUES (%s, %s, %s, 0, 0.0, false) + RETURNING id + """, + ( + seeded_data["player2_id"], + seeded_data["team_id"], + seeded_data["batter_track_id"], + ), + ) + extra_state_id = cur.fetchone()[0] + pg_conn.commit() + + try: + team_id = seeded_data["team_id"] + + resp1 = client.get( + f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER + ) + assert resp1.status_code == 200 + data1 = resp1.json() + assert len(data1["items"]) == 1 + + resp2 = client.get( + f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER + ) + assert resp2.status_code == 200 + data2 = resp2.json() + assert len(data2["items"]) == 1 + + assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"] + finally: + cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,)) + pg_conn.commit() + + +# --------------------------------------------------------------------------- +# Tests: GET /api/v2/evolution/cards/{card_id} +# --------------------------------------------------------------------------- + + +@_skip_no_pg +def test_get_card_state_shape(client, seeded_data): + """GET /evolution/cards/{card_id} returns all required fields. + + Verifies the full response envelope: + player_id, team_id, current_tier, current_value, fully_evolved, + last_evaluated_at, next_threshold, and a nested 'track' dict + with id, name, card_type, formula, and t1-t4 thresholds. + """ + card_id = seeded_data["card_id"] + resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + assert resp.status_code == 200 + data = resp.json() + + assert data["player_id"] == seeded_data["player_id"] + assert data["team_id"] == seeded_data["team_id"] + assert data["current_tier"] == 1 + assert data["current_value"] == 87.5 + assert data["fully_evolved"] is False + + t = data["track"] + assert t["id"] == seeded_data["batter_track_id"] + assert t["name"] == "WP07 Batter Track" + assert t["card_type"] == "batter" + assert t["formula"] == "pa + tb * 2" + assert t["t1_threshold"] == 37 + assert t["t2_threshold"] == 149 + assert t["t3_threshold"] == 448 + assert t["t4_threshold"] == 896 + + # tier=1 -> next threshold is t2_threshold + assert data["next_threshold"] == 149 + + +@_skip_no_pg +def test_get_card_state_next_threshold(client, seeded_data, pg_conn): + """next_threshold reflects the threshold for the tier immediately above current. + + Tier mapping: + 0 -> t1_threshold (37) + 1 -> t2_threshold (149) + 2 -> t3_threshold (448) + 3 -> t4_threshold (896) + 4 -> null (fully evolved) + + This test advances the state to tier=2, confirms next_threshold=448, + then to tier=4 (fully_evolved=True) and confirms next_threshold=null. + Restores original state after assertions. + """ + cur = pg_conn.cursor() + card_id = seeded_data["card_id"] + state_id = seeded_data["state_id"] + + # Advance to tier 2 + cur.execute( + "UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,) + ) + pg_conn.commit() + + try: + resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + assert resp.status_code == 200 + assert resp.json()["next_threshold"] == 448 # t3_threshold + + # Advance to tier 4 (fully evolved) + cur.execute( + "UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true " + "WHERE id = %s", + (state_id,), + ) + pg_conn.commit() + + resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + assert resp2.status_code == 200 + assert resp2.json()["next_threshold"] is None + finally: + cur.execute( + "UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false " + "WHERE id = %s", + (state_id,), + ) + pg_conn.commit() + + +@_skip_no_pg +def test_get_card_id_resolves_player(client, seeded_data): + """card_id is resolved via the Card table to obtain (player_id, team_id). + + The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState. + Verifies that card_id correctly maps to the right player's evolution state. + """ + card_id = seeded_data["card_id"] + resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + assert resp.status_code == 200 + data = resp.json() + assert data["player_id"] == seeded_data["player_id"] + assert data["team_id"] == seeded_data["team_id"] + + +@_skip_no_pg +def test_get_card_404_no_state(client, seeded_data): + """GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists. + + card_no_state_id is a card row for player2 on the team, but no + evolution_card_state row was created for player2. The endpoint must + return 404, not 500 or an empty response. + """ + card_id = seeded_data["card_no_state_id"] + resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER) + assert resp.status_code == 404 + + +@_skip_no_pg +def test_duplicate_cards_share_state(client, seeded_data): + """Two Card rows for the same player+team share one EvolutionCardState. + + card_id and card2_id both belong to player_id on team_id. Because the + unique-(player,team) constraint means only one state row can exist, both + card IDs must resolve to the same state data. + """ + card1_id = seeded_data["card_id"] + card2_id = seeded_data["card2_id"] + + resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER) + resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER) + + assert resp1.status_code == 200 + assert resp2.status_code == 200 + data1 = resp1.json() + data2 = resp2.json() + + assert data1["player_id"] == data2["player_id"] == seeded_data["player_id"] + assert data1["current_tier"] == data2["current_tier"] == 1 + assert data1["current_value"] == data2["current_value"] == 87.5 + + +# --------------------------------------------------------------------------- +# Auth tests +# --------------------------------------------------------------------------- + + +@_skip_no_pg +def test_auth_required(client, seeded_data): + """Both endpoints return 401 when no Bearer token is provided. + + Verifies that the valid_token dependency is enforced on: + GET /api/v2/teams/{id}/evolutions + GET /api/v2/evolution/cards/{id} + """ + team_id = seeded_data["team_id"] + card_id = seeded_data["card_id"] + + resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions") + assert resp_list.status_code == 401 + + resp_card = client.get(f"/api/v2/evolution/cards/{card_id}") + assert resp_card.status_code == 401 diff --git a/tests/test_formula_engine.py b/tests/test_formula_engine.py index 67c14a9..435cd92 100644 --- a/tests/test_formula_engine.py +++ b/tests/test_formula_engine.py @@ -43,9 +43,27 @@ def pitcher_stats(**kwargs): def track_dict(card_type: str) -> dict: """Return the locked threshold dict for a given card_type.""" return { - "batter": {"card_type": "batter", "t1": 37, "t2": 149, "t3": 448, "t4": 896}, - "sp": {"card_type": "sp", "t1": 10, "t2": 40, "t3": 120, "t4": 240}, - "rp": {"card_type": "rp", "t1": 3, "t2": 12, "t3": 35, "t4": 70}, + "batter": { + "card_type": "batter", + "t1_threshold": 37, + "t2_threshold": 149, + "t3_threshold": 448, + "t4_threshold": 896, + }, + "sp": { + "card_type": "sp", + "t1_threshold": 10, + "t2_threshold": 40, + "t3_threshold": 120, + "t4_threshold": 240, + }, + "rp": { + "card_type": "rp", + "t1_threshold": 3, + "t2_threshold": 12, + "t3_threshold": 35, + "t4_threshold": 70, + }, }[card_type] diff --git a/tests/test_postgame_evolution.py b/tests/test_postgame_evolution.py new file mode 100644 index 0000000..21671e8 --- /dev/null +++ b/tests/test_postgame_evolution.py @@ -0,0 +1,667 @@ +"""Integration tests for WP-13: Post-Game Callback Integration. + +Tests cover both post-game callback endpoints: + POST /api/v2/season-stats/update-game/{game_id} + POST /api/v2/evolution/evaluate-game/{game_id} + +All tests run against a named shared-memory SQLite database so that Peewee +model queries inside the route handlers (which execute in the TestClient's +thread) and test fixture setup/assertions (which execute in the pytest thread) +use the same underlying database connection. This is necessary because +SQLite :memory: databases are per-connection — a new thread gets a new empty +database unless a shared-cache URI is used. + +The WP-13 tests therefore manage their own database fixture (_wp13_db) and do +not use the conftest autouse setup_test_db. The module-level setup_wp13_db +fixture creates tables before each test and drops them after. + +The season_stats service 'db' reference is patched at module level so that +db.atomic() inside update_season_stats() operates on _wp13_db. + +Test matrix: + test_update_game_creates_season_stats_rows + POST to update-game, assert player_season_stats rows are created. + test_update_game_response_shape + Response contains {"updated": N, "skipped": false}. + test_update_game_idempotent + Second POST to same game_id returns skipped=true, stats unchanged. + test_evaluate_game_increases_current_value + After update-game, POST to evaluate-game, assert current_value > 0. + test_evaluate_game_tier_advancement + Set up card near tier threshold, game pushes past it, assert tier advanced. + test_evaluate_game_no_tier_advancement + Player accumulates too few stats — tier stays at 0. + test_evaluate_game_tier_ups_in_response + Tier-up appears in tier_ups list with correct fields. + test_evaluate_game_skips_players_without_state + Players in game but without EvolutionCardState are silently skipped. + test_auth_required_update_game + Missing bearer token returns 401 on update-game. + test_auth_required_evaluate_game + Missing bearer token returns 401 on evaluate-game. +""" + +import os + +# Set API_TOKEN before any app imports so that app.dependencies.AUTH_TOKEN +# is initialised to the same value as our test bearer token. +os.environ.setdefault("API_TOKEN", "test-token") + +import app.services.season_stats as _season_stats_module +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient +from peewee import SqliteDatabase + +from app.db_engine import ( + Cardset, + EvolutionCardState, + EvolutionCosmetic, + EvolutionTierBoost, + EvolutionTrack, + MlbPlayer, + Pack, + PackType, + Player, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + Rarity, + Roster, + RosterSlot, + ScoutClaim, + ScoutOpportunity, + StratGame, + StratPlay, + Decision, + Team, + Card, + Event, +) + +# --------------------------------------------------------------------------- +# Shared-memory SQLite database for WP-13 tests. +# A named shared-memory URI allows multiple connections (and therefore +# multiple threads) to share the same in-memory database, which is required +# because TestClient routes run in a different thread than pytest fixtures. +# --------------------------------------------------------------------------- +_wp13_db = SqliteDatabase( + "file:wp13test?mode=memory&cache=shared", + uri=True, + pragmas={"foreign_keys": 1}, +) + +_WP13_MODELS = [ + Rarity, + Event, + Cardset, + MlbPlayer, + Player, + Team, + PackType, + Pack, + Card, + Roster, + RosterSlot, + StratGame, + StratPlay, + Decision, + ScoutOpportunity, + ScoutClaim, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + EvolutionTrack, + EvolutionCardState, + EvolutionTierBoost, + EvolutionCosmetic, +] + +# 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. +_season_stats_module.db = _wp13_db + +# --------------------------------------------------------------------------- +# Auth header used by every authenticated request +# --------------------------------------------------------------------------- +AUTH_HEADER = {"Authorization": "Bearer test-token"} + + +# --------------------------------------------------------------------------- +# Database fixture — binds all models to _wp13_db and creates/drops tables +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def setup_wp13_db(): + """Bind WP-13 models to the shared-memory SQLite db and create tables. + + autouse=True so every test in this module automatically gets a fresh + schema. Tables are dropped in reverse dependency order after each test. + + This fixture replaces (and disables) the conftest autouse setup_test_db + for tests in this module because we need a different database backend + (shared-cache URI rather than :memory:) to support multi-thread access + via TestClient. + """ + _wp13_db.bind(_WP13_MODELS) + _wp13_db.connect(reuse_if_open=True) + _wp13_db.create_tables(_WP13_MODELS) + yield _wp13_db + _wp13_db.drop_tables(list(reversed(_WP13_MODELS)), safe=True) + + +# --------------------------------------------------------------------------- +# Slim test app — only mounts the two routers under test. +# A db_middleware ensures the shared-cache connection is open for each request. +# --------------------------------------------------------------------------- + + +def _build_test_app() -> FastAPI: + """Build a minimal FastAPI instance with just the WP-13 routers. + + A db_middleware calls _wp13_db.connect(reuse_if_open=True) before each + request so that the route handler thread can use the shared-memory SQLite + connection even though it runs in a different thread from the fixture. + """ + from app.routers_v2.season_stats import router as ss_router + from app.routers_v2.evolution import router as evo_router + + test_app = FastAPI() + + @test_app.middleware("http") + async def db_middleware(request: Request, call_next): + _wp13_db.connect(reuse_if_open=True) + return await call_next(request) + + test_app.include_router(ss_router) + test_app.include_router(evo_router) + return test_app + + +# --------------------------------------------------------------------------- +# TestClient fixture — function-scoped so it uses the per-test db binding. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(setup_wp13_db): + """FastAPI TestClient backed by the slim test app and shared-memory SQLite.""" + with TestClient(_build_test_app()) as c: + yield c + + +# --------------------------------------------------------------------------- +# Shared helper factories (mirrors test_season_stats_update.py style) +# --------------------------------------------------------------------------- + + +def _make_cardset(): + cs, _ = Cardset.get_or_create( + name="WP13 Test Set", + defaults={"description": "wp13 cardset", "total_cards": 100}, + ) + return cs + + +def _make_rarity(): + r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"}) + return r + + +def _make_player(name: str, pos: str = "1B") -> Player: + return Player.create( + p_name=name, + rarity=_make_rarity(), + cardset=_make_cardset(), + set_num=1, + pos_1=pos, + image="https://example.com/img.png", + mlbclub="TST", + franchise="TST", + description=f"wp13 test: {name}", + ) + + +def _make_team(abbrev: str, gmid: int) -> Team: + return Team.create( + abbrev=abbrev, + sname=abbrev, + lname=f"Team {abbrev}", + gmid=gmid, + gmname=f"gm_{abbrev.lower()}", + gsheet="https://docs.google.com/spreadsheets/wp13", + wallet=500, + team_value=1000, + collection_value=1000, + season=11, + is_ai=False, + ) + + +def _make_game(team_a, team_b) -> StratGame: + return StratGame.create( + season=11, + game_type="ranked", + away_team=team_a, + home_team=team_b, + ) + + +def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats): + """Create a StratPlay with sensible zero-defaults for all stat columns.""" + defaults = dict( + on_base_code="000", + inning_half="top", + inning_num=1, + batting_order=1, + starting_outs=0, + away_score=0, + home_score=0, + pa=0, + ab=0, + hit=0, + run=0, + double=0, + triple=0, + homerun=0, + bb=0, + so=0, + hbp=0, + rbi=0, + sb=0, + cs=0, + outs=0, + sac=0, + ibb=0, + gidp=0, + bphr=0, + bpfo=0, + bp1b=0, + bplo=0, + ) + defaults.update(stats) + return StratPlay.create( + game=game, + play_num=play_num, + batter=batter, + batter_team=batter_team, + pitcher=pitcher, + pitcher_team=pitcher_team, + **defaults, + ) + + +def _make_track( + name: str = "WP13 Batter Track", card_type: str = "batter" +) -> EvolutionTrack: + track, _ = EvolutionTrack.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 +) -> EvolutionCardState: + return EvolutionCardState.create( + player=player, + team=team, + track=track, + current_tier=current_tier, + current_value=current_value, + fully_evolved=False, + last_evaluated_at=None, + ) + + +# --------------------------------------------------------------------------- +# Tests: POST /api/v2/season-stats/update-game/{game_id} +# --------------------------------------------------------------------------- + + +def test_update_game_creates_season_stats_rows(client): + """POST update-game creates player_season_stats rows for players in the game. + + What: Set up a batter and pitcher in a game with 3 PA for the batter. + After the endpoint call, assert a BattingSeasonStats row exists with pa=3. + + Why: This is the core write path. If the row is not created, the + evolution evaluator will always see zero career stats. + """ + team_a = _make_team("WU1", gmid=20001) + team_b = _make_team("WU2", gmid=20002) + batter = _make_player("WP13 Batter A") + pitcher = _make_player("WP13 Pitcher A", pos="SP") + game = _make_game(team_a, team_b) + + for i in range(3): + _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + + resp = client.post( + f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + + stats = BattingSeasonStats.get_or_none( + (BattingSeasonStats.player == batter) + & (BattingSeasonStats.team == team_a) + & (BattingSeasonStats.season == 11) + ) + assert stats is not None + assert stats.pa == 3 + + +def test_update_game_response_shape(client): + """POST update-game returns {"updated": N, "skipped": false}. + + What: A game with one batter and one pitcher produces updated >= 1 and + skipped is false on the first call. + + Why: The bot relies on 'updated' to log how many rows were touched and + 'skipped' to detect re-delivery. + """ + team_a = _make_team("WS1", gmid=20011) + team_b = _make_team("WS2", gmid=20012) + batter = _make_player("WP13 Batter S") + pitcher = _make_player("WP13 Pitcher S", pos="SP") + game = _make_game(team_a, team_b) + + _make_play(game, 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + + resp = client.post( + f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + assert "updated" in data + assert data["updated"] >= 1 + assert data["skipped"] is False + + +def test_update_game_idempotent(client): + """Calling update-game twice for the same game returns skipped=true on second call. + + What: Process a game once (pa=3), then call the endpoint again with the + same game_id. The second response must have skipped=true and updated=0, + and pa in the DB must still be 3 (not 6). + + Why: The bot infrastructure may deliver game-complete events more than + once. Double-counting would corrupt all evolution stats downstream. + """ + team_a = _make_team("WI1", gmid=20021) + team_b = _make_team("WI2", gmid=20022) + batter = _make_player("WP13 Batter I") + pitcher = _make_player("WP13 Pitcher I", pos="SP") + game = _make_game(team_a, team_b) + + for i in range(3): + _make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + + resp1 = client.post( + f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER + ) + assert resp1.status_code == 200 + assert resp1.json()["skipped"] is False + + resp2 = client.post( + f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER + ) + assert resp2.status_code == 200 + data2 = resp2.json() + assert data2["skipped"] is True + assert data2["updated"] == 0 + + stats = BattingSeasonStats.get( + (BattingSeasonStats.player == batter) & (BattingSeasonStats.team == team_a) + ) + assert stats.pa == 3 # not 6 + + +# --------------------------------------------------------------------------- +# Tests: POST /api/v2/evolution/evaluate-game/{game_id} +# --------------------------------------------------------------------------- + + +def test_evaluate_game_increases_current_value(client): + """After update-game, evaluate-game raises the card's current_value above 0. + + What: Batter with an EvolutionCardState gets 3 hits (pa=3, hit=3) from a + game. update-game writes those stats; evaluate-game then recomputes the + value. current_value in the DB must be > 0 after the evaluate call. + + Why: This is the end-to-end path: stats in -> evaluate -> value updated. + If current_value stays 0, the card will never advance regardless of how + many games are played. + """ + team_a = _make_team("WE1", gmid=20031) + team_b = _make_team("WE2", gmid=20032) + batter = _make_player("WP13 Batter E") + pitcher = _make_player("WP13 Pitcher E", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track() + _make_state(batter, team_a, track) + + for i in range(3): + _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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + + state = EvolutionCardState.get( + (EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) + ) + assert state.current_value > 0 + + +def test_evaluate_game_tier_advancement(client): + """A game that pushes a card past a tier threshold advances the tier. + + What: Set the batter's career value just below T1 (37) by manually seeding + a prior BattingSeasonStats row with pa=34. Then add a game that brings the + total past 37 and call evaluate-game. current_tier must advance to >= 1. + + Why: Tier advancement is the core deliverable of card evolution. If the + threshold comparison is off-by-one or the tier is never written, the card + will never visually evolve. + """ + team_a = _make_team("WT1", gmid=20041) + team_b = _make_team("WT2", gmid=20042) + batter = _make_player("WP13 Batter T") + pitcher = _make_player("WP13 Pitcher T", pos="SP") + 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) + + # Seed prior stats: 34 PA (value = 34; T1 threshold = 37) + BattingSeasonStats.create( + player=batter, + team=team_a, + season=10, # previous season + pa=34, + ) + + # Game adds 4 more 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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + + updated_state = EvolutionCardState.get( + (EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) + ) + assert updated_state.current_tier >= 1 + + +def test_evaluate_game_no_tier_advancement(client): + """A game with insufficient stats does not advance the tier. + + What: A batter starts at tier=0 with current_value=0. The game adds only + 2 PA (value=2 which is < T1 threshold of 37). After evaluate-game the + tier must still be 0. + + Why: We need to confirm the threshold guard works correctly — cards should + not advance prematurely before earning the required stats. + """ + team_a = _make_team("WN1", gmid=20051) + team_b = _make_team("WN2", gmid=20052) + batter = _make_player("WP13 Batter N") + pitcher = _make_player("WP13 Pitcher N", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track(name="WP13 No-Adv Track") + _make_state(batter, team_a, track, current_tier=0) + + # Only 2 PA — far below T1=37 + for i in range(2): + _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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + assert data["tier_ups"] == [] + + state = EvolutionCardState.get( + (EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a) + ) + assert state.current_tier == 0 + + +def test_evaluate_game_tier_ups_in_response(client): + """evaluate-game response includes a tier_ups entry when a player advances. + + What: Seed a batter at tier=0 with pa=34 (just below T1=37). A game adds + 4 PA pushing total to 38. The response tier_ups list must contain one + entry with the correct fields: player_id, team_id, player_name, old_tier, + new_tier, current_value, track_name. + + Why: The bot uses tier_ups to trigger in-game notifications and visual card + upgrade animations. A missing or malformed entry would silently skip the + announcement. + """ + team_a = _make_team("WR1", gmid=20061) + team_b = _make_team("WR2", gmid=20062) + batter = _make_player("WP13 Batter R") + pitcher = _make_player("WP13 Pitcher R", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track(name="WP13 Tier-Ups Track") + _make_state(batter, team_a, track, current_tier=0) + + # Seed prior stats below threshold + BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34) + + # Game pushes past T1 + 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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + assert data["evaluated"] >= 1 + assert len(data["tier_ups"]) == 1 + + tu = data["tier_ups"][0] + assert tu["player_id"] == batter.player_id + assert tu["team_id"] == team_a.id + assert tu["player_name"] == "WP13 Batter R" + assert tu["old_tier"] == 0 + assert tu["new_tier"] >= 1 + assert tu["current_value"] > 0 + assert tu["track_name"] == "WP13 Tier-Ups Track" + + +def test_evaluate_game_skips_players_without_state(client): + """Players in a game without an EvolutionCardState are silently skipped. + + What: A game has two players: one with a card state and one without. + After evaluate-game, evaluated should be 1 (only the player with state) + and the endpoint must return 200 without errors. + + Why: Not every player on a roster will have started their evolution journey. + A hard 404 or 500 for missing states would break the entire batch. + """ + team_a = _make_team("WK1", gmid=20071) + team_b = _make_team("WK2", gmid=20072) + batter_with_state = _make_player("WP13 Batter WithState") + batter_no_state = _make_player("WP13 Batter NoState") + pitcher = _make_player("WP13 Pitcher K", pos="SP") + game = _make_game(team_a, team_b) + track = _make_track(name="WP13 Skip Track") + + # Only batter_with_state gets an EvolutionCardState + _make_state(batter_with_state, team_a, track) + + _make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1) + _make_play(game, 2, batter_no_state, 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/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER + ) + assert resp.status_code == 200 + data = resp.json() + + # Only 1 evaluation (the player with a state) + assert data["evaluated"] == 1 + + +# --------------------------------------------------------------------------- +# Tests: Auth required on both endpoints +# --------------------------------------------------------------------------- + + +def test_auth_required_update_game(client): + """Missing bearer token on update-game returns 401. + + What: POST to update-game without any Authorization header. + + Why: Both endpoints are production-only callbacks that should never be + accessible without a valid bearer token. + """ + team_a = _make_team("WA1", gmid=20081) + team_b = _make_team("WA2", gmid=20082) + game = _make_game(team_a, team_b) + + resp = client.post(f"/api/v2/season-stats/update-game/{game.id}") + assert resp.status_code == 401 + + +def test_auth_required_evaluate_game(client): + """Missing bearer token on evaluate-game returns 401. + + What: POST to evaluate-game without any Authorization header. + + Why: Same security requirement as update-game — callbacks must be + authenticated to prevent replay attacks and unauthorized stat manipulation. + """ + team_a = _make_team("WB1", gmid=20091) + team_b = _make_team("WB2", gmid=20092) + game = _make_game(team_a, team_b) + + resp = client.post(f"/api/v2/evolution/evaluate-game/{game.id}") + assert resp.status_code == 401 diff --git a/tests/test_season_stats_update.py b/tests/test_season_stats_update.py new file mode 100644 index 0000000..b833cea --- /dev/null +++ b/tests/test_season_stats_update.py @@ -0,0 +1,910 @@ +""" +Tests for app/services/season_stats.py — update_season_stats(). + +What: Verify that the full-recalculation stat engine correctly aggregates +StratPlay and Decision rows into BattingSeasonStats and PitchingSeasonStats, +handles duplicate calls idempotently, accumulates stats across multiple games, +and supports forced reprocessing for self-healing. + +Why: This is the core bookkeeping engine for card evolution scoring. A +double-count bug, a missed Decision merge, or a team-isolation failure +would silently produce wrong stats that would then corrupt every +evolution tier calculation downstream. + +Test data is created using real Peewee models (no mocking) against the +in-memory SQLite database provided by the autouse setup_test_db fixture +in conftest.py. All Player and Team creation uses the actual required +column set discovered from the model definition in db_engine.py. +""" + +import app.services.season_stats as _season_stats_module +import pytest + +from app.db_engine import ( + BattingSeasonStats, + Cardset, + Decision, + PitchingSeasonStats, + Player, + Rarity, + StratGame, + StratPlay, + Team, +) +from app.services.season_stats import update_season_stats +from tests.conftest import _test_db + +# --------------------------------------------------------------------------- +# Module-level patch: redirect season_stats.db to the test database +# --------------------------------------------------------------------------- +# season_stats.py holds a module-level reference to the `db` object imported +# from db_engine. When test models are rebound to _test_db via bind(), the +# `db` object inside season_stats still points at the original production db +# (SQLite file or PostgreSQL). We replace it here so that db.atomic() in +# update_season_stats() operates on the same in-memory connection that the +# test fixtures write to. +_season_stats_module.db = _test_db + + +# --------------------------------------------------------------------------- +# Helper factories +# --------------------------------------------------------------------------- + + +def _make_cardset(): + """Return a reusable Cardset row (or fetch the existing one by name).""" + cs, _ = Cardset.get_or_create( + name="Test Set", + defaults={"description": "Test cardset", "total_cards": 100}, + ) + return cs + + +def _make_rarity(): + """Return the Common rarity singleton.""" + r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"}) + return r + + +def _make_player(name: str, pos: str = "1B") -> Player: + """Create a Player row with all required (non-nullable) columns satisfied. + + Why we need this helper: Player has many non-nullable varchar columns + (image, mlbclub, franchise, description) and a required FK to Cardset. + A single helper keeps test fixtures concise and consistent. + """ + return Player.create( + p_name=name, + rarity=_make_rarity(), + cardset=_make_cardset(), + set_num=1, + pos_1=pos, + image="https://example.com/image.png", + mlbclub="TST", + franchise="TST", + description=f"Test player: {name}", + ) + + +def _make_team(abbrev: str, gmid: int, season: int = 11) -> Team: + """Create a Team row with all required (non-nullable) columns satisfied.""" + return Team.create( + abbrev=abbrev, + sname=abbrev, + lname=f"Team {abbrev}", + gmid=gmid, + gmname=f"gm_{abbrev.lower()}", + gsheet="https://docs.google.com/spreadsheets/test", + wallet=500, + team_value=1000, + collection_value=1000, + season=season, + is_ai=False, + ) + + +def make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats): + """Create a StratPlay row with sensible defaults for all required fields. + + Why we provide defaults for every stat column: StratPlay has many + IntegerField columns with default=0 at the model level, but supplying + them explicitly makes it clear what the baseline state of each play is + and keeps the helper signature stable if defaults change. + """ + defaults = dict( + on_base_code="000", + inning_half="top", + inning_num=1, + batting_order=1, + starting_outs=0, + away_score=0, + home_score=0, + pa=0, + ab=0, + hit=0, + run=0, + double=0, + triple=0, + homerun=0, + bb=0, + so=0, + hbp=0, + rbi=0, + sb=0, + cs=0, + outs=0, + sac=0, + ibb=0, + gidp=0, + bphr=0, + bpfo=0, + bp1b=0, + bplo=0, + ) + defaults.update(stats) + return StratPlay.create( + game=game, + play_num=play_num, + batter=batter, + batter_team=batter_team, + pitcher=pitcher, + pitcher_team=pitcher_team, + **defaults, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def team_a(): + return _make_team("TMA", gmid=1001) + + +@pytest.fixture +def team_b(): + return _make_team("TMB", gmid=1002) + + +@pytest.fixture +def player_batter(): + """A batter-type player for team A.""" + return _make_player("Batter One", pos="CF") + + +@pytest.fixture +def player_pitcher(): + """A pitcher-type player for team B.""" + return _make_player("Pitcher One", pos="SP") + + +@pytest.fixture +def game(team_a, team_b): + return StratGame.create( + season=11, + game_type="ranked", + away_team=team_a, + home_team=team_b, + ) + + +# --------------------------------------------------------------------------- +# Tests — Existing behavior (kept) +# --------------------------------------------------------------------------- + + +def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher, game): + """Batting stat totals from StratPlay rows are correctly accumulated. + + What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a + home run) for one batter. After update_season_stats(), the + BattingSeasonStats row should reflect the exact sum of all play fields. + + Why: The core of the batting aggregation pipeline. If any field mapping + is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution + scoring and leaderboards would silently report incorrect stats. + """ + # PA 1: single (hit=1, ab=1, pa=1) + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + ) + # PA 2: home run (hit=1, homerun=1, ab=1, pa=1, rbi=1, run=1) + make_play( + game, + 2, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + homerun=1, + rbi=1, + run=1, + outs=0, + ) + # PA 3: strikeout (ab=1, pa=1, so=1, outs=1) + make_play( + game, + 3, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + ) + # PA 4: walk (pa=1, bb=1) + make_play( + game, + 4, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + bb=1, + outs=0, + ) + + result = update_season_stats(game.id) + + assert result["batters_updated"] >= 1 + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 4 + assert stats.ab == 3 + assert stats.hits == 2 + assert stats.hr == 1 + assert stats.strikeouts == 1 + assert stats.bb == 1 + assert stats.rbi == 1 + assert stats.runs == 1 + assert stats.games == 1 + + +def test_single_game_pitching_stats( + team_a, team_b, player_batter, player_pitcher, game +): + """Pitching stat totals (outs, k, hits_allowed, bb_allowed) are correct. + + What: The same plays that create batting stats for the batter are also + the source for the pitcher's opposing stats. This test checks that + _recalc_pitching() correctly inverts batter-perspective fields. + + Why: The batter's 'so' becomes the pitcher's 'strikeouts', the batter's + 'hit' becomes 'hits_allowed', etc. Any transposition in this mapping + would corrupt pitcher stats silently. + """ + # Play 1: strikeout — batter so=1, outs=1 + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + ) + # Play 2: single — batter hit=1 + make_play( + game, + 2, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + ) + # Play 3: walk — batter bb=1 + make_play( + game, + 3, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + bb=1, + outs=0, + ) + + update_season_stats(game.id) + + stats = PitchingSeasonStats.get( + PitchingSeasonStats.player == player_pitcher, + PitchingSeasonStats.team == team_b, + PitchingSeasonStats.season == 11, + ) + assert stats.outs == 1 # one strikeout = one out recorded + assert stats.strikeouts == 1 # batter's so → pitcher's strikeouts + assert stats.hits_allowed == 1 # batter's hit → pitcher hits_allowed + assert stats.bb == 1 # batter's bb → pitcher bb (walks allowed) + assert stats.games == 1 + + +def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game): + """Decision.win=1 for a pitcher results in wins=1 in PitchingSeasonStats. + + What: Add a single StratPlay to establish the pitcher in pitching pairs, + then create a Decision row recording a win. Call update_season_stats() + and verify the wins column is 1. + + Why: Decisions are stored in a separate table from StratPlay. If + _recalc_decisions() fails to merge them (wrong FK lookup, key mismatch), + pitchers would always show 0 wins/losses/saves regardless of actual game + outcomes, breaking standings and evolution criteria. + """ + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + Decision.create( + season=11, + game=game, + pitcher=player_pitcher, + pitcher_team=team_b, + win=1, + loss=0, + is_save=0, + hold=0, + b_save=0, + is_start=True, + ) + + update_season_stats(game.id) + + stats = PitchingSeasonStats.get( + PitchingSeasonStats.player == player_pitcher, + PitchingSeasonStats.team == team_b, + PitchingSeasonStats.season == 11, + ) + assert stats.wins == 1 + assert stats.losses == 0 + + +def test_double_count_prevention(team_a, team_b, player_batter, player_pitcher, game): + """Calling update_season_stats() twice for the same game must not double the stats. + + What: Process a game once (pa=3), then immediately call the function + again with the same game_id. The second call finds the ProcessedGame + ledger row and returns early with 'skipped'=True. The resulting pa + should still be 3, not 6. + + Why: The bot infrastructure may deliver game-complete events more than + once (network retries, message replays). The ProcessedGame ledger + provides full idempotency for all replay scenarios. + """ + for i in range(3): + make_play( + game, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + first_result = update_season_stats(game.id) + assert "skipped" not in first_result + + second_result = update_season_stats(game.id) + assert second_result.get("skipped") is True + assert second_result["batters_updated"] == 0 + assert second_result["pitchers_updated"] == 0 + + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + # Must still be 3, not 6 + assert stats.pa == 3 + + +def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher): + """Stats from two separate games are summed in a single BattingSeasonStats row. + + What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team. + After both updates the stats row should show pa=5. + + Why: BattingSeasonStats is a season-long accumulator, not a per-game + snapshot. The full recalculation queries all StratPlay rows for the season, + so processing game 2 recomputes with all 5 PAs included. + """ + game1 = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + game2 = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + + # Game 1: 2 plate appearances + for i in range(2): + make_play( + game1, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + # Game 2: 3 plate appearances + for i in range(3): + make_play( + game2, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + update_season_stats(game1.id) + update_season_stats(game2.id) + + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 5 + assert stats.games == 2 + + +def test_two_team_game(team_a, team_b): + """Players from both teams in a game each get their own stats row. + + What: Create a batter+pitcher pair for team A and another pair for team B. + In the same game, team A bats against team B's pitcher and vice versa. + After update_season_stats(), both batters and both pitchers must have + correct, isolated stats rows. + + Why: A key correctness guarantee is that stats are attributed to the + correct (player, team) combination. If team attribution is wrong, + a player's stats could appear under the wrong franchise or be merged + with an opponent's row. + """ + batter_a = _make_player("Batter A", pos="CF") + pitcher_a = _make_player("Pitcher A", pos="SP") + batter_b = _make_player("Batter B", pos="CF") + pitcher_b = _make_player("Pitcher B", pos="SP") + + game = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + + # Team A bats against team B's pitcher (away half) + make_play( + game, + 1, + batter_a, + team_a, + pitcher_b, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + inning_half="top", + ) + make_play( + game, + 2, + batter_a, + team_a, + pitcher_b, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + inning_half="top", + ) + + # Team B bats against team A's pitcher (home half) + make_play( + game, + 3, + batter_b, + team_b, + pitcher_a, + team_a, + pa=1, + ab=1, + bb=1, + outs=0, + inning_half="bottom", + ) + + update_season_stats(game.id) + + # Team A's batter: 2 PA, 1 hit, 1 SO + stats_ba = BattingSeasonStats.get( + BattingSeasonStats.player == batter_a, + BattingSeasonStats.team == team_a, + ) + assert stats_ba.pa == 2 + assert stats_ba.hits == 1 + assert stats_ba.strikeouts == 1 + + # Team B's batter: 1 PA, 1 BB + stats_bb = BattingSeasonStats.get( + BattingSeasonStats.player == batter_b, + BattingSeasonStats.team == team_b, + ) + assert stats_bb.pa == 1 + assert stats_bb.bb == 1 + + # Team B's pitcher (faced team A's batter): 1 hit allowed, 1 strikeout + stats_pb = PitchingSeasonStats.get( + PitchingSeasonStats.player == pitcher_b, + PitchingSeasonStats.team == team_b, + ) + assert stats_pb.hits_allowed == 1 + assert stats_pb.strikeouts == 1 + + # Team A's pitcher (faced team B's batter): 1 BB allowed + stats_pa = PitchingSeasonStats.get( + PitchingSeasonStats.player == pitcher_a, + PitchingSeasonStats.team == team_a, + ) + assert stats_pa.bb == 1 + + +def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher): + """Out-of-order processing and re-delivery produce correct stats. + + What: Process game G+1 first (pa=2), then game G (pa=3). The full + recalculation approach means both calls query all StratPlay rows for the + season, so the final stats are always correct regardless of processing + order. Re-delivering game G returns 'skipped'=True and leaves stats at 5. + + Why: With full recalculation, out-of-order processing is inherently safe. + The ProcessedGame ledger still prevents redundant work on re-delivery. + """ + game_g = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + game_g1 = StratGame.create( + season=11, game_type="ranked", away_team=team_a, home_team=team_b + ) + + # Game G: 3 plate appearances + for i in range(3): + make_play( + game_g, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + # Game G+1: 2 plate appearances + for i in range(2): + make_play( + game_g1, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + # Process G+1 first, then G — simulates out-of-order delivery + update_season_stats(game_g1.id) + update_season_stats(game_g.id) + + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 5 # 3 (game G) + 2 (game G+1) + + # Re-deliver game G — must be blocked by ProcessedGame ledger + replay_result = update_season_stats(game_g.id) + assert replay_result.get("skipped") is True + + # Stats must remain at 5, not 8 + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 5 + + +# --------------------------------------------------------------------------- +# Tests — New (force recalc / idempotency / self-healing) +# --------------------------------------------------------------------------- + + +def test_force_recalc(team_a, team_b, player_batter, player_pitcher, game): + """Processing with force=True after initial processing does not double stats. + + What: Process a game normally (pa=3), then reprocess with force=True. + Because the recalculation reads all StratPlay rows and writes totals + (not deltas), the stats remain at pa=3 after the forced reprocess. + + Why: The force flag bypasses the ProcessedGame ledger skip, but since + the underlying data hasn't changed, the recalculated totals must be + identical. This proves the replacement upsert is safe. + """ + for i in range(3): + make_play( + game, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + ) + + first_result = update_season_stats(game.id) + assert first_result["batters_updated"] >= 1 + assert "skipped" not in first_result + + # Force reprocess — should NOT double stats + force_result = update_season_stats(game.id, force=True) + assert "skipped" not in force_result + assert force_result["batters_updated"] >= 1 + + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 3 + assert stats.hits == 3 + assert stats.games == 1 + + +def test_idempotent_reprocessing(team_a, team_b, player_batter, player_pitcher, game): + """Two consecutive force=True calls produce identical stats. + + What: Force-process the same game twice. Both calls recompute from + scratch, so the stats after the second call must be identical to the + stats after the first call. + + Why: Idempotency is a critical property of the recalculation engine. + External systems (admin scripts, retry loops) may call force=True + multiple times; the result must be stable. + """ + for i in range(4): + make_play( + game, + i + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + so=1 if i % 2 == 0 else 0, + hit=0 if i % 2 == 0 else 1, + outs=1 if i % 2 == 0 else 0, + ) + + update_season_stats(game.id, force=True) + stats_after_first = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + pa_1, hits_1, so_1 = ( + stats_after_first.pa, + stats_after_first.hits, + stats_after_first.strikeouts, + ) + + update_season_stats(game.id, force=True) + stats_after_second = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + + assert stats_after_second.pa == pa_1 + assert stats_after_second.hits == hits_1 + assert stats_after_second.strikeouts == so_1 + + +def test_partial_reprocessing_heals( + team_a, team_b, player_batter, player_pitcher, game +): + """Force reprocessing corrects manually corrupted stats. + + What: Process a game (pa=3, hits=2), then manually corrupt the stats + row (set pa=999). Force-reprocess the game. The stats should be healed + back to the correct totals (pa=3, hits=2). + + Why: This is the primary self-healing benefit of full recalculation. + Partial processing, bugs, or manual edits can corrupt season stats; + force=True recomputes from the source-of-truth StratPlay data and + writes the correct totals regardless of current row state. + """ + # PA 1: single + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + outs=0, + ) + # PA 2: double + make_play( + game, + 2, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + hit=1, + double=1, + outs=0, + ) + # PA 3: strikeout + make_play( + game, + 3, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + so=1, + outs=1, + ) + + update_season_stats(game.id) + + # Verify correct initial state + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 3 + assert stats.hits == 2 + assert stats.doubles == 1 + + # Corrupt the stats manually + stats.pa = 999 + stats.hits = 0 + stats.doubles = 50 + stats.save() + + # Verify corruption took effect + stats = BattingSeasonStats.get_by_id(stats.id) + assert stats.pa == 999 + + # Force reprocess — should heal the corruption + update_season_stats(game.id, force=True) + + stats = BattingSeasonStats.get( + BattingSeasonStats.player == player_batter, + BattingSeasonStats.team == team_a, + BattingSeasonStats.season == 11, + ) + assert stats.pa == 3 + assert stats.hits == 2 + assert stats.doubles == 1 + assert stats.strikeouts == 1 + assert stats.games == 1 + + +def test_decision_only_pitcher(team_a, team_b, player_batter, player_pitcher, game): + """A pitcher with a Decision but no StratPlay rows still gets stats recorded. + + What: Create a second pitcher who has a Decision (win) for the game but + does not appear in any StratPlay rows. After update_season_stats(), the + decision-only pitcher should have a PitchingSeasonStats row with wins=1 + and all play-level stats at 0. + + Why: In rare cases a pitcher may be credited with a decision without + recording any plays (e.g. inherited runner scoring rules, edge cases in + game simulation). The old code handled this in _apply_decisions(); the + new code must include Decision-scanned pitchers in _get_player_pairs(). + """ + relief_pitcher = _make_player("Relief Pitcher", pos="RP") + + # The main pitcher has plays + make_play( + game, + 1, + player_batter, + team_a, + player_pitcher, + team_b, + pa=1, + ab=1, + outs=1, + ) + + # The relief pitcher has a Decision but NO StratPlay rows + Decision.create( + season=11, + game=game, + pitcher=relief_pitcher, + pitcher_team=team_b, + win=1, + loss=0, + is_save=0, + hold=0, + b_save=0, + is_start=False, + ) + + update_season_stats(game.id) + + # The relief pitcher should have a PitchingSeasonStats row + stats = PitchingSeasonStats.get( + PitchingSeasonStats.player == relief_pitcher, + PitchingSeasonStats.team == team_b, + PitchingSeasonStats.season == 11, + ) + assert stats.wins == 1 + assert stats.games == 0 # no plays, so COUNT(DISTINCT game) = 0 + assert stats.outs == 0 + assert stats.strikeouts == 0 + assert stats.games_started == 0