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>
232 lines
8.0 KiB
Python
232 lines
8.0 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"])
|
|
|
|
# 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}
|