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>
161 lines
5.1 KiB
Python
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}
|