paper-dynasty-database/app/routers_v2/evolution.py
Cal Corum b151923480 feat(WP-13): post-game callback endpoints for season stats and evolution
Implements two new API endpoints the bot calls after a game completes:

  POST /api/v2/season-stats/update-game/{game_id}
    Delegates to update_season_stats() service (WP-05). Returns
    {"updated": N, "skipped": bool} with idempotency via ProcessedGame ledger.

  POST /api/v2/evolution/evaluate-game/{game_id}
    Finds all (player_id, team_id) pairs from the game's StratPlay rows,
    calls evaluate_card() for each pair that has an EvolutionCardState,
    and returns {"evaluated": N, "tier_ups": [...]} with full tier-up detail.

New files:
  app/services/evolution_evaluator.py — evaluate_card() service (WP-08)
  tests/test_postgame_evolution.py    — 10 integration tests (all pass)

Modified files:
  app/routers_v2/season_stats.py — rewritten to delegate to the service
  app/routers_v2/evolution.py    — evaluate-game endpoint added
  app/main.py                    — season_stats router registered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:50:36 -05:00

161 lines
5.1 KiB
Python

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"])
@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.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 by calling evaluate_card(). Pairs without a state row are silently
skipped.
Tier advancement detection: the tier before and after evaluate_card() are
compared. If the tier increased, the player is added to the 'tier_ups'
list in the response.
Errors for individual players are logged but do not abort the batch; the
endpoint always returns a summary even if some evaluations fail.
Response:
{
"evaluated": N,
"tier_ups": [
{
"player_id": ...,
"team_id": ...,
"player_name": ...,
"old_tier": ...,
"new_tier": ...,
"current_value": ...,
"track_name": ...
},
...
]
}
"""
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
# Collect all unique (player_id, team_id) pairs who appeared in this game.
# Both batters and pitchers are included.
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:
# Check if an EvolutionCardState exists for this pair; skip if not.
state = (
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
.join(EvolutionTrack)
.where(
(EvolutionCardState.player == player_id)
& (EvolutionCardState.team == team_id)
)
.first()
)
if state is None:
continue
old_tier = state.current_tier
track_name = state.track.name
try:
result = evaluate_card(player_id, team_id)
except Exception as exc:
logger.error(
"evaluate-game/%d: player=%d team=%d failed: %s",
game_id,
player_id,
team_id,
exc,
exc_info=True,
)
continue
evaluated += 1
new_tier = result["current_tier"]
if new_tier > old_tier:
# Resolve player name for the tier-up notification.
try:
player_name = Player.get_by_id(player_id).p_name
except Exception:
player_name = f"player_{player_id}"
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["current_value"],
"track_name": track_name,
}
)
logger.info(
"evaluate-game/%d: evaluated=%d tier_ups=%d",
game_id,
evaluated,
len(tier_ups),
)
return {"evaluated": evaluated, "tier_ups": tier_ups}