Complete rename of the card progression system from "Evolution" to "Refractor" across all code, routes, models, services, seeds, and tests. - Route prefix: /api/v2/evolution → /api/v2/refractor - Model classes: EvolutionTrack → RefractorTrack, etc. - 12 files renamed, 8 files content-edited - New migration to rename DB tables - 117 tests pass, no logic changes Co-Authored-By: Claude Opus 4.6 (1M context) <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/refractor", tags=["refractor"])
|
|
|
|
# Tier -> threshold attribute name. Index = current_tier; value is the
|
|
# attribute on RefractorTrack 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 a RefractorCardState 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 RefractorTrack
|
|
|
|
query = RefractorTrack.select()
|
|
if card_type is not None:
|
|
query = query.where(RefractorTrack.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 RefractorTrack
|
|
|
|
try:
|
|
track = RefractorTrack.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 RefractorCardState for a card identified by its Card.id.
|
|
|
|
Resolves card_id -> (player_id, team_id) via the Card table, then looks
|
|
up the matching RefractorCardState 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 RefractorCardState yet.
|
|
"""
|
|
if not valid_token(token):
|
|
logging.warning("Bad Token: [REDACTED]")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
from ..db_engine import Card, RefractorCardState, RefractorTrack, 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 refractor state for this (player, team) pair, joining the
|
|
# track so a single query resolves both rows.
|
|
try:
|
|
state = (
|
|
RefractorCardState.select(RefractorCardState, RefractorTrack)
|
|
.join(RefractorTrack)
|
|
.where(
|
|
(RefractorCardState.player == card.player_id)
|
|
& (RefractorCardState.team == card.team_id)
|
|
)
|
|
.get()
|
|
)
|
|
except DoesNotExist:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"No refractor 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 refractor state for a card from career stats.
|
|
|
|
Resolves card_id to (player_id, team_id), then recomputes the refractor
|
|
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.refractor_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 refractor 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 a RefractorCardState, re-computes the refractor
|
|
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 RefractorCardState, RefractorTrack, Player, StratPlay
|
|
from ..services.refractor_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 = RefractorCardState.get_or_none(
|
|
(RefractorCardState.player_id == player_id)
|
|
& (RefractorCardState.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"Refractor eval failed for player={player_id} team={team_id}: {exc}"
|
|
)
|
|
|
|
return {"evaluated": evaluated, "tier_ups": tier_ups}
|