paper-dynasty-database/app/services/evolution_init.py
Cal Corum 264c7dc73c feat(WP-10): pack opening hook — evolution_card_state initialization
Closes #75.

New file app/services/evolution_init.py:
- _determine_card_type(player): pure fn mapping pos_1 to 'batter'/'sp'/'rp'
- initialize_card_evolution(player_id, team_id, card_type): get_or_create
  EvolutionCardState with current_tier=0, current_value=0.0, fully_evolved=False
- Safe failure: all exceptions caught and logged, never raises
- Idempotent: duplicate calls for same (player_id, team_id) are no-ops
  and do NOT reset existing evolution progress

Modified app/routers_v2/cards.py:
- Add WP-10 hook after Card.bulk_create in the POST endpoint
- For each card posted, call _determine_card_type + initialize_card_evolution
- Wrapped in try/except so evolution failures cannot block pack opening
- Fix pre-existing lint violations (unused lc_id, bare f-string, unused e)

New file tests/test_evolution_init.py (16 tests, all passing):
- Unit: track assignment for batter / SP / RP / CP positions
- Integration: first card creates state with zeroed fields
- Integration: duplicate card is a no-op (progress not reset)
- Integration: different players on same team get separate states
- Integration: card_type routes to correct EvolutionTrack
- Integration: missing track returns None gracefully

Fix tests/test_evolution_models.py: correct PlayerSeasonStats import/usage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 13:41:05 -05:00

139 lines
4.4 KiB
Python

"""
WP-10: Pack opening hook — evolution_card_state initialization.
Public API
----------
initialize_card_evolution(player_id, team_id, card_type)
Get-or-create an EvolutionCardState for the (player_id, team_id) pair.
Returns the state instance on success, or None if initialization fails
(missing track, integrity error, etc.). Never raises.
_determine_card_type(player)
Pure function: inspect player.pos_1 and return 'sp', 'rp', or 'batter'.
Exported so the cards router and tests can call it directly.
Design notes
------------
- The function is intentionally fire-and-forget from the caller's perspective.
All exceptions are caught and logged; pack opening is never blocked.
- No EvolutionProgress rows are created here. Progress accumulation is a
separate concern handled by the stats-update pipeline (WP-07/WP-08).
- AI teams and Gauntlet teams skip Paperdex insertion (cards.py pattern);
we do NOT replicate that exclusion here — all teams get an evolution state
so that future rule changes don't require back-filling.
"""
import logging
from typing import Optional
from app.db_engine import DoesNotExist, EvolutionCardState, EvolutionTrack
logger = logging.getLogger(__name__)
def _determine_card_type(player) -> str:
"""Map a player's primary position to an evolution card_type string.
Rules (from WP-10 spec):
- pos_1 contains 'SP' -> 'sp'
- pos_1 contains 'RP' or 'CP' -> 'rp'
- anything else -> 'batter'
Args:
player: Any object with a ``pos_1`` attribute (Player model or stub).
Returns:
One of the strings 'batter', 'sp', 'rp'.
"""
pos = (player.pos_1 or "").upper()
if "SP" in pos:
return "sp"
if "RP" in pos or "CP" in pos:
return "rp"
return "batter"
def initialize_card_evolution(
player_id: int,
team_id: int,
card_type: str,
) -> Optional[EvolutionCardState]:
"""Get-or-create an EvolutionCardState for a newly acquired card.
Called by the cards POST endpoint after each card is inserted. The
function is idempotent: if a state row already exists for the
(player_id, team_id) pair it is returned unchanged — existing
evolution progress is never reset.
Args:
player_id: Primary key of the Player row (Player.player_id).
team_id: Primary key of the Team row (Team.id).
card_type: One of 'batter', 'sp', 'rp'. Determines which
EvolutionTrack is assigned to the new state.
Returns:
The existing or newly created EvolutionCardState instance, or
None if initialization could not complete (missing track seed
data, unexpected DB error, etc.).
"""
try:
track = EvolutionTrack.get(EvolutionTrack.card_type == card_type)
except DoesNotExist:
logger.warning(
"evolution_init: no EvolutionTrack found for card_type=%r "
"(player_id=%s, team_id=%s) — skipping state creation",
card_type,
player_id,
team_id,
)
return None
except Exception:
logger.exception(
"evolution_init: unexpected error fetching track "
"(card_type=%r, player_id=%s, team_id=%s)",
card_type,
player_id,
team_id,
)
return None
try:
state, created = EvolutionCardState.get_or_create(
player_id=player_id,
team_id=team_id,
defaults={
"track": track,
"current_tier": 0,
"current_value": 0.0,
"fully_evolved": False,
},
)
if created:
logger.debug(
"evolution_init: created EvolutionCardState id=%s "
"(player_id=%s, team_id=%s, card_type=%r)",
state.id,
player_id,
team_id,
card_type,
)
else:
logger.debug(
"evolution_init: state already exists id=%s "
"(player_id=%s, team_id=%s) — no-op",
state.id,
player_id,
team_id,
)
return state
except Exception:
logger.exception(
"evolution_init: failed to get_or_create state "
"(player_id=%s, team_id=%s, card_type=%r)",
player_id,
team_id,
card_type,
)
return None