When a card reaches a new Refractor tier during game evaluation, the system now creates a boosted variant card with modified ratings. This connects the Phase 2 Foundation pure functions (PR #176) to the live evaluate-game endpoint. Key changes: - evaluate_card() gains dry_run parameter so apply_tier_boost() is the sole writer of current_tier, ensuring atomicity with variant creation - apply_tier_boost() orchestrates the full boost flow: source card lookup, boost application, variant card + ratings creation, audit record, and atomic state mutations inside db.atomic() - evaluate_game() calls evaluate_card(dry_run=True) then loops through intermediate tiers on tier-up, with error isolation per player - Display stat helpers compute fresh avg/obp/slg for variant cards - REFRACTOR_BOOST_ENABLED env var provides a kill switch - 51 new tests: unit tests for display stats, integration tests for orchestration, HTTP endpoint tests for multi-tier jumps, pitcher path, kill switch, atomicity, idempotency, and cross-player isolation - Clarified all "79-sum" references to note the 108-total card invariant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
423 lines
16 KiB
Python
423 lines
16 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, player_name=None) -> 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 computed fields:
|
|
- 'next_threshold': threshold for the tier immediately above (None when fully evolved).
|
|
- 'progress_pct': current_value / next_threshold * 100, rounded to 1 decimal
|
|
(None when fully evolved or next_threshold is zero).
|
|
- 'player_name': included when passed (e.g. from a list join); omitted otherwise.
|
|
|
|
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
|
|
|
|
progress_pct = None
|
|
if next_threshold is not None and next_threshold > 0:
|
|
progress_pct = round((state.current_value / next_threshold) * 100, 1)
|
|
|
|
result = {
|
|
"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 hasattr(state.last_evaluated_at, "isoformat")
|
|
else state.last_evaluated_at or None
|
|
),
|
|
"track": track_dict,
|
|
"next_threshold": next_threshold,
|
|
"progress_pct": progress_pct,
|
|
}
|
|
|
|
if player_name is not None:
|
|
result["player_name"] = player_name
|
|
|
|
return result
|
|
|
|
|
|
@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")
|
|
async def list_card_states(
|
|
team_id: int = Query(...),
|
|
card_type: Optional[str] = Query(default=None),
|
|
tier: Optional[int] = Query(default=None, ge=0, le=4),
|
|
season: Optional[int] = Query(default=None),
|
|
progress: Optional[str] = Query(default=None),
|
|
evaluated_only: bool = Query(default=True),
|
|
limit: int = Query(default=10, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
token: str = Depends(oauth2_scheme),
|
|
):
|
|
"""List RefractorCardState rows for a team, with optional filters and pagination.
|
|
|
|
Required:
|
|
team_id -- filter to this team's cards; returns empty list if team has no states
|
|
|
|
Optional filters:
|
|
card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type
|
|
tier -- filter by current_tier (0-4)
|
|
season -- filter to players who have batting or pitching season stats in that
|
|
season (EXISTS subquery against batting/pitching_season_stats)
|
|
progress -- 'close' = only cards within 80% of their next tier threshold;
|
|
fully evolved cards are always excluded from this filter
|
|
evaluated_only -- default True; when True, excludes cards where last_evaluated_at
|
|
is NULL (cards created but never run through the evaluator).
|
|
Set to False to include all rows, including zero-value placeholders.
|
|
|
|
Pagination:
|
|
limit -- page size (1-100, default 10)
|
|
offset -- items to skip (default 0)
|
|
|
|
Response: {"count": N, "items": [...]}
|
|
count is the total matching rows before limit/offset.
|
|
Each item includes player_name and progress_pct in addition to the
|
|
standard single-card response fields.
|
|
|
|
Sort order: current_tier DESC, current_value DESC.
|
|
"""
|
|
if not valid_token(token):
|
|
logging.warning("Bad Token: [REDACTED]")
|
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
|
|
from ..db_engine import (
|
|
RefractorCardState,
|
|
RefractorTrack,
|
|
Player,
|
|
BattingSeasonStats,
|
|
PitchingSeasonStats,
|
|
fn,
|
|
Case,
|
|
JOIN,
|
|
)
|
|
|
|
query = (
|
|
RefractorCardState.select(RefractorCardState, RefractorTrack, Player)
|
|
.join(RefractorTrack)
|
|
.switch(RefractorCardState)
|
|
.join(
|
|
Player, JOIN.LEFT_OUTER, on=(RefractorCardState.player == Player.player_id)
|
|
)
|
|
.where(RefractorCardState.team == team_id)
|
|
.order_by(
|
|
RefractorCardState.current_tier.desc(),
|
|
RefractorCardState.current_value.desc(),
|
|
)
|
|
)
|
|
|
|
if card_type is not None:
|
|
query = query.where(RefractorTrack.card_type == card_type)
|
|
|
|
if tier is not None:
|
|
query = query.where(RefractorCardState.current_tier == tier)
|
|
|
|
if season is not None:
|
|
batter_exists = BattingSeasonStats.select().where(
|
|
(BattingSeasonStats.player == RefractorCardState.player)
|
|
& (BattingSeasonStats.team == RefractorCardState.team)
|
|
& (BattingSeasonStats.season == season)
|
|
)
|
|
pitcher_exists = PitchingSeasonStats.select().where(
|
|
(PitchingSeasonStats.player == RefractorCardState.player)
|
|
& (PitchingSeasonStats.team == RefractorCardState.team)
|
|
& (PitchingSeasonStats.season == season)
|
|
)
|
|
query = query.where(fn.EXISTS(batter_exists) | fn.EXISTS(pitcher_exists))
|
|
|
|
if progress == "close":
|
|
next_threshold_expr = Case(
|
|
RefractorCardState.current_tier,
|
|
(
|
|
(0, RefractorTrack.t1_threshold),
|
|
(1, RefractorTrack.t2_threshold),
|
|
(2, RefractorTrack.t3_threshold),
|
|
(3, RefractorTrack.t4_threshold),
|
|
),
|
|
None,
|
|
)
|
|
query = query.where(
|
|
(RefractorCardState.fully_evolved == False) # noqa: E712
|
|
& (RefractorCardState.current_value >= next_threshold_expr * 0.8)
|
|
)
|
|
|
|
if evaluated_only:
|
|
query = query.where(RefractorCardState.last_evaluated_at.is_null(False))
|
|
|
|
total = query.count()
|
|
items = []
|
|
for state in query.offset(offset).limit(limit):
|
|
player_name = None
|
|
try:
|
|
player_name = state.player.p_name
|
|
except Exception:
|
|
pass
|
|
items.append(_build_card_state_response(state, player_name=player_name))
|
|
|
|
return {"count": total, "items": items}
|
|
|
|
|
|
@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")
|
|
|
|
import os
|
|
|
|
from ..db_engine import RefractorCardState, Player, StratPlay
|
|
from ..services.refractor_boost import apply_tier_boost
|
|
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 = []
|
|
|
|
boost_enabled = os.environ.get("REFRACTOR_BOOST_ENABLED", "true").lower() != "false"
|
|
|
|
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
|
|
# Use dry_run=True so that current_tier is NOT written here.
|
|
# apply_tier_boost() writes current_tier + variant atomically on
|
|
# tier-up. If no tier-up occurs, apply_tier_boost is not called
|
|
# and the tier stays at old_tier (correct behaviour).
|
|
result = evaluate_card(player_id, team_id, dry_run=True)
|
|
evaluated += 1
|
|
|
|
# Use computed_tier (what the formula says) to detect tier-ups.
|
|
computed_tier = result.get("computed_tier", old_tier)
|
|
if computed_tier > old_tier:
|
|
player_name = "Unknown"
|
|
try:
|
|
p = Player.get_by_id(player_id)
|
|
player_name = p.p_name
|
|
except Exception:
|
|
pass
|
|
|
|
# Phase 2: Apply rating boosts for each tier gained.
|
|
# apply_tier_boost() writes current_tier + variant atomically.
|
|
# If it fails, current_tier stays at old_tier — automatic retry next game.
|
|
boost_result = None
|
|
if not boost_enabled:
|
|
# Boost disabled via REFRACTOR_BOOST_ENABLED=false.
|
|
# Skip notification — current_tier was not written (dry_run),
|
|
# so reporting a tier-up would be a false notification.
|
|
continue
|
|
|
|
card_type = state.track.card_type if state.track else None
|
|
if card_type:
|
|
last_successful_tier = old_tier
|
|
failing_tier = old_tier + 1
|
|
try:
|
|
for tier in range(old_tier + 1, computed_tier + 1):
|
|
failing_tier = tier
|
|
boost_result = apply_tier_boost(
|
|
player_id, team_id, tier, card_type
|
|
)
|
|
last_successful_tier = tier
|
|
except Exception as boost_exc:
|
|
logger.warning(
|
|
f"Refractor boost failed for player={player_id} "
|
|
f"team={team_id} tier={failing_tier}: {boost_exc}"
|
|
)
|
|
# Report only the tiers that actually succeeded.
|
|
# If none succeeded, skip the tier_up notification entirely.
|
|
if last_successful_tier == old_tier:
|
|
continue
|
|
# At least one intermediate tier was committed; report that.
|
|
computed_tier = last_successful_tier
|
|
else:
|
|
# No card_type means no track — skip boost and skip notification.
|
|
# A false tier-up notification must not be sent when the boost
|
|
# was never applied (current_tier was never written to DB).
|
|
logger.warning(
|
|
f"Refractor boost skipped for player={player_id} "
|
|
f"team={team_id}: no card_type on track"
|
|
)
|
|
continue
|
|
|
|
tier_up_entry = {
|
|
"player_id": player_id,
|
|
"team_id": team_id,
|
|
"player_name": player_name,
|
|
"old_tier": old_tier,
|
|
"new_tier": computed_tier,
|
|
"current_value": result.get("current_value", 0),
|
|
"track_name": state.track.name if state.track else "Unknown",
|
|
}
|
|
|
|
# Non-breaking addition: include boost info when available.
|
|
if boost_result:
|
|
tier_up_entry["variant_created"] = boost_result.get(
|
|
"variant_created"
|
|
)
|
|
|
|
tier_ups.append(tier_up_entry)
|
|
|
|
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}
|