diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index eeeb242..370188b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -55,8 +55,6 @@ jobs: context: . push: true tags: ${{ steps.tags.outputs.tags }} - cache-from: type=registry,ref=manticorum67/paper-dynasty-database:buildcache - cache-to: type=registry,ref=manticorum67/paper-dynasty-database:buildcache,mode=max - name: Tag release if: success() && github.ref == 'refs/heads/main' diff --git a/Dockerfile b/Dockerfile index 63899b0..8922bb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,12 @@ -FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11 +FROM python:3.11-slim-bookworm WORKDIR /usr/src/app -# Chrome dependency Instalation -# RUN apt-get update && apt-get install -y \ -# fonts-liberation \ -# libasound2 \ -# libatk-bridge2.0-0 \ -# libatk1.0-0 \ -# libatspi2.0-0 \ -# libcups2 \ -# libdbus-1-3 \ -# libdrm2 \ -# libgbm1 \ -# libgtk-3-0 \ -# # libgtk-4-1 \ -# libnspr4 \ -# libnss3 \ -# libwayland-client0 \ -# libxcomposite1 \ -# libxdamage1 \ -# libxfixes3 \ -# libxkbcommon0 \ -# libxrandr2 \ -# xdg-utils \ -# libu2f-udev \ -# libvulkan1 -# # Chrome instalation -# RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -# RUN apt-get install -y ./google-chrome-stable_current_amd64.deb -# RUN rm google-chrome-stable_current_amd64.deb -# # Check chrome version -# RUN echo "Chrome: " && google-chrome --version - COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt RUN playwright install chromium RUN playwright install-deps chromium -COPY ./app /app/app \ No newline at end of file +COPY ./app /usr/src/app/app + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"] diff --git a/app/db_engine.py b/app/db_engine.py index 30e7d7c..0b44ed1 100644 --- a/app/db_engine.py +++ b/app/db_engine.py @@ -1050,8 +1050,129 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique= Decision.add_index(decision_index) +class BattingSeasonStats(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + season = IntegerField() + games = IntegerField(default=0) + pa = IntegerField(default=0) + ab = IntegerField(default=0) + hits = IntegerField(default=0) + doubles = IntegerField(default=0) + triples = IntegerField(default=0) + hr = IntegerField(default=0) + rbi = IntegerField(default=0) + runs = IntegerField(default=0) + bb = IntegerField(default=0) + strikeouts = IntegerField(default=0) + hbp = IntegerField(default=0) + sac = IntegerField(default=0) + ibb = IntegerField(default=0) + gidp = IntegerField(default=0) + sb = IntegerField(default=0) + cs = IntegerField(default=0) + last_game = ForeignKeyField(StratGame, null=True) + last_updated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "batting_season_stats" + + +bss_unique_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.player, BattingSeasonStats.team, BattingSeasonStats.season), + unique=True, +) +BattingSeasonStats.add_index(bss_unique_index) + +bss_team_season_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.team, BattingSeasonStats.season), + unique=False, +) +BattingSeasonStats.add_index(bss_team_season_index) + +bss_player_season_index = ModelIndex( + BattingSeasonStats, + (BattingSeasonStats.player, BattingSeasonStats.season), + unique=False, +) +BattingSeasonStats.add_index(bss_player_season_index) + + +class PitchingSeasonStats(BaseModel): + player = ForeignKeyField(Player) + team = ForeignKeyField(Team) + season = IntegerField() + games = IntegerField(default=0) + games_started = IntegerField(default=0) + outs = IntegerField(default=0) + strikeouts = IntegerField(default=0) + bb = IntegerField(default=0) + hits_allowed = IntegerField(default=0) + runs_allowed = IntegerField(default=0) + earned_runs = IntegerField(default=0) + hr_allowed = IntegerField(default=0) + hbp = IntegerField(default=0) + wild_pitches = IntegerField(default=0) + balks = IntegerField(default=0) + wins = IntegerField(default=0) + losses = IntegerField(default=0) + holds = IntegerField(default=0) + saves = IntegerField(default=0) + blown_saves = IntegerField(default=0) + last_game = ForeignKeyField(StratGame, null=True) + last_updated_at = DateTimeField(null=True) + + class Meta: + database = db + table_name = "pitching_season_stats" + + +pitss_unique_index = ModelIndex( + PitchingSeasonStats, + (PitchingSeasonStats.player, PitchingSeasonStats.team, PitchingSeasonStats.season), + unique=True, +) +PitchingSeasonStats.add_index(pitss_unique_index) + +pitss_team_season_index = ModelIndex( + PitchingSeasonStats, + (PitchingSeasonStats.team, PitchingSeasonStats.season), + unique=False, +) +PitchingSeasonStats.add_index(pitss_team_season_index) + +pitss_player_season_index = ModelIndex( + PitchingSeasonStats, + (PitchingSeasonStats.player, PitchingSeasonStats.season), + unique=False, +) +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], safe=True) + db.create_tables( + [ + StratGame, + StratPlay, + Decision, + BattingSeasonStats, + PitchingSeasonStats, + ProcessedGame, + ], + safe=True, + ) class ScoutOpportunity(BaseModel): @@ -1089,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 64cbfc2..2949642 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ import logging import os +from contextlib import asynccontextmanager from datetime import datetime from fastapi import FastAPI, Request @@ -16,8 +17,9 @@ logging.basicConfig( # from fastapi.staticfiles import StaticFiles # from fastapi.templating import Jinja2Templates -from .db_engine import db -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, @@ -49,10 +51,23 @@ from .routers_v2 import ( stratplays, scout_opportunities, scout_claims, + evolution, + season_stats, ) + +@asynccontextmanager +async def lifespan(app): + # Startup: warm up the persistent Chromium browser + await get_browser() + yield + # Shutdown: clean up browser and playwright + await shutdown_browser() + + app = FastAPI( # root_path='/api', + lifespan=lifespan, responses={404: {"description": "Not found"}}, docs_url="/api/docs", redoc_url="/api/redoc", @@ -92,6 +107,8 @@ app.include_router(stratplays.router) 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") @@ -114,4 +131,4 @@ async def get_docs(req: Request): @app.get("/api/openapi.json", include_in_schema=False) async def openapi(): - return get_openapi(title="Paper Dynasty API", version=f"0.1.1", routes=app.routes) + return get_openapi(title="Paper Dynasty API", version="0.1.1", routes=app.routes) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/season_stats.py b/app/models/season_stats.py new file mode 100644 index 0000000..b47dfec --- /dev/null +++ b/app/models/season_stats.py @@ -0,0 +1,7 @@ +"""Season stats ORM models. + +Models are defined in db_engine alongside all other Peewee models; this +module re-exports them so callers can import from `app.models.season_stats`. +""" + +from ..db_engine import BattingSeasonStats, PitchingSeasonStats # noqa: F401 diff --git a/app/routers_v2/cards.py b/app/routers_v2/cards.py index 96b9774..a8614fc 100644 --- a/app/routers_v2/cards.py +++ b/app/routers_v2/cards.py @@ -6,12 +6,9 @@ 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'] -) +router = APIRouter(prefix="/api/v2/cards", tags=["cards"]) class CardPydantic(pydantic.BaseModel): @@ -26,12 +23,20 @@ class CardModel(pydantic.BaseModel): cards: List[CardPydantic] -@router.get('') +@router.get("") async def get_cards( - player_id: Optional[int] = None, team_id: Optional[int] = None, pack_id: Optional[int] = None, - value: Optional[int] = None, min_value: Optional[int] = None, max_value: Optional[int] = None, variant: Optional[int] = None, - order_by: Optional[str] = None, limit: Optional[int] = None, dupes: Optional[bool] = None, - csv: Optional[bool] = None): + player_id: Optional[int] = None, + team_id: Optional[int] = None, + pack_id: Optional[int] = None, + value: Optional[int] = None, + min_value: Optional[int] = None, + max_value: Optional[int] = None, + variant: Optional[int] = None, + order_by: Optional[str] = None, + limit: Optional[int] = None, + dupes: Optional[bool] = None, + csv: Optional[bool] = None, +): all_cards = Card.select() # if all_cards.count() == 0: @@ -65,7 +70,7 @@ async def get_cards( if max_value is not None: all_cards = all_cards.where(Card.value <= max_value) if order_by is not None: - if order_by.lower() == 'new': + if order_by.lower() == "new": all_cards = all_cards.order_by(-Card.id) else: all_cards = all_cards.order_by(Card.id) @@ -73,8 +78,10 @@ async def get_cards( all_cards = all_cards.limit(limit) if dupes: if team_id is None: - raise HTTPException(status_code=400, detail='Dupe checking must include a team_id') - logging.debug(f'dupe check') + raise HTTPException( + status_code=400, detail="Dupe checking must include a team_id" + ) + logging.debug("dupe check") p_query = Card.select(Card.player).where(Card.team_id == team_id) seen = set() dupes = [] @@ -90,38 +97,52 @@ async def get_cards( # raise HTTPException(status_code=404, detail=f'No cards found') if csv: - data_list = [['id', 'player', 'cardset', 'rarity', 'team', 'pack', 'value']] #, 'variant']] + data_list = [ + ["id", "player", "cardset", "rarity", "team", "pack", "value"] + ] # , 'variant']] for line in all_cards: data_list.append( [ - line.id, line.player.p_name, line.player.cardset, line.player.rarity, line.team.abbrev, line.pack, + line.id, + line.player.p_name, + line.player.cardset, + line.player.rarity, + line.team.abbrev, + line.pack, line.value, # line.variant ] ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_cards.count(), 'cards': []} - for x in all_cards: + card_list = list(all_cards) + player_ids = [c.player_id for c in card_list if c.player_id is not None] + dex_by_player = {} + if player_ids: + for row in Paperdex.select().where(Paperdex.player_id << player_ids): + dex_by_player.setdefault(row.player_id, []).append(row) + return_val = {"count": len(card_list), "cards": []} + for x in card_list: this_record = model_to_dict(x) - logging.debug(f'this_record: {this_record}') + logging.debug(f"this_record: {this_record}") - this_dex = Paperdex.select().where(Paperdex.player == x) - this_record['player']['paperdex'] = {'count': this_dex.count(), 'paperdex': []} - for y in this_dex: - this_record['player']['paperdex']['paperdex'].append(model_to_dict(y, recurse=False)) + entries = dex_by_player.get(x.player_id, []) + this_record["player"]["paperdex"] = { + "count": len(entries), + "paperdex": [model_to_dict(y, recurse=False) for y in entries], + } - return_val['cards'].append(this_record) + return_val["cards"].append(this_record) # return_val['cards'].append(model_to_dict(x)) return return_val -@router.get('/{card_id}') +@router.get("/{card_id}") async def v1_cards_get_one(card_id, csv: Optional[bool] = False): try: this_card = Card.get_by_id(card_id) @@ -130,34 +151,37 @@ async def v1_cards_get_one(card_id, csv: Optional[bool] = False): if csv: data_list = [ - ['id', 'player', 'team', 'pack', 'value'], - [this_card.id, this_card.player, this_card.team.abbrev, this_card.pack, this_card.value] + ["id", "player", "team", "pack", "value"], + [ + this_card.id, + this_card.player, + this_card.team.abbrev, + this_card.pack, + this_card.value, + ], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_card) return return_val -@router.post('') +@router.post("") async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post cards. This event has been logged.' + 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 this_team = Team.get_by_id(cards.cards[0].team_id) - if this_team.is_ai or 'Gauntlet' in this_team.abbrev: + if this_team.is_ai or "Gauntlet" in this_team.abbrev: inc_dex = False # new_dex = [] @@ -177,11 +201,28 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): with db.atomic(): Card.bulk_create(new_cards, batch_size=15) - cost_query = Player.update(cost=Player.cost + 1).where(Player.player_id << player_ids) + cost_query = Player.update(cost=Player.cost + 1).where( + Player.player_id << player_ids + ) cost_query.execute() # sheets.post_new_cards(SHEETS_AUTH, lc_id) - raise HTTPException(status_code=200, detail=f'{len(new_cards)} cards have been added') + # 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" + ) # @router.post('/ai-update') @@ -198,21 +239,27 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)): # raise HTTPException(status_code=200, detail=f'Just sent AI cards to sheets') -@router.post('/legal-check/{rarity_name}') +@router.post("/legal-check/{rarity_name}") async def v1_cards_legal_check( - rarity_name: str, card_id: list = Query(default=None), token: str = Depends(oauth2_scheme)): + rarity_name: str, + card_id: list = Query(default=None), + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') - raise HTTPException( - status_code=401, - detail='Unauthorized' - ) + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") if rarity_name not in CARDSETS.keys(): - return f'Rarity name {rarity_name} not a valid check' + return f"Rarity name {rarity_name} not a valid check" # Handle case where card_id is passed as a stringified list - if card_id and len(card_id) == 1 and isinstance(card_id[0], str) and card_id[0].startswith('['): + if ( + card_id + and len(card_id) == 1 + and isinstance(card_id[0], str) + and card_id[0].startswith("[") + ): import ast + try: card_id = [int(x) for x in ast.literal_eval(card_id[0])] except (ValueError, SyntaxError): @@ -222,70 +269,81 @@ async def v1_cards_legal_check( all_cards = Card.select().where(Card.id << card_id) for x in all_cards: - if x.player.cardset_id not in CARDSETS[rarity_name]['human']: + if x.player.cardset_id not in CARDSETS[rarity_name]["human"]: if x.player.p_name in x.player.description: bad_cards.append(x.player.description) else: - bad_cards.append(f'{x.player.description} {x.player.p_name}') + bad_cards.append(f"{x.player.description} {x.player.p_name}") - return {'count': len(bad_cards), 'bad_cards': bad_cards} + return {"count": len(bad_cards), "bad_cards": bad_cards} -@router.post('/post-update/{starting_id}') +@router.post("/post-update/{starting_id}") async def v1_cards_post_update(starting_id: int, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to update card lists. This event has been logged.' + detail="You are not authorized to update card lists. This event has been logged.", ) # sheets.post_new_cards(SHEETS_AUTH, starting_id) - raise HTTPException(status_code=200, detail=f'Just sent cards to sheets starting at ID {starting_id}') + raise HTTPException( + status_code=200, + detail=f"Just sent cards to sheets starting at ID {starting_id}", + ) -@router.post('/post-delete') +@router.post("/post-delete") async def v1_cards_post_delete(del_ids: str, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete card lists. This event has been logged.' + detail="You are not authorized to delete card lists. This event has been logged.", ) - logging.info(f'del_ids: {del_ids} / type: {type(del_ids)}') + logging.info(f"del_ids: {del_ids} / type: {type(del_ids)}") # sheets.post_deletion(SHEETS_AUTH, del_ids.split(',')) -@router.post('/wipe-team/{team_id}') +@router.post("/wipe-team/{team_id}") async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to wipe teams. This event has been logged.' + detail="You are not authorized to wipe teams. This event has been logged.", ) 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') t_query = Card.update(team=None).where(Card.team == this_team).execute() - return f'Wiped {t_query} cards' + return f"Wiped {t_query} cards" -@router.patch('/{card_id}') +@router.patch("/{card_id}") async def v1_cards_patch( - card_id, player_id: Optional[int] = None, team_id: Optional[int] = None, pack_id: Optional[int] = None, - value: Optional[int] = None, variant: Optional[int] = None, roster1_id: Optional[int] = None, roster2_id: Optional[int] = None, - roster3_id: Optional[int] = None, token: str = Depends(oauth2_scheme)): + card_id, + player_id: Optional[int] = None, + team_id: Optional[int] = None, + pack_id: Optional[int] = None, + value: Optional[int] = None, + variant: Optional[int] = None, + roster1_id: Optional[int] = None, + roster2_id: Optional[int] = None, + roster3_id: Optional[int] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch cards. This event has been logged.' + detail="You are not authorized to patch cards. This event has been logged.", ) try: this_card = Card.get_by_id(card_id) @@ -318,17 +376,17 @@ async def v1_cards_patch( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that rarity' + detail="Well slap my ass and call me a teapot; I could not save that rarity", ) -@router.delete('/{card_id}') +@router.delete("/{card_id}") async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete packs. This event has been logged.' + detail="You are not authorized to delete packs. This event has been logged.", ) try: this_card = Card.get_by_id(card_id) @@ -338,6 +396,6 @@ async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)): count = this_card.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Card {card_id} has been deleted') + raise HTTPException(status_code=200, detail=f"Card {card_id} has been deleted") else: - raise HTTPException(status_code=500, detail=f'Card {card_id} was not deleted') + raise HTTPException(status_code=500, detail=f"Card {card_id} was not deleted") diff --git a/app/routers_v2/evolution.py b/app/routers_v2/evolution.py new file mode 100644 index 0000000..d08e528 --- /dev/null +++ b/app/routers_v2/evolution.py @@ -0,0 +1,231 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +import logging +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( + card_type: Optional[str] = Query(default=None), + token: str = Depends(oauth2_scheme), +): + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionTrack + + query = EvolutionTrack.select() + if card_type is not None: + query = query.where(EvolutionTrack.card_type == card_type) + + items = [model_to_dict(t, recurse=False) for t in query] + return {"count": len(items), "items": items} + + +@router.get("/tracks/{track_id}") +async def get_track(track_id: int, token: str = Depends(oauth2_scheme)): + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionTrack + + try: + track = EvolutionTrack.get_by_id(track_id) + except Exception: + 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/paperdex.py b/app/routers_v2/paperdex.py index 41e309d..1108cbc 100644 --- a/app/routers_v2/paperdex.py +++ b/app/routers_v2/paperdex.py @@ -8,35 +8,33 @@ from pandas import DataFrame from ..db_engine import Paperdex, model_to_dict, Player, Cardset, Team, DoesNotExist from ..dependencies import oauth2_scheme, valid_token - -router = APIRouter( - prefix='/api/v2/paperdex', - tags=['paperdex'] -) +router = APIRouter(prefix="/api/v2/paperdex", tags=["paperdex"]) class PaperdexModel(pydantic.BaseModel): team_id: int player_id: int - created: Optional[int] = int(datetime.timestamp(datetime.now())*1000) + created: Optional[int] = int(datetime.timestamp(datetime.now()) * 1000) -@router.get('') +@router.get("") async def get_paperdex( - team_id: Optional[int] = None, player_id: Optional[int] = None, created_after: Optional[int] = None, - cardset_id: Optional[int] = None, created_before: Optional[int] = None, flat: Optional[bool] = False, - csv: Optional[bool] = None): + team_id: Optional[int] = None, + player_id: Optional[int] = None, + created_after: Optional[int] = None, + cardset_id: Optional[int] = None, + created_before: Optional[int] = None, + flat: Optional[bool] = False, + csv: Optional[bool] = None, + limit: Optional[int] = 500, +): all_dex = Paperdex.select().join(Player).join(Cardset).order_by(Paperdex.id) - if all_dex.count() == 0: - raise HTTPException(status_code=404, detail=f'There are no paperdex to filter') - if team_id is not None: all_dex = all_dex.where(Paperdex.team_id == team_id) if player_id is not None: all_dex = all_dex.where(Paperdex.player_id == player_id) if cardset_id is not None: - all_sets = Cardset.select().where(Cardset.id == cardset_id) all_dex = all_dex.where(Paperdex.player.cardset.id == cardset_id) if created_after is not None: # Convert milliseconds timestamp to datetime for PostgreSQL comparison @@ -47,61 +45,63 @@ async def get_paperdex( created_before_dt = datetime.fromtimestamp(created_before / 1000) all_dex = all_dex.where(Paperdex.created <= created_before_dt) - # if all_dex.count() == 0: - # db.close() - # raise HTTPException(status_code=404, detail=f'No paperdex found') + if limit is not None: + all_dex = all_dex.limit(limit) if csv: - data_list = [['id', 'team_id', 'player_id', 'created']] + data_list = [["id", "team_id", "player_id", "created"]] for line in all_dex: data_list.append( - [ - line.id, line.team.id, line.player.player_id, line.created - ] + [line.id, line.team.id, line.player.player_id, line.created] ) return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: - return_val = {'count': all_dex.count(), 'paperdex': []} - for x in all_dex: - return_val['paperdex'].append(model_to_dict(x, recurse=not flat)) + items = list(all_dex) + return_val = {"count": len(items), "paperdex": []} + for x in items: + return_val["paperdex"].append(model_to_dict(x, recurse=not flat)) return return_val -@router.get('/{paperdex_id}') +@router.get("/{paperdex_id}") async def get_one_paperdex(paperdex_id, csv: Optional[bool] = False): try: this_dex = Paperdex.get_by_id(paperdex_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}') + raise HTTPException( + status_code=404, detail=f"No paperdex found with id {paperdex_id}" + ) if csv: data_list = [ - ['id', 'team_id', 'player_id', 'created'], - [this_dex.id, this_dex.team.id, this_dex.player.id, this_dex.created] + ["id", "team_id", "player_id", "created"], + [this_dex.id, this_dex.team.id, this_dex.player.id, this_dex.created], ] return_val = DataFrame(data_list).to_csv(header=False, index=False) - return Response(content=return_val, media_type='text/csv') + return Response(content=return_val, media_type="text/csv") else: return_val = model_to_dict(this_dex) return return_val -@router.post('') +@router.post("") async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to post paperdex. This event has been logged.' + detail="You are not authorized to post paperdex. This event has been logged.", ) - dupe_dex = Paperdex.get_or_none(Paperdex.team_id == paperdex.team_id, Paperdex.player_id == paperdex.player_id) + dupe_dex = Paperdex.get_or_none( + Paperdex.team_id == paperdex.team_id, Paperdex.player_id == paperdex.player_id + ) if dupe_dex: return_val = model_to_dict(dupe_dex) return return_val @@ -109,7 +109,7 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch this_dex = Paperdex( team_id=paperdex.team_id, player_id=paperdex.player_id, - created=datetime.fromtimestamp(paperdex.created / 1000) + created=datetime.fromtimestamp(paperdex.created / 1000), ) saved = this_dex.save() @@ -119,24 +119,30 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that dex' + detail="Well slap my ass and call me a teapot; I could not save that dex", ) -@router.patch('/{paperdex_id}') +@router.patch("/{paperdex_id}") async def patch_paperdex( - paperdex_id, team_id: Optional[int] = None, player_id: Optional[int] = None, created: Optional[int] = None, - token: str = Depends(oauth2_scheme)): + paperdex_id, + team_id: Optional[int] = None, + player_id: Optional[int] = None, + created: Optional[int] = None, + token: str = Depends(oauth2_scheme), +): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to patch paperdex. This event has been logged.' + detail="You are not authorized to patch paperdex. This event has been logged.", ) try: this_dex = Paperdex.get_by_id(paperdex_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}') + raise HTTPException( + status_code=404, detail=f"No paperdex found with id {paperdex_id}" + ) if team_id is not None: this_dex.team_id = team_id @@ -151,40 +157,43 @@ async def patch_paperdex( else: raise HTTPException( status_code=418, - detail='Well slap my ass and call me a teapot; I could not save that rarity' + detail="Well slap my ass and call me a teapot; I could not save that rarity", ) -@router.delete('/{paperdex_id}') +@router.delete("/{paperdex_id}") async def delete_paperdex(paperdex_id, token: str = Depends(oauth2_scheme)): if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') + logging.warning("Bad Token: [REDACTED]") raise HTTPException( status_code=401, - detail='You are not authorized to delete rewards. This event has been logged.' + detail="You are not authorized to delete rewards. This event has been logged.", ) try: this_dex = Paperdex.get_by_id(paperdex_id) except DoesNotExist: - raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}') + raise HTTPException( + status_code=404, detail=f"No paperdex found with id {paperdex_id}" + ) count = this_dex.delete_instance() if count == 1: - raise HTTPException(status_code=200, detail=f'Paperdex {this_dex} has been deleted') - else: - raise HTTPException(status_code=500, detail=f'Paperdex {this_dex} was not deleted') - - -@router.post('/wipe-ai') -async def wipe_ai_paperdex(token: str = Depends(oauth2_scheme)): - if not valid_token(token): - logging.warning('Bad Token: [REDACTED]') raise HTTPException( - status_code=401, - detail='Unauthorized' + status_code=200, detail=f"Paperdex {this_dex} has been deleted" + ) + else: + raise HTTPException( + status_code=500, detail=f"Paperdex {this_dex} was not deleted" ) - g_teams = Team.select().where(Team.abbrev.contains('Gauntlet')) + +@router.post("/wipe-ai") +async def wipe_ai_paperdex(token: str = Depends(oauth2_scheme)): + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + g_teams = Team.select().where(Team.abbrev.contains("Gauntlet")) count = Paperdex.delete().where(Paperdex.team << g_teams).execute() - return f'Deleted {count} records' + return f"Deleted {count} records" diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 25a8363..7ebd1b5 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -9,7 +9,9 @@ from typing import Optional, List, Literal import logging import pydantic from pandas import DataFrame -from playwright.async_api import async_playwright +import asyncio as _asyncio + +from playwright.async_api import async_playwright, Browser, Playwright from ..card_creation import get_batter_card_data, get_pitcher_card_data from ..db_engine import ( @@ -31,6 +33,62 @@ from ..db_engine import ( from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token +# --------------------------------------------------------------------------- +# Persistent browser instance (WP-02) +# --------------------------------------------------------------------------- + +_browser: Browser | None = None +_playwright: Playwright | None = None +_browser_lock = _asyncio.Lock() + + +async def get_browser() -> Browser: + """Get or create persistent Chromium browser instance. + + Reuses a single browser across all card renders, eliminating the ~1-1.5s + per-request launch/teardown overhead. Automatically reconnects if the + browser process has died. + + Uses an asyncio.Lock to prevent concurrent requests from racing to + launch multiple Chromium processes. + """ + global _browser, _playwright + async with _browser_lock: + if _browser is None or not _browser.is_connected(): + if _playwright is not None: + try: + await _playwright.stop() + except Exception: + pass + _playwright = await async_playwright().start() + _browser = await _playwright.chromium.launch( + args=["--no-sandbox", "--disable-dev-shm-usage"] + ) + return _browser + + +async def shutdown_browser(): + """Clean shutdown of the persistent browser. + + Called by the FastAPI lifespan handler on application exit so the + Chromium process is not left orphaned. + """ + global _browser, _playwright + if _browser: + try: + await _browser.close() + except Exception: + pass + _browser = None + if _playwright: + try: + await _playwright.stop() + except Exception: + pass + _playwright = None + + +# --------------------------------------------------------------------------- # Franchise normalization: Convert city+team names to city-agnostic team names # This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics') FRANCHISE_NORMALIZE = { @@ -144,7 +202,7 @@ async def get_players( ): all_players = Player.select() if all_players.count() == 0: - raise HTTPException(status_code=404, detail=f"There are no players to filter") + raise HTTPException(status_code=404, detail="There are no players to filter") if name is not None: all_players = all_players.where(fn.Lower(Player.p_name) == name.lower()) @@ -674,7 +732,7 @@ async def get_batter_card( ) headers = {"Cache-Control": "public, max-age=86400"} - filename = ( + _filename = ( f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}" ) if ( @@ -799,16 +857,17 @@ async def get_batter_card( logging.debug(f"body:\n{html_response.body.decode('UTF-8')}") file_path = f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png" - async with async_playwright() as p: - browser = await p.chromium.launch() - page = await browser.new_page() + browser = await get_browser() + page = await browser.new_page(viewport={"width": 1280, "height": 720}) + try: await page.set_content(html_response.body.decode("UTF-8")) await page.screenshot( path=file_path, type="png", clip={"x": 0.0, "y": 0, "width": 1200, "height": 600}, ) - await browser.close() + finally: + await page.close() # hti = Html2Image( # browser='chrome', diff --git a/app/routers_v2/scouting.py b/app/routers_v2/scouting.py index 0e51f87..2c44bba 100644 --- a/app/routers_v2/scouting.py +++ b/app/routers_v2/scouting.py @@ -36,14 +36,3 @@ async def get_player_keys(player_id: list = Query(default=None)): return_val = {"count": len(all_keys), "keys": [dict(x) for x in all_keys]} return return_val - - -@router.post("/live-update/pitching") -def live_update_pitching(files: BattingFiles, token: str = Depends(oauth2_scheme)): - if not valid_token(token): - logging.warning("Bad Token: [REDACTED]") - raise HTTPException( - status_code=401, detail="You are not authorized to initiate live updates." - ) - - return files.dict() diff --git a/app/routers_v2/season_stats.py b/app/routers_v2/season_stats.py new file mode 100644 index 0000000..65dd787 --- /dev/null +++ b/app/routers_v2/season_stats.py @@ -0,0 +1,72 @@ +"""Season stats API endpoints. + +Covers WP-13 (Post-Game Callback Integration): + POST /api/v2/season-stats/update-game/{game_id} + +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 ..dependencies import oauth2_scheme, valid_token + +router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"]) + +logger = logging.getLogger(__name__) + + +@router.post("/update-game/{game_id}") +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. + + 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 + + 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, "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") + + from ..services.season_stats import update_season_stats + from ..db_engine import DoesNotExist + + try: + result = update_season_stats(game_id, force=force) + except DoesNotExist: + raise HTTPException(status_code=404, detail=f"Game {game_id} not found") + 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}", + ) + + 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..dc7265c 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,9 +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 = "" total_cost = 0 @@ -1098,7 +1095,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 +1239,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 +1290,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 +1388,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 +1528,69 @@ 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 → EvolutionTrack (for card_type filtering and + threshold context) and EvolutionCardState → Player (for player_name), + both eager-loaded in a single query. Results are paginated via + page/per_page (1-indexed pages); items are ordered by current_tier DESC, + current_value DESC so the most-progressed cards appear first. + + 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}, + plus a ``player_name`` field sourced from the Player table. + """ + if not valid_token(token): + logging.warning("Bad Token: [REDACTED]") + raise HTTPException(status_code=401, detail="Unauthorized") + + from ..db_engine import EvolutionCardState, EvolutionTrack, Player + from ..routers_v2.evolution import _build_card_state_response + + query = ( + EvolutionCardState.select(EvolutionCardState, EvolutionTrack, Player) + .join(EvolutionTrack) + .switch(EvolutionCardState) + .join(Player) + .where(EvolutionCardState.team == team_id) + .order_by( + EvolutionCardState.current_tier.desc(), + EvolutionCardState.current_value.desc(), + ) + ) + + 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 = [] + for state in page_query: + item = _build_card_state_response(state) + item["player_name"] = state.player.p_name + items.append(item) + return {"count": total, "items": items} diff --git a/app/seed/__init__.py b/app/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/seed/evolution_tracks.json b/app/seed/evolution_tracks.json new file mode 100644 index 0000000..4f06142 --- /dev/null +++ b/app/seed/evolution_tracks.json @@ -0,0 +1,29 @@ +[ + { + "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 new file mode 100644 index 0000000..3314a97 --- /dev/null +++ b/app/seed/evolution_tracks.py @@ -0,0 +1,66 @@ +"""Seed script for EvolutionTrack records. + +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 logging +from pathlib import Path + +from app.db_engine import EvolutionTrack + +logger = logging.getLogger(__name__) + +_JSON_PATH = Path(__file__).parent / "evolution_tracks.json" + + +def seed_evolution_tracks() -> list[EvolutionTrack]: + """Upsert evolution tracks from JSON seed data. + + Returns a list of EvolutionTrack instances that were created or updated. + """ + raw = _JSON_PATH.read_text(encoding="utf-8") + track_defs = json.loads(raw) + + 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"], + }, + ) + + 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/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/evolution_evaluator.py b/app/services/evolution_evaluator.py new file mode 100644 index 0000000..b6da6b9 --- /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, UTC +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(UTC) + 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 new file mode 100644 index 0000000..2e55a65 --- /dev/null +++ b/app/services/formula_engine.py @@ -0,0 +1,119 @@ +"""Formula engine for evolution value computation (WP-09). + +Three pure functions that compute a numeric evolution value from career stats, +plus helpers for formula dispatch and tier classification. + +Stats attributes expected by each formula: + compute_batter_value: pa, hits, doubles, triples, hr (from BattingSeasonStats) + compute_sp_value: outs, strikeouts (from PitchingSeasonStats) + compute_rp_value: outs, strikeouts (from PitchingSeasonStats) +""" + +from typing import Protocol + + +class BatterStats(Protocol): + pa: int + hits: int + doubles: int + triples: int + hr: int + + +class PitcherStats(Protocol): + outs: int + strikeouts: int + + +# --------------------------------------------------------------------------- +# Core formula functions +# --------------------------------------------------------------------------- + + +def compute_batter_value(stats) -> float: + """PA + (TB x 2) where TB = 1B + 2x2B + 3x3B + 4xHR.""" + 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) + + +def _pitcher_value(stats) -> float: + return stats.outs / 3 + stats.strikeouts + + +def compute_sp_value(stats) -> float: + """IP + K where IP = outs / 3.""" + return _pitcher_value(stats) + + +def compute_rp_value(stats) -> float: + """IP + K (same formula as SP; thresholds differ).""" + return _pitcher_value(stats) + + +# --------------------------------------------------------------------------- +# Dispatch and tier helpers +# --------------------------------------------------------------------------- + +_FORMULA_DISPATCH = { + "batter": compute_batter_value, + "sp": compute_sp_value, + "rp": compute_rp_value, +} + + +def compute_value_for_track(card_type: str, stats) -> float: + """Dispatch to the correct formula function by card_type. + + Args: + card_type: One of 'batter', 'sp', 'rp'. + stats: Object with the attributes required by the formula. + + Raises: + ValueError: If card_type is not recognised. + """ + fn = _FORMULA_DISPATCH.get(card_type) + if fn is None: + raise ValueError(f"Unknown card_type: {card_type!r}") + return fn(stats) + + +def tier_from_value(value: float, track) -> int: + """Return the evolution tier (0-4) for a computed value against a track. + + Tier boundaries are inclusive on the lower end: + T0: value < t1 + T1: t1 <= value < t2 + T2: t2 <= value < t3 + T3: t3 <= value < t4 + T4: value >= t4 + + Args: + value: Computed formula value. + 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_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 diff --git a/app/services/season_stats.py b/app/services/season_stats.py new file mode 100644 index 0000000..991bfa5 --- /dev/null +++ b/app/services/season_stats.py @@ -0,0 +1,452 @@ +""" +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)) + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b1c8d25 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,5 @@ +[tool.ruff] +[tool.ruff.lint] +# db_engine.py uses `from peewee import *` throughout — a pre-existing +# codebase pattern. Suppress wildcard-import warnings for that file only. +per-file-ignores = { "app/db_engine.py" = ["F401", "F403", "F405"], "app/main.py" = ["E402", "F541"] } 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/storage/templates/player_card.html b/storage/templates/player_card.html index 83750ed..9cdf814 100644 --- a/storage/templates/player_card.html +++ b/storage/templates/player_card.html @@ -2,9 +2,6 @@
{% include 'style.html' %} - - -