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>
233 lines
8.9 KiB
Python
233 lines
8.9 KiB
Python
"""Refractor evaluator service (WP-08).
|
||
|
||
Force-recalculates a card's refractor state from career totals.
|
||
|
||
evaluate_card() is the main entry point:
|
||
1. Load career totals: SUM all BattingSeasonStats/PitchingSeasonStats rows for (player_id, team_id)
|
||
2. Determine track from card_state.track
|
||
3. Compute formula value (delegated to formula engine, WP-09)
|
||
4. Compare value to track thresholds to determine new_tier
|
||
5. Update card_state.current_value = computed value
|
||
6. Update card_state.current_tier = max(current_tier, new_tier) — no regression
|
||
(SKIPPED when dry_run=True)
|
||
7. Update card_state.fully_evolved = (current_tier >= 4)
|
||
(SKIPPED when dry_run=True)
|
||
8. Update card_state.last_evaluated_at = NOW()
|
||
|
||
When dry_run=True, only steps 5 and 8 are written (current_value and
|
||
last_evaluated_at). Steps 6–7 (current_tier and fully_evolved) are intentionally
|
||
skipped so that the evaluate-game endpoint can detect a pending tier-up and
|
||
delegate the tier write to apply_tier_boost(), which writes tier + variant
|
||
atomically. The return dict always includes both "computed_tier" (what the
|
||
formula says the tier should be) and "computed_fully_evolved" (whether the
|
||
computed tier implies full evolution) so callers can make decisions without
|
||
reading the database again.
|
||
|
||
Idempotent: calling multiple times with the same data produces the same result.
|
||
|
||
Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
|
||
and WP-09 (formula engine). Models and formula functions are imported lazily so
|
||
this module can be imported before those PRs merge.
|
||
"""
|
||
|
||
from datetime import datetime
|
||
import logging
|
||
|
||
|
||
class _CareerTotals:
|
||
"""Aggregated career stats for a (player_id, team_id) pair.
|
||
|
||
Passed to the formula engine as a stats-duck-type object with the attributes
|
||
required by compute_value_for_track:
|
||
batter: pa, hits, doubles, triples, hr
|
||
sp/rp: outs, strikeouts
|
||
"""
|
||
|
||
__slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts")
|
||
|
||
def __init__(self, pa, hits, doubles, triples, hr, outs, strikeouts):
|
||
self.pa = pa
|
||
self.hits = hits
|
||
self.doubles = doubles
|
||
self.triples = triples
|
||
self.hr = hr
|
||
self.outs = outs
|
||
self.strikeouts = strikeouts
|
||
|
||
|
||
def evaluate_card(
|
||
player_id: int,
|
||
team_id: int,
|
||
dry_run: bool = False,
|
||
_stats_model=None,
|
||
_state_model=None,
|
||
_compute_value_fn=None,
|
||
_tier_from_value_fn=None,
|
||
) -> dict:
|
||
"""Force-recalculate a card's refractor tier from career stats.
|
||
|
||
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
|
||
card_type) for (player_id, team_id) across all seasons, then delegates
|
||
formula computation and tier classification to the formula engine. The
|
||
result is written back to refractor_card_state and returned as a dict.
|
||
|
||
current_tier never decreases (no regression):
|
||
card_state.current_tier = max(card_state.current_tier, new_tier)
|
||
|
||
When dry_run=True, only current_value and last_evaluated_at are written —
|
||
current_tier and fully_evolved are NOT updated. This allows the caller
|
||
(evaluate-game endpoint) to detect a tier-up and delegate the tier write
|
||
to apply_tier_boost(), which writes tier + variant atomically. The return
|
||
dict always includes "computed_tier" (what the formula says the tier should
|
||
be) in addition to "current_tier" (what is actually stored in the DB).
|
||
|
||
Args:
|
||
player_id: Player primary key.
|
||
team_id: Team primary key.
|
||
dry_run: When True, skip writing current_tier and fully_evolved so
|
||
that apply_tier_boost() can write them atomically with variant
|
||
creation. Defaults to False (existing behaviour for the manual
|
||
/evaluate endpoint).
|
||
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats
|
||
(used in tests to inject a stub model with all stat fields).
|
||
_state_model: Override for RefractorCardState (used in tests to avoid
|
||
importing from db_engine before WP-05 merges).
|
||
_compute_value_fn: Override for formula_engine.compute_value_for_track
|
||
(used in tests to avoid importing formula_engine before WP-09 merges).
|
||
_tier_from_value_fn: Override for formula_engine.tier_from_value
|
||
(used in tests).
|
||
|
||
Returns:
|
||
Dict with current_tier, computed_tier, current_value, fully_evolved,
|
||
last_evaluated_at (ISO-8601 string). "computed_tier" reflects what
|
||
the formula computed; "current_tier" reflects what is stored in the DB
|
||
(which may differ when dry_run=True and a tier-up is pending).
|
||
|
||
Raises:
|
||
ValueError: If no refractor_card_state row exists for (player_id, team_id).
|
||
"""
|
||
if _state_model is None:
|
||
from app.db_engine import RefractorCardState as _state_model # noqa: PLC0415
|
||
|
||
if _compute_value_fn is None or _tier_from_value_fn is None:
|
||
from app.services.formula_engine import ( # noqa: PLC0415
|
||
compute_value_for_track,
|
||
tier_from_value,
|
||
)
|
||
|
||
if _compute_value_fn is None:
|
||
_compute_value_fn = compute_value_for_track
|
||
if _tier_from_value_fn is None:
|
||
_tier_from_value_fn = tier_from_value
|
||
|
||
# 1. Load card state
|
||
card_state = _state_model.get_or_none(
|
||
(_state_model.player_id == player_id) & (_state_model.team_id == team_id)
|
||
)
|
||
if card_state is None:
|
||
raise ValueError(
|
||
f"No refractor_card_state for player_id={player_id} team_id={team_id}"
|
||
)
|
||
|
||
# 2. Load career totals from the appropriate season stats table
|
||
if _stats_model is not None:
|
||
# Test override: use the injected stub model for all fields
|
||
rows = list(
|
||
_stats_model.select().where(
|
||
(_stats_model.player_id == player_id)
|
||
& (_stats_model.team_id == team_id)
|
||
)
|
||
)
|
||
totals = _CareerTotals(
|
||
pa=sum(r.pa for r in rows),
|
||
hits=sum(r.hits for r in rows),
|
||
doubles=sum(r.doubles for r in rows),
|
||
triples=sum(r.triples for r in rows),
|
||
hr=sum(r.hr for r in rows),
|
||
outs=sum(r.outs for r in rows),
|
||
strikeouts=sum(r.strikeouts for r in rows),
|
||
)
|
||
else:
|
||
from app.db_engine import (
|
||
BattingSeasonStats,
|
||
PitchingSeasonStats,
|
||
) # noqa: PLC0415
|
||
|
||
card_type = card_state.track.card_type
|
||
if card_type == "batter":
|
||
rows = list(
|
||
BattingSeasonStats.select().where(
|
||
(BattingSeasonStats.player == player_id)
|
||
& (BattingSeasonStats.team == team_id)
|
||
)
|
||
)
|
||
totals = _CareerTotals(
|
||
pa=sum(r.pa for r in rows),
|
||
hits=sum(r.hits for r in rows),
|
||
doubles=sum(r.doubles for r in rows),
|
||
triples=sum(r.triples for r in rows),
|
||
hr=sum(r.hr for r in rows),
|
||
outs=0,
|
||
strikeouts=sum(r.strikeouts for r in rows),
|
||
)
|
||
else:
|
||
rows = list(
|
||
PitchingSeasonStats.select().where(
|
||
(PitchingSeasonStats.player == player_id)
|
||
& (PitchingSeasonStats.team == team_id)
|
||
)
|
||
)
|
||
totals = _CareerTotals(
|
||
pa=0,
|
||
hits=0,
|
||
doubles=0,
|
||
triples=0,
|
||
hr=0,
|
||
outs=sum(r.outs for r in rows),
|
||
strikeouts=sum(r.strikeouts for r in rows),
|
||
)
|
||
|
||
# 3. Determine track
|
||
track = card_state.track
|
||
|
||
# 4. Compute formula value and new tier
|
||
value = _compute_value_fn(track.card_type, totals)
|
||
new_tier = _tier_from_value_fn(value, track)
|
||
|
||
# 5–8. Update card state.
|
||
now = datetime.now()
|
||
computed_tier = new_tier
|
||
computed_fully_evolved = computed_tier >= 4
|
||
|
||
# Always update value and timestamp; current_tier and fully_evolved are
|
||
# skipped when dry_run=True so that apply_tier_boost() can write them
|
||
# atomically with variant creation on tier-up.
|
||
card_state.current_value = value
|
||
card_state.last_evaluated_at = now
|
||
if not dry_run:
|
||
card_state.current_tier = max(card_state.current_tier, new_tier)
|
||
card_state.fully_evolved = card_state.current_tier >= 4
|
||
card_state.save()
|
||
|
||
logging.debug(
|
||
"refractor_eval: player=%s team=%s value=%.2f computed_tier=%s "
|
||
"stored_tier=%s dry_run=%s",
|
||
player_id,
|
||
team_id,
|
||
value,
|
||
computed_tier,
|
||
card_state.current_tier,
|
||
dry_run,
|
||
)
|
||
|
||
return {
|
||
"player_id": player_id,
|
||
"team_id": team_id,
|
||
"current_value": card_state.current_value,
|
||
"current_tier": card_state.current_tier,
|
||
"computed_tier": computed_tier,
|
||
"computed_fully_evolved": computed_fully_evolved,
|
||
"fully_evolved": card_state.fully_evolved,
|
||
"last_evaluated_at": card_state.last_evaluated_at.isoformat(),
|
||
}
|