Compare commits
13 Commits
b151923480
...
a2d2aa3d31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2d2aa3d31 | ||
| e66a1ea9c4 | |||
|
|
503e570da5 | ||
|
|
583bde73a9 | ||
| f5b24cf8f2 | |||
| 55f98b8246 | |||
|
|
64b6225c41 | ||
|
|
fe3dc0e4d2 | ||
| c69082e3ee | |||
|
|
d1d9159edf | ||
|
|
db6f8d9b66 | ||
|
|
264c7dc73c | ||
|
|
c935c50a96 |
@ -1152,9 +1152,25 @@ pitss_player_season_index = ModelIndex(
|
||||
PitchingSeasonStats.add_index(pitss_player_season_index)
|
||||
|
||||
|
||||
class ProcessedGame(BaseModel):
|
||||
game = ForeignKeyField(StratGame, primary_key=True)
|
||||
processed_at = DateTimeField(default=datetime.now)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "processed_game"
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
db.create_tables(
|
||||
[StratGame, StratPlay, Decision, BattingSeasonStats, PitchingSeasonStats],
|
||||
[
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
],
|
||||
safe=True,
|
||||
)
|
||||
|
||||
@ -1194,75 +1210,6 @@ if not SKIP_TABLE_CREATION:
|
||||
db.create_tables([ScoutOpportunity, ScoutClaim], safe=True)
|
||||
|
||||
|
||||
class PlayerSeasonStats(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
season = IntegerField()
|
||||
|
||||
# Batting stats
|
||||
games_batting = IntegerField(default=0)
|
||||
pa = IntegerField(default=0)
|
||||
ab = IntegerField(default=0)
|
||||
hits = IntegerField(default=0)
|
||||
doubles = IntegerField(default=0)
|
||||
triples = IntegerField(default=0)
|
||||
hr = IntegerField(default=0)
|
||||
bb = IntegerField(default=0)
|
||||
hbp = IntegerField(default=0)
|
||||
so = IntegerField(default=0)
|
||||
rbi = IntegerField(default=0)
|
||||
runs = IntegerField(default=0)
|
||||
sb = IntegerField(default=0)
|
||||
cs = IntegerField(default=0)
|
||||
|
||||
# Pitching stats
|
||||
games_pitching = IntegerField(default=0)
|
||||
outs = IntegerField(default=0)
|
||||
k = IntegerField(default=0)
|
||||
bb_allowed = IntegerField(default=0)
|
||||
hits_allowed = IntegerField(default=0)
|
||||
hr_allowed = IntegerField(default=0)
|
||||
wins = IntegerField(default=0)
|
||||
losses = IntegerField(default=0)
|
||||
saves = IntegerField(default=0)
|
||||
holds = IntegerField(default=0)
|
||||
blown_saves = IntegerField(default=0)
|
||||
|
||||
# Meta
|
||||
last_game = ForeignKeyField(StratGame, null=True)
|
||||
last_updated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "player_season_stats"
|
||||
|
||||
|
||||
player_season_stats_unique_index = ModelIndex(
|
||||
PlayerSeasonStats,
|
||||
(PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season),
|
||||
unique=True,
|
||||
)
|
||||
PlayerSeasonStats.add_index(player_season_stats_unique_index)
|
||||
|
||||
player_season_stats_team_season_index = ModelIndex(
|
||||
PlayerSeasonStats,
|
||||
(PlayerSeasonStats.team, PlayerSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PlayerSeasonStats.add_index(player_season_stats_team_season_index)
|
||||
|
||||
player_season_stats_player_season_index = ModelIndex(
|
||||
PlayerSeasonStats,
|
||||
(PlayerSeasonStats.player, PlayerSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PlayerSeasonStats.add_index(player_season_stats_player_season_index)
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
db.create_tables([PlayerSeasonStats], safe=True)
|
||||
|
||||
|
||||
class EvolutionTrack(BaseModel):
|
||||
name = CharField(unique=True)
|
||||
card_type = CharField() # 'batter', 'sp', 'rp'
|
||||
|
||||
@ -6,6 +6,7 @@ from pandas import DataFrame
|
||||
|
||||
from ..db_engine import db, Card, model_to_dict, Team, Player, Pack, Paperdex, CARDSETS, DoesNotExist
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
from ..services.evolution_init import _determine_card_type, initialize_card_evolution
|
||||
|
||||
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
|
||||
|
||||
@ -80,7 +81,7 @@ async def get_cards(
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Dupe checking must include a team_id"
|
||||
)
|
||||
logging.debug(f"dupe check")
|
||||
logging.debug("dupe check")
|
||||
p_query = Card.select(Card.player).where(Card.team_id == team_id)
|
||||
seen = set()
|
||||
dupes = []
|
||||
@ -176,9 +177,6 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
status_code=401,
|
||||
detail="You are not authorized to post cards. This event has been logged.",
|
||||
)
|
||||
last_card = Card.select(Card.id).order_by(-Card.id).limit(1)
|
||||
lc_id = last_card[0].id
|
||||
|
||||
new_cards = []
|
||||
player_ids = []
|
||||
inc_dex = True
|
||||
@ -209,6 +207,19 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
cost_query.execute()
|
||||
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
|
||||
|
||||
# WP-10: initialize evolution state for each new card (fire-and-forget)
|
||||
for x in cards.cards:
|
||||
try:
|
||||
this_player = Player.get_by_id(x.player_id)
|
||||
card_type = _determine_card_type(this_player)
|
||||
initialize_card_evolution(x.player_id, x.team_id, card_type)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"evolution hook: unexpected error for player_id=%s team_id=%s",
|
||||
x.player_id,
|
||||
x.team_id,
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=200, detail=f"{len(new_cards)} cards have been added"
|
||||
)
|
||||
@ -307,7 +318,7 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
|
||||
|
||||
try:
|
||||
this_team = Team.get_by_id(team_id)
|
||||
except DoesNotExist as e:
|
||||
except DoesNotExist:
|
||||
logging.error(f'/cards/wipe-team/{team_id} - could not find team')
|
||||
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')
|
||||
|
||||
|
||||
@ -9,6 +9,50 @@ 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(
|
||||
@ -45,38 +89,87 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
|
||||
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 by calling evaluate_card(). Pairs without a state row are silently
|
||||
skipped.
|
||||
|
||||
Tier advancement detection: the tier before and after evaluate_card() are
|
||||
compared. If the tier increased, the player is added to the 'tier_ups'
|
||||
list in the response.
|
||||
|
||||
Errors for individual players are logged but do not abort the batch; the
|
||||
endpoint always returns a summary even if some evaluations fail.
|
||||
|
||||
Response:
|
||||
{
|
||||
"evaluated": N,
|
||||
"tier_ups": [
|
||||
{
|
||||
"player_id": ...,
|
||||
"team_id": ...,
|
||||
"player_name": ...,
|
||||
"old_tier": ...,
|
||||
"new_tier": ...,
|
||||
"current_value": ...,
|
||||
"track_name": ...
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
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]")
|
||||
@ -85,8 +178,6 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
||||
from ..db_engine import EvolutionCardState, EvolutionTrack, Player, StratPlay
|
||||
from ..services.evolution_evaluator import evaluate_card
|
||||
|
||||
# Collect all unique (player_id, team_id) pairs who appeared in this game.
|
||||
# Both batters and pitchers are included.
|
||||
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
||||
|
||||
pairs: set[tuple[int, int]] = set()
|
||||
@ -100,61 +191,41 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
||||
tier_ups = []
|
||||
|
||||
for player_id, team_id in pairs:
|
||||
# Check if an EvolutionCardState exists for this pair; skip if not.
|
||||
state = (
|
||||
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
|
||||
.join(EvolutionTrack)
|
||||
.where(
|
||||
(EvolutionCardState.player == player_id)
|
||||
& (EvolutionCardState.team == team_id)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if state is None:
|
||||
continue
|
||||
|
||||
old_tier = state.current_tier
|
||||
track_name = state.track.name
|
||||
|
||||
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.error(
|
||||
"evaluate-game/%d: player=%d team=%d failed: %s",
|
||||
game_id,
|
||||
player_id,
|
||||
team_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
continue
|
||||
|
||||
evaluated += 1
|
||||
|
||||
new_tier = result["current_tier"]
|
||||
if new_tier > old_tier:
|
||||
# Resolve player name for the tier-up notification.
|
||||
try:
|
||||
player_name = Player.get_by_id(player_id).p_name
|
||||
except Exception:
|
||||
player_name = f"player_{player_id}"
|
||||
|
||||
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["current_value"],
|
||||
"track_name": track_name,
|
||||
}
|
||||
logger.warning(
|
||||
f"Evolution eval failed for player={player_id} team={team_id}: {exc}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"evaluate-game/%d: evaluated=%d tier_ups=%d",
|
||||
game_id,
|
||||
evaluated,
|
||||
len(tier_ups),
|
||||
)
|
||||
return {"evaluated": evaluated, "tier_ups": tier_ups}
|
||||
|
||||
@ -135,15 +135,15 @@ async def get_teams(
|
||||
if has_guide is not None:
|
||||
# Use boolean comparison (PostgreSQL-compatible)
|
||||
if not has_guide:
|
||||
all_teams = all_teams.where(Team.has_guide == False)
|
||||
all_teams = all_teams.where(Team.has_guide == False) # noqa: E712
|
||||
else:
|
||||
all_teams = all_teams.where(Team.has_guide == True)
|
||||
all_teams = all_teams.where(Team.has_guide == True) # noqa: E712
|
||||
|
||||
if is_ai is not None:
|
||||
if not is_ai:
|
||||
all_teams = all_teams.where(Team.is_ai == False)
|
||||
all_teams = all_teams.where(Team.is_ai == False) # noqa: E712
|
||||
else:
|
||||
all_teams = all_teams.where(Team.is_ai == True)
|
||||
all_teams = all_teams.where(Team.is_ai == True) # noqa: E712
|
||||
|
||||
if event_id is not None:
|
||||
all_teams = all_teams.where(Team.event_id == event_id)
|
||||
@ -254,24 +254,24 @@ def get_scouting_dfs(allowed_players, position: str):
|
||||
if position in ["LF", "CF", "RF"]:
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm OF"
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name="Arm OF"
|
||||
)
|
||||
)
|
||||
elif position == "C":
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name=f"Arm C"
|
||||
dict([(x.player.player_id, x.arm) for x in positions]), name="Arm C"
|
||||
)
|
||||
)
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.pb) for x in positions]), name=f"PB C"
|
||||
dict([(x.player.player_id, x.pb) for x in positions]), name="PB C"
|
||||
)
|
||||
)
|
||||
series_list.append(
|
||||
pd.Series(
|
||||
dict([(x.player.player_id, x.overthrow) for x in positions]),
|
||||
name=f"Throw C",
|
||||
name="Throw C",
|
||||
)
|
||||
)
|
||||
|
||||
@ -314,11 +314,11 @@ async def get_team_lineup(
|
||||
all_players = Player.select().where(Player.franchise == this_team.sname)
|
||||
|
||||
if difficulty_name == "exhibition":
|
||||
logging.info(f"pulling an exhibition lineup")
|
||||
logging.info("pulling an exhibition lineup")
|
||||
if cardset_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Must provide at least one cardset_id for exhibition lineups",
|
||||
detail="Must provide at least one cardset_id for exhibition lineups",
|
||||
)
|
||||
legal_players = all_players.where(Player.cardset_id << cardset_id)
|
||||
|
||||
@ -404,17 +404,17 @@ async def get_team_lineup(
|
||||
# if x.battingcard.player.p_name not in player_names:
|
||||
# starting_nine['DH'] = x.battingcard.player
|
||||
# break
|
||||
logging.debug(f"Searching for a DH!")
|
||||
logging.debug("Searching for a DH!")
|
||||
dh_query = legal_players.order_by(Player.cost.desc())
|
||||
for x in dh_query:
|
||||
logging.debug(f"checking {x.p_name} for {position}")
|
||||
if x.p_name not in player_names and "P" not in x.pos_1:
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine["DH"]["player"] = model_to_dict(x)
|
||||
try:
|
||||
vl, vr, total_ops = get_bratings(x.player_id)
|
||||
except AttributeError as e:
|
||||
logging.debug(f"Could not find batting lines")
|
||||
except AttributeError:
|
||||
logging.debug("Could not find batting lines")
|
||||
else:
|
||||
# starting_nine[position]['vl'] = vl
|
||||
# starting_nine[position]['vr'] = vr
|
||||
@ -429,12 +429,12 @@ async def get_team_lineup(
|
||||
for x in dh_query:
|
||||
logging.debug(f"checking {x.p_name} for {position}")
|
||||
if x.p_name not in player_names:
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine["DH"]["player"] = model_to_dict(x)
|
||||
try:
|
||||
vl, vr, total_ops = get_bratings(x.player_id)
|
||||
except AttributeError as e:
|
||||
logging.debug(f"Could not find batting lines")
|
||||
except AttributeError:
|
||||
logging.debug("Could not find batting lines")
|
||||
else:
|
||||
vl, vr, total_ops = get_bratings(x.player_id)
|
||||
starting_nine[position]["vl"] = vl["obp"] + vl["slg"]
|
||||
@ -464,7 +464,7 @@ async def get_team_lineup(
|
||||
x.player.p_name not in player_names
|
||||
and x.player.p_name.lower() != pitcher_name
|
||||
):
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine[position]["player"] = model_to_dict(x.player)
|
||||
vl, vr, total_ops = get_bratings(x.player.player_id)
|
||||
starting_nine[position]["vl"] = vl
|
||||
@ -542,7 +542,7 @@ async def get_team_lineup(
|
||||
x.player.p_name not in player_names
|
||||
and x.player.p_name.lower() != pitcher_name
|
||||
):
|
||||
logging.debug(f"adding!")
|
||||
logging.debug("adding!")
|
||||
starting_nine[position]["player"] = model_to_dict(x.player)
|
||||
vl, vr, total_ops = get_bratings(x.player.player_id)
|
||||
starting_nine[position]["vl"] = vl["obp"] + vl["slg"]
|
||||
@ -649,11 +649,11 @@ async def get_team_sp(
|
||||
all_players = Player.select().where(Player.franchise == this_team.sname)
|
||||
|
||||
if difficulty_name == "exhibition":
|
||||
logging.info(f"pulling an exhibition lineup")
|
||||
logging.info("pulling an exhibition lineup")
|
||||
if cardset_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Must provide at least one cardset_id for exhibition lineups",
|
||||
detail="Must provide at least one cardset_id for exhibition lineups",
|
||||
)
|
||||
legal_players = all_players.where(Player.cardset_id << cardset_id)
|
||||
|
||||
@ -778,11 +778,11 @@ async def get_team_rp(
|
||||
)
|
||||
|
||||
if difficulty_name == "exhibition":
|
||||
logging.info(f"pulling an exhibition RP")
|
||||
logging.info("pulling an exhibition RP")
|
||||
if cardset_id is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Must provide at least one cardset_id for exhibition lineups",
|
||||
detail="Must provide at least one cardset_id for exhibition lineups",
|
||||
)
|
||||
legal_players = all_players.where(Player.cardset_id << cardset_id)
|
||||
|
||||
@ -934,7 +934,7 @@ async def get_team_rp(
|
||||
)
|
||||
return this_player
|
||||
|
||||
logging.info(f"Falling to last chance pitcher")
|
||||
logging.info("Falling to last chance pitcher")
|
||||
all_relievers = sort_pitchers(
|
||||
PitchingCard.select()
|
||||
.join(Player)
|
||||
@ -957,7 +957,7 @@ async def get_team_record(team_id: int, season: int):
|
||||
all_games = StratGame.select().where(
|
||||
((StratGame.away_team_id == team_id) | (StratGame.home_team_id == team_id))
|
||||
& (StratGame.season == season)
|
||||
& (StratGame.short_game == False)
|
||||
& (StratGame.short_game == False) # noqa: E712
|
||||
)
|
||||
|
||||
template = {
|
||||
@ -1049,8 +1049,6 @@ async def team_buy_players(team_id: int, ids: str, ts: str):
|
||||
detail=f"You are not authorized to buy {this_team.abbrev} cards. This event has been logged.",
|
||||
)
|
||||
|
||||
last_card = Card.select(Card.id).order_by(-Card.id).limit(1)
|
||||
lc_id = last_card[0].id
|
||||
|
||||
all_ids = ids.split(",")
|
||||
conf_message = ""
|
||||
@ -1098,7 +1096,7 @@ async def team_buy_players(team_id: int, ids: str, ts: str):
|
||||
if this_player.rarity.value >= 2:
|
||||
new_notif = Notification(
|
||||
created=datetime.now(),
|
||||
title=f"Price Change",
|
||||
title="Price Change",
|
||||
desc="Modified by buying and selling",
|
||||
field_name=f"{this_player.description} "
|
||||
f"{this_player.p_name if this_player.p_name not in this_player.description else ''}",
|
||||
@ -1242,7 +1240,7 @@ async def team_sell_cards(team_id: int, ids: str, ts: str):
|
||||
if this_player.rarity.value >= 2:
|
||||
new_notif = Notification(
|
||||
created=datetime.now(),
|
||||
title=f"Price Change",
|
||||
title="Price Change",
|
||||
desc="Modified by buying and selling",
|
||||
field_name=f"{this_player.description} "
|
||||
f"{this_player.p_name if this_player.p_name not in this_player.description else ''}",
|
||||
@ -1293,7 +1291,7 @@ async def get_team_cards(team_id, csv: Optional[bool] = True):
|
||||
.order_by(-Card.player.rarity.value, Card.player.p_name)
|
||||
)
|
||||
if all_cards.count() == 0:
|
||||
raise HTTPException(status_code=404, detail=f"No cards found")
|
||||
raise HTTPException(status_code=404, detail="No cards found")
|
||||
|
||||
card_vals = [model_to_dict(x) for x in all_cards]
|
||||
|
||||
@ -1391,7 +1389,7 @@ async def team_season_update(new_season: int, token: str = Depends(oauth2_scheme
|
||||
detail="You are not authorized to post teams. This event has been logged.",
|
||||
)
|
||||
|
||||
r_query = Team.update(
|
||||
Team.update(
|
||||
ranking=1000, season=new_season, wallet=Team.wallet + 250, has_guide=False
|
||||
).execute()
|
||||
current = Current.latest()
|
||||
@ -1531,3 +1529,57 @@ async def delete_team(team_id, token: str = Depends(oauth2_scheme)):
|
||||
raise HTTPException(status_code=200, detail=f"Team {team_id} has been deleted")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f"Team {team_id} was not deleted")
|
||||
|
||||
|
||||
@router.get("/{team_id}/evolutions")
|
||||
async def list_team_evolutions(
|
||||
team_id: int,
|
||||
card_type: Optional[str] = Query(default=None),
|
||||
tier: Optional[int] = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=10, ge=1, le=100),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
"""List all EvolutionCardState rows for a team, with optional filters.
|
||||
|
||||
Joins EvolutionCardState to EvolutionTrack so that card_type filtering
|
||||
works without a second query. Results are paginated via page/per_page
|
||||
(1-indexed pages); items are ordered by player_id for stable ordering.
|
||||
|
||||
Query parameters:
|
||||
card_type -- filter to states whose track.card_type matches (e.g. 'batter', 'sp')
|
||||
tier -- filter to states at a specific current_tier (0-4)
|
||||
page -- 1-indexed page number (default 1)
|
||||
per_page -- items per page (default 10, max 100)
|
||||
|
||||
Response shape:
|
||||
{"count": N, "items": [card_state_with_threshold_context, ...]}
|
||||
|
||||
Each item in 'items' has the same shape as GET /evolution/cards/{card_id}.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
from ..db_engine import EvolutionCardState, EvolutionTrack
|
||||
from ..routers_v2.evolution import _build_card_state_response
|
||||
|
||||
query = (
|
||||
EvolutionCardState.select(EvolutionCardState, EvolutionTrack)
|
||||
.join(EvolutionTrack)
|
||||
.where(EvolutionCardState.team == team_id)
|
||||
.order_by(EvolutionCardState.player_id)
|
||||
)
|
||||
|
||||
if card_type is not None:
|
||||
query = query.where(EvolutionTrack.card_type == card_type)
|
||||
|
||||
if tier is not None:
|
||||
query = query.where(EvolutionCardState.current_tier == tier)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * per_page
|
||||
page_query = query.offset(offset).limit(per_page)
|
||||
|
||||
items = [_build_card_state_response(state) for state in page_query]
|
||||
return {"count": total, "items": items}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Evolution evaluator service (WP-08 / WP-13).
|
||||
"""Evolution evaluator service (WP-08).
|
||||
|
||||
Force-recalculates a card's evolution state from career totals.
|
||||
|
||||
@ -14,7 +14,7 @@ evaluate_card() is the main entry point:
|
||||
|
||||
Idempotent: calling multiple times with the same data produces the same result.
|
||||
|
||||
Depends on WP-02 (PlayerSeasonStats), WP-04 (EvolutionCardState), and WP-09
|
||||
Depends on WP-05 (EvolutionCardState), WP-07 (PlayerSeasonStats), and WP-09
|
||||
(formula engine). Models and formula functions are imported lazily so this
|
||||
module can be imported before those PRs merge.
|
||||
"""
|
||||
@ -26,13 +26,10 @@ import logging
|
||||
class _CareerTotals:
|
||||
"""Aggregated career stats for a (player_id, team_id) pair.
|
||||
|
||||
Passed to the formula engine as a duck-type stats object with the
|
||||
attributes required by compute_value_for_track:
|
||||
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
|
||||
|
||||
Note: PlayerSeasonStats stores pitcher strikeouts as 'k'; this class
|
||||
exposes them as 'strikeouts' to satisfy the formula engine Protocol.
|
||||
sp/rp: outs, k
|
||||
"""
|
||||
|
||||
__slots__ = ("pa", "hits", "doubles", "triples", "hr", "outs", "strikeouts")
|
||||
@ -69,10 +66,11 @@ def evaluate_card(
|
||||
player_id: Player primary key.
|
||||
team_id: Team primary key.
|
||||
_stats_model: Override for PlayerSeasonStats (used in tests to avoid
|
||||
importing from db_engine before models are available).
|
||||
_state_model: Override for EvolutionCardState (used in tests).
|
||||
importing from db_engine before WP-07 merges).
|
||||
_state_model: Override for EvolutionCardState (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).
|
||||
(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).
|
||||
|
||||
@ -123,8 +121,6 @@ def evaluate_card(
|
||||
triples=sum(r.triples for r in rows),
|
||||
hr=sum(r.hr for r in rows),
|
||||
outs=sum(r.outs for r in rows),
|
||||
# PlayerSeasonStats stores pitcher Ks as 'k'; expose as 'strikeouts'
|
||||
# to satisfy the PitcherStats Protocol expected by the formula engine.
|
||||
strikeouts=sum(r.k for r in rows),
|
||||
)
|
||||
|
||||
@ -135,7 +131,7 @@ def evaluate_card(
|
||||
value = _compute_value_fn(track.card_type, totals)
|
||||
new_tier = _tier_from_value_fn(value, track)
|
||||
|
||||
# 5-8. Update card state (no tier regression)
|
||||
# 5–8. Update card state (no tier regression)
|
||||
now = datetime.utcnow()
|
||||
card_state.current_value = value
|
||||
card_state.current_tier = max(card_state.current_tier, new_tier)
|
||||
|
||||
138
app/services/evolution_init.py
Normal file
138
app/services/evolution_init.py
Normal file
@ -0,0 +1,138 @@
|
||||
"""
|
||||
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
|
||||
@ -1,20 +1,19 @@
|
||||
"""
|
||||
season_stats.py — Incremental PlayerSeasonStats update logic.
|
||||
season_stats.py — Incremental BattingSeasonStats and PitchingSeasonStats update logic.
|
||||
|
||||
Called once per completed StratGame to accumulate batting and pitching
|
||||
statistics into the player_season_stats table.
|
||||
statistics into the batting_season_stats and pitching_season_stats tables
|
||||
respectively.
|
||||
|
||||
Idempotency limitation: re-delivery of a game is detected by checking
|
||||
whether any PlayerSeasonStats row still carries that game_id as last_game.
|
||||
This guard only works if no later game has been processed for the same
|
||||
players — if game G+1 is processed first, a re-delivery of game G will
|
||||
bypass the guard and double-count stats. A persistent processed-game
|
||||
ledger is needed for full idempotency across out-of-order re-delivery
|
||||
(see issue #105).
|
||||
Idempotency: re-delivery of a game (including out-of-order re-delivery)
|
||||
is detected via an atomic INSERT into the ProcessedGame ledger table
|
||||
keyed on game_id. The first call for a given game_id succeeds; all
|
||||
subsequent calls return early with "skipped": True without modifying
|
||||
any stats rows.
|
||||
|
||||
Peewee upsert strategy:
|
||||
- SQLite: on_conflict_replace() — simplest path, deletes + re-inserts
|
||||
- PostgreSQL: on_conflict() with EXCLUDED — true atomic increment via SQL
|
||||
- SQLite: read-modify-write inside db.atomic() transaction
|
||||
- PostgreSQL: ON CONFLICT ... DO UPDATE with column-level EXCLUDED increments
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -26,8 +25,10 @@ from peewee import EXCLUDED
|
||||
|
||||
from app.db_engine import (
|
||||
db,
|
||||
BattingSeasonStats,
|
||||
Decision,
|
||||
PlayerSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
)
|
||||
@ -41,27 +42,31 @@ def _build_batting_groups(plays):
|
||||
"""
|
||||
Aggregate per-play batting stats by (batter_id, batter_team_id).
|
||||
|
||||
Only plays where pa > 0 are counted toward games_batting, but all
|
||||
Only plays where pa > 0 are counted toward games, but all
|
||||
play-level stat fields are accumulated regardless of pa value so
|
||||
that rare edge cases (e.g. sac bunt without official PA) are
|
||||
correctly included in the totals.
|
||||
|
||||
Returns a dict keyed by (batter_id, batter_team_id) with stat dicts.
|
||||
Returns a dict keyed by (batter_id, batter_team_id) with stat dicts
|
||||
matching BattingSeasonStats column names.
|
||||
"""
|
||||
groups = defaultdict(
|
||||
lambda: {
|
||||
"games_batting": 0,
|
||||
"games": 0,
|
||||
"pa": 0,
|
||||
"ab": 0,
|
||||
"hits": 0,
|
||||
"doubles": 0,
|
||||
"triples": 0,
|
||||
"hr": 0,
|
||||
"bb": 0,
|
||||
"hbp": 0,
|
||||
"so": 0,
|
||||
"rbi": 0,
|
||||
"runs": 0,
|
||||
"bb": 0,
|
||||
"strikeouts": 0,
|
||||
"hbp": 0,
|
||||
"sac": 0,
|
||||
"ibb": 0,
|
||||
"gidp": 0,
|
||||
"sb": 0,
|
||||
"cs": 0,
|
||||
"appeared": False, # tracks whether batter appeared at all in this game
|
||||
@ -84,16 +89,19 @@ def _build_batting_groups(plays):
|
||||
g["doubles"] += play.double
|
||||
g["triples"] += play.triple
|
||||
g["hr"] += play.homerun
|
||||
g["bb"] += play.bb
|
||||
g["hbp"] += play.hbp
|
||||
g["so"] += play.so
|
||||
g["rbi"] += play.rbi
|
||||
g["runs"] += play.run
|
||||
g["bb"] += play.bb
|
||||
g["strikeouts"] += play.so
|
||||
g["hbp"] += play.hbp
|
||||
g["sac"] += play.sac
|
||||
g["ibb"] += play.ibb
|
||||
g["gidp"] += play.gidp
|
||||
g["sb"] += play.sb
|
||||
g["cs"] += play.cs
|
||||
|
||||
if play.pa > 0 and not g["appeared"]:
|
||||
g["games_batting"] = 1
|
||||
g["games"] = 1
|
||||
g["appeared"] = True
|
||||
|
||||
# Clean up the helper flag before returning
|
||||
@ -110,30 +118,40 @@ def _build_pitching_groups(plays):
|
||||
Stats on StratPlay are recorded from the batter's perspective, so
|
||||
when accumulating pitcher stats we collect:
|
||||
- outs → pitcher outs recorded (directly on play)
|
||||
- so → strikeouts (batter's so = pitcher's k)
|
||||
- so → strikeouts (batter's so = pitcher's strikeouts)
|
||||
- hit → hits allowed
|
||||
- bb+hbp → base-on-balls allowed
|
||||
- bb → walks allowed (batter bb, separate from hbp)
|
||||
- hbp → hit batters
|
||||
- homerun → home runs allowed
|
||||
|
||||
games_pitching counts unique pitchers who appeared (at least one
|
||||
play as pitcher), capped at 1 per game since this function processes
|
||||
a single game.
|
||||
games counts unique pitchers who appeared (at least one play as
|
||||
pitcher), capped at 1 per game since this function processes a
|
||||
single game. games_started is populated later via _apply_decisions().
|
||||
|
||||
Returns a dict keyed by (pitcher_id, pitcher_team_id) with stat dicts.
|
||||
Fields not available from StratPlay (runs_allowed, earned_runs,
|
||||
wild_pitches, balks) default to 0 and are not incremented.
|
||||
|
||||
Returns a dict keyed by (pitcher_id, pitcher_team_id) with stat dicts
|
||||
matching PitchingSeasonStats column names.
|
||||
"""
|
||||
groups = defaultdict(
|
||||
lambda: {
|
||||
"games_pitching": 1, # pitcher appeared in this game by definition
|
||||
"games": 1, # pitcher appeared in this game by definition
|
||||
"games_started": 0, # populated later via _apply_decisions
|
||||
"outs": 0,
|
||||
"k": 0,
|
||||
"strikeouts": 0,
|
||||
"bb": 0,
|
||||
"hits_allowed": 0,
|
||||
"bb_allowed": 0,
|
||||
"runs_allowed": 0, # not available from StratPlay
|
||||
"earned_runs": 0, # not available from StratPlay
|
||||
"hr_allowed": 0,
|
||||
# Decision stats added later
|
||||
"hbp": 0,
|
||||
"wild_pitches": 0, # not available from StratPlay
|
||||
"balks": 0, # not available from StratPlay
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"saves": 0,
|
||||
"holds": 0,
|
||||
"saves": 0,
|
||||
"blown_saves": 0,
|
||||
}
|
||||
)
|
||||
@ -141,13 +159,18 @@ def _build_pitching_groups(plays):
|
||||
for play in plays:
|
||||
pitcher_id = play.pitcher_id
|
||||
pitcher_team_id = play.pitcher_team_id
|
||||
|
||||
if pitcher_id is None:
|
||||
continue
|
||||
|
||||
key = (pitcher_id, pitcher_team_id)
|
||||
g = groups[key]
|
||||
|
||||
g["outs"] += play.outs
|
||||
g["k"] += play.so
|
||||
g["strikeouts"] += play.so
|
||||
g["hits_allowed"] += play.hit
|
||||
g["bb_allowed"] += play.bb + play.hbp
|
||||
g["bb"] += play.bb
|
||||
g["hbp"] += play.hbp
|
||||
g["hr_allowed"] += play.homerun
|
||||
|
||||
return groups
|
||||
@ -170,16 +193,22 @@ def _apply_decisions(pitching_groups, decisions):
|
||||
# Initialise a zeroed entry if not already present.
|
||||
if key not in pitching_groups:
|
||||
pitching_groups[key] = {
|
||||
"games_pitching": 1,
|
||||
"games": 1,
|
||||
"games_started": 0,
|
||||
"outs": 0,
|
||||
"k": 0,
|
||||
"strikeouts": 0,
|
||||
"bb": 0,
|
||||
"hits_allowed": 0,
|
||||
"bb_allowed": 0,
|
||||
"runs_allowed": 0,
|
||||
"earned_runs": 0,
|
||||
"hr_allowed": 0,
|
||||
"hbp": 0,
|
||||
"wild_pitches": 0,
|
||||
"balks": 0,
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"saves": 0,
|
||||
"holds": 0,
|
||||
"saves": 0,
|
||||
"blown_saves": 0,
|
||||
}
|
||||
|
||||
@ -189,124 +218,71 @@ def _apply_decisions(pitching_groups, decisions):
|
||||
g["saves"] += decision.is_save
|
||||
g["holds"] += decision.hold
|
||||
g["blown_saves"] += decision.b_save
|
||||
g["games_started"] += 1 if decision.is_start else 0
|
||||
|
||||
|
||||
def _upsert_postgres(player_id, team_id, season, game_id, batting, pitching):
|
||||
def _upsert_batting_postgres(player_id, team_id, season, game_id, batting):
|
||||
"""
|
||||
PostgreSQL upsert using ON CONFLICT ... DO UPDATE with column-level
|
||||
increments. Each stat column is incremented by the value from the
|
||||
EXCLUDED (incoming) row, ensuring concurrent games don't overwrite
|
||||
each other.
|
||||
PostgreSQL upsert for BattingSeasonStats using ON CONFLICT ... DO UPDATE.
|
||||
Each stat column is incremented by the EXCLUDED (incoming) value,
|
||||
ensuring concurrent games don't overwrite each other.
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
row = {
|
||||
"player_id": player_id,
|
||||
"team_id": team_id,
|
||||
"season": season,
|
||||
"games_batting": batting.get("games_batting", 0),
|
||||
"pa": batting.get("pa", 0),
|
||||
"ab": batting.get("ab", 0),
|
||||
"hits": batting.get("hits", 0),
|
||||
"doubles": batting.get("doubles", 0),
|
||||
"triples": batting.get("triples", 0),
|
||||
"hr": batting.get("hr", 0),
|
||||
"bb": batting.get("bb", 0),
|
||||
"hbp": batting.get("hbp", 0),
|
||||
"so": batting.get("so", 0),
|
||||
"rbi": batting.get("rbi", 0),
|
||||
"runs": batting.get("runs", 0),
|
||||
"sb": batting.get("sb", 0),
|
||||
"cs": batting.get("cs", 0),
|
||||
"games_pitching": pitching.get("games_pitching", 0),
|
||||
"outs": pitching.get("outs", 0),
|
||||
"k": pitching.get("k", 0),
|
||||
"hits_allowed": pitching.get("hits_allowed", 0),
|
||||
"bb_allowed": pitching.get("bb_allowed", 0),
|
||||
"hr_allowed": pitching.get("hr_allowed", 0),
|
||||
"wins": pitching.get("wins", 0),
|
||||
"losses": pitching.get("losses", 0),
|
||||
"saves": pitching.get("saves", 0),
|
||||
"holds": pitching.get("holds", 0),
|
||||
"blown_saves": pitching.get("blown_saves", 0),
|
||||
"last_game_id": game_id,
|
||||
"last_updated_at": now,
|
||||
}
|
||||
|
||||
# Incrementable stat columns (all batting + pitching accumulators)
|
||||
increment_cols = [
|
||||
"games_batting",
|
||||
"games",
|
||||
"pa",
|
||||
"ab",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"hr",
|
||||
"bb",
|
||||
"hbp",
|
||||
"so",
|
||||
"rbi",
|
||||
"runs",
|
||||
"bb",
|
||||
"strikeouts",
|
||||
"hbp",
|
||||
"sac",
|
||||
"ibb",
|
||||
"gidp",
|
||||
"sb",
|
||||
"cs",
|
||||
"games_pitching",
|
||||
"outs",
|
||||
"k",
|
||||
"hits_allowed",
|
||||
"bb_allowed",
|
||||
"hr_allowed",
|
||||
"wins",
|
||||
"losses",
|
||||
"saves",
|
||||
"holds",
|
||||
"blown_saves",
|
||||
]
|
||||
|
||||
# Build the conflict-target field objects
|
||||
conflict_target = [
|
||||
PlayerSeasonStats.player,
|
||||
PlayerSeasonStats.team,
|
||||
PlayerSeasonStats.season,
|
||||
BattingSeasonStats.player,
|
||||
BattingSeasonStats.team,
|
||||
BattingSeasonStats.season,
|
||||
]
|
||||
|
||||
# Build the update dict: increment accumulators, overwrite metadata
|
||||
update_dict = {}
|
||||
for col in increment_cols:
|
||||
field_obj = getattr(PlayerSeasonStats, col)
|
||||
field_obj = getattr(BattingSeasonStats, col)
|
||||
update_dict[field_obj] = field_obj + EXCLUDED[col]
|
||||
update_dict[BattingSeasonStats.last_game] = EXCLUDED["last_game_id"]
|
||||
update_dict[BattingSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
|
||||
|
||||
update_dict[PlayerSeasonStats.last_game] = EXCLUDED["last_game_id"]
|
||||
update_dict[PlayerSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
|
||||
|
||||
PlayerSeasonStats.insert(
|
||||
BattingSeasonStats.insert(
|
||||
player=player_id,
|
||||
team=team_id,
|
||||
season=season,
|
||||
games_batting=row["games_batting"],
|
||||
pa=row["pa"],
|
||||
ab=row["ab"],
|
||||
hits=row["hits"],
|
||||
doubles=row["doubles"],
|
||||
triples=row["triples"],
|
||||
hr=row["hr"],
|
||||
bb=row["bb"],
|
||||
hbp=row["hbp"],
|
||||
so=row["so"],
|
||||
rbi=row["rbi"],
|
||||
runs=row["runs"],
|
||||
sb=row["sb"],
|
||||
cs=row["cs"],
|
||||
games_pitching=row["games_pitching"],
|
||||
outs=row["outs"],
|
||||
k=row["k"],
|
||||
hits_allowed=row["hits_allowed"],
|
||||
bb_allowed=row["bb_allowed"],
|
||||
hr_allowed=row["hr_allowed"],
|
||||
wins=row["wins"],
|
||||
losses=row["losses"],
|
||||
saves=row["saves"],
|
||||
holds=row["holds"],
|
||||
blown_saves=row["blown_saves"],
|
||||
games=batting.get("games", 0),
|
||||
pa=batting.get("pa", 0),
|
||||
ab=batting.get("ab", 0),
|
||||
hits=batting.get("hits", 0),
|
||||
doubles=batting.get("doubles", 0),
|
||||
triples=batting.get("triples", 0),
|
||||
hr=batting.get("hr", 0),
|
||||
rbi=batting.get("rbi", 0),
|
||||
runs=batting.get("runs", 0),
|
||||
bb=batting.get("bb", 0),
|
||||
strikeouts=batting.get("strikeouts", 0),
|
||||
hbp=batting.get("hbp", 0),
|
||||
sac=batting.get("sac", 0),
|
||||
ibb=batting.get("ibb", 0),
|
||||
gidp=batting.get("gidp", 0),
|
||||
sb=batting.get("sb", 0),
|
||||
cs=batting.get("cs", 0),
|
||||
last_game=game_id,
|
||||
last_updated_at=now,
|
||||
).on_conflict(
|
||||
@ -316,9 +292,80 @@ def _upsert_postgres(player_id, team_id, season, game_id, batting, pitching):
|
||||
).execute()
|
||||
|
||||
|
||||
def _upsert_sqlite(player_id, team_id, season, game_id, batting, pitching):
|
||||
def _upsert_pitching_postgres(player_id, team_id, season, game_id, pitching):
|
||||
"""
|
||||
SQLite upsert: read-modify-write inside the outer atomic() block.
|
||||
PostgreSQL upsert for PitchingSeasonStats using ON CONFLICT ... DO UPDATE.
|
||||
Each stat column is incremented by the EXCLUDED (incoming) value,
|
||||
ensuring concurrent games don't overwrite each other.
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
increment_cols = [
|
||||
"games",
|
||||
"games_started",
|
||||
"outs",
|
||||
"strikeouts",
|
||||
"bb",
|
||||
"hits_allowed",
|
||||
"runs_allowed",
|
||||
"earned_runs",
|
||||
"hr_allowed",
|
||||
"hbp",
|
||||
"wild_pitches",
|
||||
"balks",
|
||||
"wins",
|
||||
"losses",
|
||||
"holds",
|
||||
"saves",
|
||||
"blown_saves",
|
||||
]
|
||||
|
||||
conflict_target = [
|
||||
PitchingSeasonStats.player,
|
||||
PitchingSeasonStats.team,
|
||||
PitchingSeasonStats.season,
|
||||
]
|
||||
|
||||
update_dict = {}
|
||||
for col in increment_cols:
|
||||
field_obj = getattr(PitchingSeasonStats, col)
|
||||
update_dict[field_obj] = field_obj + EXCLUDED[col]
|
||||
update_dict[PitchingSeasonStats.last_game] = EXCLUDED["last_game_id"]
|
||||
update_dict[PitchingSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
|
||||
|
||||
PitchingSeasonStats.insert(
|
||||
player=player_id,
|
||||
team=team_id,
|
||||
season=season,
|
||||
games=pitching.get("games", 0),
|
||||
games_started=pitching.get("games_started", 0),
|
||||
outs=pitching.get("outs", 0),
|
||||
strikeouts=pitching.get("strikeouts", 0),
|
||||
bb=pitching.get("bb", 0),
|
||||
hits_allowed=pitching.get("hits_allowed", 0),
|
||||
runs_allowed=pitching.get("runs_allowed", 0),
|
||||
earned_runs=pitching.get("earned_runs", 0),
|
||||
hr_allowed=pitching.get("hr_allowed", 0),
|
||||
hbp=pitching.get("hbp", 0),
|
||||
wild_pitches=pitching.get("wild_pitches", 0),
|
||||
balks=pitching.get("balks", 0),
|
||||
wins=pitching.get("wins", 0),
|
||||
losses=pitching.get("losses", 0),
|
||||
holds=pitching.get("holds", 0),
|
||||
saves=pitching.get("saves", 0),
|
||||
blown_saves=pitching.get("blown_saves", 0),
|
||||
last_game=game_id,
|
||||
last_updated_at=now,
|
||||
).on_conflict(
|
||||
conflict_target=conflict_target,
|
||||
action="update",
|
||||
update=update_dict,
|
||||
).execute()
|
||||
|
||||
|
||||
def _upsert_batting_sqlite(player_id, team_id, season, game_id, batting):
|
||||
"""
|
||||
SQLite upsert for BattingSeasonStats: read-modify-write inside the outer atomic() block.
|
||||
|
||||
SQLite doesn't support EXCLUDED-based increments via Peewee's
|
||||
on_conflict(), so we use get_or_create + field-level addition.
|
||||
@ -327,37 +374,68 @@ def _upsert_sqlite(player_id, team_id, season, game_id, batting, pitching):
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
obj, _ = PlayerSeasonStats.get_or_create(
|
||||
obj, _ = BattingSeasonStats.get_or_create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
season=season,
|
||||
)
|
||||
|
||||
obj.games_batting += batting.get("games_batting", 0)
|
||||
obj.games += batting.get("games", 0)
|
||||
obj.pa += batting.get("pa", 0)
|
||||
obj.ab += batting.get("ab", 0)
|
||||
obj.hits += batting.get("hits", 0)
|
||||
obj.doubles += batting.get("doubles", 0)
|
||||
obj.triples += batting.get("triples", 0)
|
||||
obj.hr += batting.get("hr", 0)
|
||||
obj.bb += batting.get("bb", 0)
|
||||
obj.hbp += batting.get("hbp", 0)
|
||||
obj.so += batting.get("so", 0)
|
||||
obj.rbi += batting.get("rbi", 0)
|
||||
obj.runs += batting.get("runs", 0)
|
||||
obj.bb += batting.get("bb", 0)
|
||||
obj.strikeouts += batting.get("strikeouts", 0)
|
||||
obj.hbp += batting.get("hbp", 0)
|
||||
obj.sac += batting.get("sac", 0)
|
||||
obj.ibb += batting.get("ibb", 0)
|
||||
obj.gidp += batting.get("gidp", 0)
|
||||
obj.sb += batting.get("sb", 0)
|
||||
obj.cs += batting.get("cs", 0)
|
||||
|
||||
obj.games_pitching += pitching.get("games_pitching", 0)
|
||||
obj.last_game_id = game_id
|
||||
obj.last_updated_at = now
|
||||
obj.save()
|
||||
|
||||
|
||||
def _upsert_pitching_sqlite(player_id, team_id, season, game_id, pitching):
|
||||
"""
|
||||
SQLite upsert for PitchingSeasonStats: read-modify-write inside the outer atomic() block.
|
||||
|
||||
SQLite doesn't support EXCLUDED-based increments via Peewee's
|
||||
on_conflict(), so we use get_or_create + field-level addition.
|
||||
This is safe because the entire update_season_stats() call is
|
||||
wrapped in db.atomic().
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
obj, _ = PitchingSeasonStats.get_or_create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
season=season,
|
||||
)
|
||||
|
||||
obj.games += pitching.get("games", 0)
|
||||
obj.games_started += pitching.get("games_started", 0)
|
||||
obj.outs += pitching.get("outs", 0)
|
||||
obj.k += pitching.get("k", 0)
|
||||
obj.strikeouts += pitching.get("strikeouts", 0)
|
||||
obj.bb += pitching.get("bb", 0)
|
||||
obj.hits_allowed += pitching.get("hits_allowed", 0)
|
||||
obj.bb_allowed += pitching.get("bb_allowed", 0)
|
||||
obj.runs_allowed += pitching.get("runs_allowed", 0)
|
||||
obj.earned_runs += pitching.get("earned_runs", 0)
|
||||
obj.hr_allowed += pitching.get("hr_allowed", 0)
|
||||
obj.hbp += pitching.get("hbp", 0)
|
||||
obj.wild_pitches += pitching.get("wild_pitches", 0)
|
||||
obj.balks += pitching.get("balks", 0)
|
||||
obj.wins += pitching.get("wins", 0)
|
||||
obj.losses += pitching.get("losses", 0)
|
||||
obj.saves += pitching.get("saves", 0)
|
||||
obj.holds += pitching.get("holds", 0)
|
||||
obj.saves += pitching.get("saves", 0)
|
||||
obj.blown_saves += pitching.get("blown_saves", 0)
|
||||
|
||||
obj.last_game_id = game_id
|
||||
@ -367,29 +445,28 @@ def _upsert_sqlite(player_id, team_id, season, game_id, batting, pitching):
|
||||
|
||||
def update_season_stats(game_id: int) -> dict:
|
||||
"""
|
||||
Accumulate per-game batting and pitching stats into PlayerSeasonStats.
|
||||
Accumulate per-game batting and pitching stats into BattingSeasonStats
|
||||
and PitchingSeasonStats respectively.
|
||||
|
||||
This function is safe to call exactly once per game. If called again
|
||||
for the same game_id while it is still the most-recently-processed
|
||||
game for at least one affected player (detected by checking last_game
|
||||
FK), it returns early without modifying any data.
|
||||
|
||||
Limitation: the guard only detects re-delivery if no later game has
|
||||
been processed for the same players. Out-of-order re-delivery (e.g.
|
||||
game G re-delivered after game G+1 was already processed) will not be
|
||||
caught and will silently double-count stats. See issue #105 for the
|
||||
planned ProcessedGame ledger fix.
|
||||
This function is safe to call exactly once per game. Idempotency is
|
||||
enforced via an atomic INSERT into the ProcessedGame ledger table.
|
||||
The first call for a given game_id succeeds and returns full results;
|
||||
any subsequent call (including out-of-order re-delivery after a later
|
||||
game has been processed) finds the existing row and returns early with
|
||||
"skipped": True without touching any stats rows.
|
||||
|
||||
Algorithm:
|
||||
1. Fetch StratGame to get the season.
|
||||
2. Guard against re-processing via last_game_id check.
|
||||
2. Atomic INSERT into ProcessedGame — if the row already exists,
|
||||
return early (skipped).
|
||||
3. Collect all StratPlay rows for the game.
|
||||
4. Group batting stats by (batter_id, batter_team_id).
|
||||
5. Group pitching stats by (pitcher_id, pitcher_team_id).
|
||||
6. Merge Decision rows into pitching groups.
|
||||
7. Upsert each player's contribution using either:
|
||||
7. Upsert each batter into BattingSeasonStats using either:
|
||||
- PostgreSQL: atomic SQL increment via ON CONFLICT DO UPDATE
|
||||
- SQLite: read-modify-write inside a transaction
|
||||
8. Upsert each pitcher into PitchingSeasonStats using the same strategy.
|
||||
|
||||
Args:
|
||||
game_id: Primary key of the StratGame to process.
|
||||
@ -409,16 +486,13 @@ def update_season_stats(game_id: int) -> dict:
|
||||
season = game.season
|
||||
|
||||
with db.atomic():
|
||||
# Step 2 — Double-count prevention: check if any row still
|
||||
# carries this game_id as last_game. Note: only detects replay
|
||||
# of the most-recently-processed game; out-of-order re-delivery
|
||||
# bypasses this guard (see issue #105).
|
||||
already_processed = (
|
||||
PlayerSeasonStats.select()
|
||||
.where(PlayerSeasonStats.last_game == game_id)
|
||||
.exists()
|
||||
)
|
||||
if already_processed:
|
||||
# Step 2 — Full idempotency via ProcessedGame ledger.
|
||||
# Atomic INSERT: if the row already exists (same game_id), get_or_create
|
||||
# returns created=False and we skip. This handles same-game immediate
|
||||
# replay AND out-of-order re-delivery (game G re-delivered after G+1
|
||||
# was already processed).
|
||||
_, created = ProcessedGame.get_or_create(game_id=game_id)
|
||||
if not created:
|
||||
logger.info(
|
||||
"update_season_stats: game_id=%d already processed, skipping",
|
||||
game_id,
|
||||
@ -445,28 +519,28 @@ def update_season_stats(game_id: int) -> dict:
|
||||
decisions = list(Decision.select().where(Decision.game == game_id))
|
||||
_apply_decisions(pitching_groups, decisions)
|
||||
|
||||
# Collect all unique player keys across both perspectives.
|
||||
# A two-way player (batter who also pitched, or vice-versa) gets
|
||||
# a single combined row in PlayerSeasonStats.
|
||||
all_keys = set(batting_groups.keys()) | set(pitching_groups.keys())
|
||||
|
||||
batters_updated = 0
|
||||
pitchers_updated = 0
|
||||
|
||||
upsert_fn = (
|
||||
_upsert_postgres if DATABASE_TYPE == "postgresql" else _upsert_sqlite
|
||||
upsert_batting = (
|
||||
_upsert_batting_postgres
|
||||
if DATABASE_TYPE == "postgresql"
|
||||
else _upsert_batting_sqlite
|
||||
)
|
||||
upsert_pitching = (
|
||||
_upsert_pitching_postgres
|
||||
if DATABASE_TYPE == "postgresql"
|
||||
else _upsert_pitching_sqlite
|
||||
)
|
||||
|
||||
for player_id, team_id in all_keys:
|
||||
batting = batting_groups.get((player_id, team_id), {})
|
||||
pitching = pitching_groups.get((player_id, team_id), {})
|
||||
# Step 7 — Upsert batting rows into BattingSeasonStats
|
||||
batters_updated = 0
|
||||
for (player_id, team_id), batting in batting_groups.items():
|
||||
upsert_batting(player_id, team_id, season, game_id, batting)
|
||||
batters_updated += 1
|
||||
|
||||
upsert_fn(player_id, team_id, season, game_id, batting, pitching)
|
||||
|
||||
if batting:
|
||||
batters_updated += 1
|
||||
if pitching:
|
||||
pitchers_updated += 1
|
||||
# Step 8 — Upsert pitching rows into PitchingSeasonStats
|
||||
pitchers_updated = 0
|
||||
for (player_id, team_id), pitching in pitching_groups.items():
|
||||
upsert_pitching(player_id, team_id, season, game_id, pitching)
|
||||
pitchers_updated += 1
|
||||
|
||||
logger.info(
|
||||
"update_season_stats: game_id=%d complete — "
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
-- Migration: Add card evolution tables and column extensions
|
||||
-- Date: 2026-03-17
|
||||
-- Issue: WP-04
|
||||
-- Purpose: Support the Card Evolution system — tracks player season stats,
|
||||
-- Purpose: Support the Card Evolution system — creates batting_season_stats
|
||||
-- and pitching_season_stats for per-player stat accumulation, plus
|
||||
-- evolution tracks with tier thresholds, per-card evolution state,
|
||||
-- tier-based stat boosts, and cosmetic unlocks. Also extends the
|
||||
-- card, battingcard, and pitchingcard tables with variant and
|
||||
-- image_url columns required by the evolution display layer.
|
||||
--
|
||||
-- Run on dev first, verify with:
|
||||
-- SELECT count(*) FROM player_season_stats;
|
||||
-- SELECT count(*) FROM batting_season_stats;
|
||||
-- SELECT count(*) FROM pitching_season_stats;
|
||||
-- SELECT count(*) FROM evolution_track;
|
||||
-- SELECT count(*) FROM evolution_card_state;
|
||||
-- SELECT count(*) FROM evolution_tier_boost;
|
||||
@ -27,62 +29,95 @@
|
||||
BEGIN;
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 1: player_season_stats
|
||||
-- Table 1: batting_season_stats
|
||||
-- Accumulates per-player per-team per-season
|
||||
-- batting and pitching totals for evolution
|
||||
-- formula evaluation.
|
||||
-- batting totals for evolution formula evaluation
|
||||
-- and leaderboard queries.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS player_season_stats (
|
||||
CREATE TABLE IF NOT EXISTS batting_season_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||
season INTEGER NOT NULL,
|
||||
-- Batting stats
|
||||
games_batting INTEGER NOT NULL DEFAULT 0,
|
||||
games INTEGER NOT NULL DEFAULT 0,
|
||||
pa INTEGER NOT NULL DEFAULT 0,
|
||||
ab INTEGER NOT NULL DEFAULT 0,
|
||||
hits INTEGER NOT NULL DEFAULT 0,
|
||||
doubles INTEGER NOT NULL DEFAULT 0,
|
||||
triples INTEGER NOT NULL DEFAULT 0,
|
||||
hr INTEGER NOT NULL DEFAULT 0,
|
||||
bb INTEGER NOT NULL DEFAULT 0,
|
||||
hbp INTEGER NOT NULL DEFAULT 0,
|
||||
so INTEGER NOT NULL DEFAULT 0,
|
||||
rbi INTEGER NOT NULL DEFAULT 0,
|
||||
runs INTEGER NOT NULL DEFAULT 0,
|
||||
bb INTEGER NOT NULL DEFAULT 0,
|
||||
strikeouts INTEGER NOT NULL DEFAULT 0,
|
||||
hbp INTEGER NOT NULL DEFAULT 0,
|
||||
sac INTEGER NOT NULL DEFAULT 0,
|
||||
ibb INTEGER NOT NULL DEFAULT 0,
|
||||
gidp INTEGER NOT NULL DEFAULT 0,
|
||||
sb INTEGER NOT NULL DEFAULT 0,
|
||||
cs INTEGER NOT NULL DEFAULT 0,
|
||||
-- Pitching stats
|
||||
games_pitching INTEGER NOT NULL DEFAULT 0,
|
||||
outs INTEGER NOT NULL DEFAULT 0,
|
||||
k INTEGER NOT NULL DEFAULT 0,
|
||||
bb_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
hits_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
hr_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
saves INTEGER NOT NULL DEFAULT 0,
|
||||
holds INTEGER NOT NULL DEFAULT 0,
|
||||
blown_saves INTEGER NOT NULL DEFAULT 0,
|
||||
-- Meta
|
||||
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||
last_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per player per team per season
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS player_season_stats_player_team_season_uniq
|
||||
ON player_season_stats (player_id, team_id, season);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS batting_season_stats_player_team_season_uniq
|
||||
ON batting_season_stats (player_id, team_id, season);
|
||||
|
||||
-- Fast lookup by team + season (e.g. leaderboard queries)
|
||||
CREATE INDEX IF NOT EXISTS player_season_stats_team_season_idx
|
||||
ON player_season_stats (team_id, season);
|
||||
CREATE INDEX IF NOT EXISTS batting_season_stats_team_season_idx
|
||||
ON batting_season_stats (team_id, season);
|
||||
|
||||
-- Fast lookup by player across seasons
|
||||
CREATE INDEX IF NOT EXISTS player_season_stats_player_season_idx
|
||||
ON player_season_stats (player_id, season);
|
||||
CREATE INDEX IF NOT EXISTS batting_season_stats_player_season_idx
|
||||
ON batting_season_stats (player_id, season);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 2: evolution_track
|
||||
-- Table 2: pitching_season_stats
|
||||
-- Accumulates per-player per-team per-season
|
||||
-- pitching totals for evolution formula evaluation
|
||||
-- and leaderboard queries.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pitching_season_stats (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL REFERENCES player(player_id) ON DELETE CASCADE,
|
||||
team_id INTEGER NOT NULL REFERENCES team(id) ON DELETE CASCADE,
|
||||
season INTEGER NOT NULL,
|
||||
games INTEGER NOT NULL DEFAULT 0,
|
||||
games_started INTEGER NOT NULL DEFAULT 0,
|
||||
outs INTEGER NOT NULL DEFAULT 0,
|
||||
strikeouts INTEGER NOT NULL DEFAULT 0,
|
||||
bb INTEGER NOT NULL DEFAULT 0,
|
||||
hits_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
runs_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
earned_runs INTEGER NOT NULL DEFAULT 0,
|
||||
hr_allowed INTEGER NOT NULL DEFAULT 0,
|
||||
hbp INTEGER NOT NULL DEFAULT 0,
|
||||
wild_pitches INTEGER NOT NULL DEFAULT 0,
|
||||
balks INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
losses INTEGER NOT NULL DEFAULT 0,
|
||||
holds INTEGER NOT NULL DEFAULT 0,
|
||||
saves INTEGER NOT NULL DEFAULT 0,
|
||||
blown_saves INTEGER NOT NULL DEFAULT 0,
|
||||
last_game_id INTEGER REFERENCES stratgame(id) ON DELETE SET NULL,
|
||||
last_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- One row per player per team per season
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS pitching_season_stats_player_team_season_uniq
|
||||
ON pitching_season_stats (player_id, team_id, season);
|
||||
|
||||
-- Fast lookup by team + season (e.g. leaderboard queries)
|
||||
CREATE INDEX IF NOT EXISTS pitching_season_stats_team_season_idx
|
||||
ON pitching_season_stats (team_id, season);
|
||||
|
||||
-- Fast lookup by player across seasons
|
||||
CREATE INDEX IF NOT EXISTS pitching_season_stats_player_season_idx
|
||||
ON pitching_season_stats (player_id, season);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 3: evolution_track
|
||||
-- Defines the available evolution tracks
|
||||
-- (e.g. "HR Mastery", "Ace SP"), their
|
||||
-- metric formula, and the four tier thresholds.
|
||||
@ -99,7 +134,7 @@ CREATE TABLE IF NOT EXISTS evolution_track (
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 3: evolution_card_state
|
||||
-- Table 4: evolution_card_state
|
||||
-- Records each card's current evolution tier,
|
||||
-- running metric value, and the track it
|
||||
-- belongs to. One state row per card (player
|
||||
@ -122,7 +157,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS evolution_card_state_player_team_uniq
|
||||
ON evolution_card_state (player_id, team_id);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 4: evolution_tier_boost
|
||||
-- Table 5: evolution_tier_boost
|
||||
-- Defines the stat boosts unlocked at each
|
||||
-- tier within a track. A single tier may
|
||||
-- grant multiple boosts (e.g. +1 HR and
|
||||
@ -142,7 +177,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS evolution_tier_boost_track_tier_type_target_un
|
||||
ON evolution_tier_boost (track_id, tier, boost_type, boost_target);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 5: evolution_cosmetic
|
||||
-- Table 6: evolution_cosmetic
|
||||
-- Catalogue of unlockable visual treatments
|
||||
-- (borders, foils, badges, etc.) tied to
|
||||
-- minimum tier requirements.
|
||||
@ -173,14 +208,16 @@ COMMIT;
|
||||
-- ============================================
|
||||
-- VERIFICATION QUERIES
|
||||
-- ============================================
|
||||
-- \d player_season_stats
|
||||
-- \d batting_season_stats
|
||||
-- \d pitching_season_stats
|
||||
-- \d evolution_track
|
||||
-- \d evolution_card_state
|
||||
-- \d evolution_tier_boost
|
||||
-- \d evolution_cosmetic
|
||||
-- SELECT indexname FROM pg_indexes
|
||||
-- WHERE tablename IN (
|
||||
-- 'player_season_stats',
|
||||
-- 'batting_season_stats',
|
||||
-- 'pitching_season_stats',
|
||||
-- 'evolution_card_state',
|
||||
-- 'evolution_tier_boost'
|
||||
-- )
|
||||
@ -200,4 +237,5 @@ COMMIT;
|
||||
-- DROP TABLE IF EXISTS evolution_tier_boost CASCADE;
|
||||
-- DROP TABLE IF EXISTS evolution_card_state CASCADE;
|
||||
-- DROP TABLE IF EXISTS evolution_track CASCADE;
|
||||
-- DROP TABLE IF EXISTS player_season_stats CASCADE;
|
||||
-- DROP TABLE IF EXISTS pitching_season_stats CASCADE;
|
||||
-- DROP TABLE IF EXISTS batting_season_stats CASCADE;
|
||||
|
||||
26
migrations/2026-03-18_add_processed_game.sql
Normal file
26
migrations/2026-03-18_add_processed_game.sql
Normal file
@ -0,0 +1,26 @@
|
||||
-- Migration: Add processed_game ledger for full update_season_stats() idempotency
|
||||
-- Date: 2026-03-18
|
||||
-- Issue: #105
|
||||
-- Purpose: Replace the last_game FK check in update_season_stats() with an
|
||||
-- atomic INSERT into processed_game. This prevents out-of-order
|
||||
-- re-delivery (game G re-delivered after G+1 was already processed)
|
||||
-- from bypassing the guard and double-counting stats.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS processed_game (
|
||||
game_id INTEGER PRIMARY KEY REFERENCES stratgame(id) ON DELETE CASCADE,
|
||||
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================
|
||||
-- VERIFICATION QUERIES
|
||||
-- ============================================
|
||||
-- \d processed_game
|
||||
|
||||
-- ============================================
|
||||
-- ROLLBACK (if needed)
|
||||
-- ============================================
|
||||
-- DROP TABLE IF EXISTS processed_game;
|
||||
@ -11,6 +11,7 @@ tests.
|
||||
|
||||
import os
|
||||
import pytest
|
||||
import psycopg2
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION
|
||||
@ -40,7 +41,9 @@ from app.db_engine import (
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
PlayerSeasonStats,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
@ -68,9 +71,11 @@ _TEST_MODELS = [
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
ScoutOpportunity,
|
||||
ScoutClaim,
|
||||
PlayerSeasonStats,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
@ -169,3 +174,39 @@ def track():
|
||||
t3_threshold=448,
|
||||
t4_threshold=896,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL integration fixture (used by test_evolution_*_api.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def pg_conn():
|
||||
"""Open a psycopg2 connection to the PostgreSQL instance for integration tests.
|
||||
|
||||
Reads connection parameters from the standard POSTGRES_* env vars that the
|
||||
CI workflow injects when a postgres service container is running. Skips the
|
||||
entire session (via pytest.skip) when POSTGRES_HOST is not set, keeping
|
||||
local runs clean.
|
||||
|
||||
The connection is shared for the whole session (scope="session") because
|
||||
the integration test modules use module-scoped fixtures that rely on it;
|
||||
creating a new connection per test would break those module-scoped fixtures.
|
||||
|
||||
Teardown: the connection is closed once all tests have finished.
|
||||
"""
|
||||
host = os.environ.get("POSTGRES_HOST")
|
||||
if not host:
|
||||
pytest.skip("POSTGRES_HOST not set — PostgreSQL integration tests skipped")
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=int(os.environ.get("POSTGRES_PORT", "5432")),
|
||||
dbname=os.environ.get("POSTGRES_DB", "paper_dynasty"),
|
||||
user=os.environ.get("POSTGRES_USER", "postgres"),
|
||||
password=os.environ.get("POSTGRES_PASSWORD", ""),
|
||||
)
|
||||
conn.autocommit = False
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
361
tests/test_evolution_evaluator.py
Normal file
361
tests/test_evolution_evaluator.py
Normal file
@ -0,0 +1,361 @@
|
||||
"""Tests for the evolution evaluator service (WP-08).
|
||||
|
||||
Unit tests verify tier assignment, advancement, partial progress, idempotency,
|
||||
full evolution, and no-regression behaviour without touching any database,
|
||||
using stub Peewee models bound to an in-memory SQLite database.
|
||||
|
||||
The formula engine (WP-09) and Peewee models (WP-05/WP-07) are not imported
|
||||
from db_engine/formula_engine; instead the tests supply minimal stubs and
|
||||
inject them via the _stats_model, _state_model, _compute_value_fn, and
|
||||
_tier_from_value_fn overrides on evaluate_card().
|
||||
|
||||
Stub track thresholds (batter):
|
||||
T1: 37 T2: 149 T3: 448 T4: 896
|
||||
|
||||
Useful reference values:
|
||||
value=30 → T0 (below T1=37)
|
||||
value=50 → T1 (37 <= 50 < 149)
|
||||
value=100 → T1 (stays T1; T2 threshold is 149)
|
||||
value=160 → T2 (149 <= 160 < 448)
|
||||
value=900 → T4 (>= 896) → fully_evolved
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from peewee import (
|
||||
BooleanField,
|
||||
CharField,
|
||||
DateTimeField,
|
||||
FloatField,
|
||||
ForeignKeyField,
|
||||
IntegerField,
|
||||
Model,
|
||||
SqliteDatabase,
|
||||
)
|
||||
|
||||
from app.services.evolution_evaluator import evaluate_card
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub models — mirror WP-01/WP-04/WP-07 schema without importing db_engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_test_db = SqliteDatabase(":memory:")
|
||||
|
||||
|
||||
class TrackStub(Model):
|
||||
"""Minimal EvolutionTrack stub for evaluator tests."""
|
||||
|
||||
card_type = CharField(unique=True)
|
||||
t1_threshold = IntegerField()
|
||||
t2_threshold = IntegerField()
|
||||
t3_threshold = IntegerField()
|
||||
t4_threshold = IntegerField()
|
||||
|
||||
class Meta:
|
||||
database = _test_db
|
||||
table_name = "evolution_track"
|
||||
|
||||
|
||||
class CardStateStub(Model):
|
||||
"""Minimal EvolutionCardState stub for evaluator tests."""
|
||||
|
||||
player_id = IntegerField()
|
||||
team_id = IntegerField()
|
||||
track = ForeignKeyField(TrackStub)
|
||||
current_tier = IntegerField(default=0)
|
||||
current_value = FloatField(default=0.0)
|
||||
fully_evolved = BooleanField(default=False)
|
||||
last_evaluated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = _test_db
|
||||
table_name = "evolution_card_state"
|
||||
indexes = ((("player_id", "team_id"), True),)
|
||||
|
||||
|
||||
class StatsStub(Model):
|
||||
"""Minimal PlayerSeasonStats stub for evaluator tests."""
|
||||
|
||||
player_id = IntegerField()
|
||||
team_id = IntegerField()
|
||||
season = IntegerField()
|
||||
pa = IntegerField(default=0)
|
||||
hits = IntegerField(default=0)
|
||||
doubles = IntegerField(default=0)
|
||||
triples = IntegerField(default=0)
|
||||
hr = IntegerField(default=0)
|
||||
outs = IntegerField(default=0)
|
||||
k = IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
database = _test_db
|
||||
table_name = "player_season_stats"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formula stubs — avoid importing app.services.formula_engine before WP-09
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _compute_value(card_type: str, stats) -> float:
|
||||
"""Stub compute_value_for_track: returns pa for batter, outs/3+k for pitchers."""
|
||||
if card_type == "batter":
|
||||
singles = stats.hits - stats.doubles - stats.triples - stats.hr
|
||||
tb = singles + 2 * stats.doubles + 3 * stats.triples + 4 * stats.hr
|
||||
return float(stats.pa + tb * 2)
|
||||
return stats.outs / 3 + stats.strikeouts
|
||||
|
||||
|
||||
def _tier_from_value(value: float, track) -> int:
|
||||
"""Stub tier_from_value using TrackStub fields t1_threshold/t2_threshold/etc."""
|
||||
if isinstance(track, dict):
|
||||
t1, t2, t3, t4 = (
|
||||
track["t1_threshold"],
|
||||
track["t2_threshold"],
|
||||
track["t3_threshold"],
|
||||
track["t4_threshold"],
|
||||
)
|
||||
else:
|
||||
t1, t2, t3, t4 = (
|
||||
track.t1_threshold,
|
||||
track.t2_threshold,
|
||||
track.t3_threshold,
|
||||
track.t4_threshold,
|
||||
)
|
||||
if value >= t4:
|
||||
return 4
|
||||
if value >= t3:
|
||||
return 3
|
||||
if value >= t2:
|
||||
return 2
|
||||
if value >= t1:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _db():
|
||||
"""Create tables before each test and drop them afterwards."""
|
||||
_test_db.connect(reuse_if_open=True)
|
||||
_test_db.create_tables([TrackStub, CardStateStub, StatsStub])
|
||||
yield
|
||||
_test_db.drop_tables([StatsStub, CardStateStub, TrackStub])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def batter_track():
|
||||
return TrackStub.create(
|
||||
card_type="batter",
|
||||
t1_threshold=37,
|
||||
t2_threshold=149,
|
||||
t3_threshold=448,
|
||||
t4_threshold=896,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def sp_track():
|
||||
return TrackStub.create(
|
||||
card_type="sp",
|
||||
t1_threshold=10,
|
||||
t2_threshold=40,
|
||||
t3_threshold=120,
|
||||
t4_threshold=240,
|
||||
)
|
||||
|
||||
|
||||
def _make_state(player_id, team_id, track, current_tier=0, current_value=0.0):
|
||||
return CardStateStub.create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
track=track,
|
||||
current_tier=current_tier,
|
||||
current_value=current_value,
|
||||
fully_evolved=False,
|
||||
last_evaluated_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_stats(player_id, team_id, season, **kwargs):
|
||||
return StatsStub.create(
|
||||
player_id=player_id, team_id=team_id, season=season, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _eval(player_id, team_id):
|
||||
return evaluate_card(
|
||||
player_id,
|
||||
team_id,
|
||||
_stats_model=StatsStub,
|
||||
_state_model=CardStateStub,
|
||||
_compute_value_fn=_compute_value,
|
||||
_tier_from_value_fn=_tier_from_value,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTierAssignment:
|
||||
"""Tier assigned from computed value against track thresholds."""
|
||||
|
||||
def test_value_below_t1_stays_t0(self, batter_track):
|
||||
"""value=30 is below T1 threshold (37) → tier stays 0."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=30, no extra hits → value = 30 + 0 = 30 < 37
|
||||
_make_stats(1, 1, 1, pa=30)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 0
|
||||
|
||||
def test_value_at_t1_threshold_assigns_tier_1(self, batter_track):
|
||||
"""value=50 → T1 (37 <= 50 < 149)."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=50, no hits → value = 50 + 0 = 50
|
||||
_make_stats(1, 1, 1, pa=50)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 1
|
||||
|
||||
def test_tier_advancement_to_t2(self, batter_track):
|
||||
"""value=160 → T2 (149 <= 160 < 448)."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=160, no hits → value = 160
|
||||
_make_stats(1, 1, 1, pa=160)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 2
|
||||
|
||||
def test_partial_progress_stays_t1(self, batter_track):
|
||||
"""value=100 with T2=149 → stays T1, does not advance to T2."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=100 → value = 100, T2 threshold = 149 → tier 1
|
||||
_make_stats(1, 1, 1, pa=100)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 1
|
||||
assert result["fully_evolved"] is False
|
||||
|
||||
def test_fully_evolved_at_t4(self, batter_track):
|
||||
"""value >= T4 (896) → tier=4 and fully_evolved=True."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# pa=900 → value = 900 >= 896
|
||||
_make_stats(1, 1, 1, pa=900)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 4
|
||||
assert result["fully_evolved"] is True
|
||||
|
||||
|
||||
class TestNoRegression:
|
||||
"""current_tier never decreases."""
|
||||
|
||||
def test_tier_never_decreases(self, batter_track):
|
||||
"""If current_tier=2 and new value only warrants T1, tier stays 2."""
|
||||
# Seed state at tier 2
|
||||
_make_state(1, 1, batter_track, current_tier=2, current_value=160.0)
|
||||
# Sparse stats: value=50 → would be T1, but current is T2
|
||||
_make_stats(1, 1, 1, pa=50)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 2 # no regression
|
||||
|
||||
def test_tier_advances_when_value_improves(self, batter_track):
|
||||
"""If current_tier=1 and new value warrants T3, tier advances to 3."""
|
||||
_make_state(1, 1, batter_track, current_tier=1, current_value=50.0)
|
||||
# pa=500 → value = 500 >= 448 → T3
|
||||
_make_stats(1, 1, 1, pa=500)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 3
|
||||
|
||||
|
||||
class TestIdempotency:
|
||||
"""Calling evaluate_card twice with same stats returns the same result."""
|
||||
|
||||
def test_idempotent_same_result(self, batter_track):
|
||||
"""Two evaluations with identical stats produce the same tier and value."""
|
||||
_make_state(1, 1, batter_track)
|
||||
_make_stats(1, 1, 1, pa=160)
|
||||
result1 = _eval(1, 1)
|
||||
result2 = _eval(1, 1)
|
||||
assert result1["current_tier"] == result2["current_tier"]
|
||||
assert result1["current_value"] == result2["current_value"]
|
||||
assert result1["fully_evolved"] == result2["fully_evolved"]
|
||||
|
||||
def test_idempotent_at_fully_evolved(self, batter_track):
|
||||
"""Repeated evaluation at T4 remains fully_evolved=True."""
|
||||
_make_state(1, 1, batter_track)
|
||||
_make_stats(1, 1, 1, pa=900)
|
||||
_eval(1, 1)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 4
|
||||
assert result["fully_evolved"] is True
|
||||
|
||||
|
||||
class TestCareerTotals:
|
||||
"""Stats are summed across all seasons for the player/team pair."""
|
||||
|
||||
def test_multi_season_stats_summed(self, batter_track):
|
||||
"""Stats from two seasons are aggregated into a single career total."""
|
||||
_make_state(1, 1, batter_track)
|
||||
# Season 1: pa=80, Season 2: pa=90 → total pa=170 → value=170 → T2
|
||||
_make_stats(1, 1, 1, pa=80)
|
||||
_make_stats(1, 1, 2, pa=90)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 2
|
||||
assert result["current_value"] == 170.0
|
||||
|
||||
def test_zero_stats_stays_t0(self, batter_track):
|
||||
"""No stats rows → all zeros → value=0 → tier=0."""
|
||||
_make_state(1, 1, batter_track)
|
||||
result = _eval(1, 1)
|
||||
assert result["current_tier"] == 0
|
||||
assert result["current_value"] == 0.0
|
||||
|
||||
def test_other_team_stats_not_included(self, batter_track):
|
||||
"""Stats for the same player on a different team are not counted."""
|
||||
_make_state(1, 1, batter_track)
|
||||
_make_stats(1, 1, 1, pa=50)
|
||||
# Same player, different team — should not count
|
||||
_make_stats(1, 2, 1, pa=200)
|
||||
result = _eval(1, 1)
|
||||
# Only pa=50 counted → value=50 → T1
|
||||
assert result["current_tier"] == 1
|
||||
assert result["current_value"] == 50.0
|
||||
|
||||
|
||||
class TestMissingState:
|
||||
"""ValueError when no card state exists for (player_id, team_id)."""
|
||||
|
||||
def test_missing_state_raises(self, batter_track):
|
||||
"""evaluate_card raises ValueError when no state row exists."""
|
||||
# No card state created
|
||||
with pytest.raises(ValueError, match="No evolution_card_state"):
|
||||
_eval(99, 99)
|
||||
|
||||
|
||||
class TestReturnShape:
|
||||
"""Return dict has the expected keys and types."""
|
||||
|
||||
def test_return_keys(self, batter_track):
|
||||
"""Result dict contains all expected keys."""
|
||||
_make_state(1, 1, batter_track)
|
||||
result = _eval(1, 1)
|
||||
assert set(result.keys()) == {
|
||||
"player_id",
|
||||
"team_id",
|
||||
"current_tier",
|
||||
"current_value",
|
||||
"fully_evolved",
|
||||
"last_evaluated_at",
|
||||
}
|
||||
|
||||
def test_last_evaluated_at_is_iso_string(self, batter_track):
|
||||
"""last_evaluated_at is a non-empty ISO-8601 string."""
|
||||
_make_state(1, 1, batter_track)
|
||||
result = _eval(1, 1)
|
||||
ts = result["last_evaluated_at"]
|
||||
assert isinstance(ts, str) and len(ts) > 0
|
||||
# Must be parseable as a datetime
|
||||
datetime.fromisoformat(ts)
|
||||
326
tests/test_evolution_init.py
Normal file
326
tests/test_evolution_init.py
Normal file
@ -0,0 +1,326 @@
|
||||
"""
|
||||
Tests for WP-10: evolution_card_state initialization on pack opening.
|
||||
|
||||
Covers `app/services/evolution_init.py` — the `initialize_card_evolution`
|
||||
function that creates an EvolutionCardState row when a card is first acquired.
|
||||
|
||||
Test strategy:
|
||||
- Unit tests for `_determine_card_type` cover all three branches (batter,
|
||||
SP, RP/CP) using plain objects so no database round-trip is needed.
|
||||
- Integration tests run against the in-memory SQLite database (conftest.py
|
||||
autouse fixture) and exercise the full get_or_create path.
|
||||
|
||||
Why we test idempotency:
|
||||
Pack-opening can post duplicate cards (e.g. the same player ID appears in
|
||||
two separate pack insertions). The get_or_create guarantee means the second
|
||||
call must be a no-op — it must not reset current_tier/current_value of a
|
||||
card that has already started evolving.
|
||||
|
||||
Why we test cross-player isolation:
|
||||
Two different players with the same team must each get their own
|
||||
EvolutionCardState row. A bug that checked only team_id would share state
|
||||
across players, so we assert that state.player_id matches.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db_engine import (
|
||||
Cardset,
|
||||
EvolutionCardState,
|
||||
EvolutionTrack,
|
||||
Player,
|
||||
)
|
||||
from app.services.evolution_init import _determine_card_type, initialize_card_evolution
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakePlayer:
|
||||
"""Minimal stand-in for a Player instance used in unit tests.
|
||||
|
||||
We only need pos_1 for card-type determination; real FK fields are
|
||||
not required by the pure function under test.
|
||||
"""
|
||||
|
||||
def __init__(self, pos_1: str):
|
||||
self.pos_1 = pos_1
|
||||
|
||||
|
||||
def _make_player(rarity, pos_1: str) -> Player:
|
||||
"""Create a minimal Player row with the given pos_1 value.
|
||||
|
||||
A fresh Cardset is created per call so that players are independent
|
||||
of each other and can be iterated over in separate test cases without
|
||||
FK conflicts.
|
||||
"""
|
||||
cardset = Cardset.create(
|
||||
name=f"Set-{pos_1}-{id(pos_1)}",
|
||||
description="Test",
|
||||
total_cards=1,
|
||||
)
|
||||
return Player.create(
|
||||
p_name=f"Player {pos_1}",
|
||||
rarity=rarity,
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
pos_1=pos_1,
|
||||
image="https://example.com/img.png",
|
||||
mlbclub="TST",
|
||||
franchise="TST",
|
||||
description="test",
|
||||
)
|
||||
|
||||
|
||||
def _make_track(card_type: str) -> EvolutionTrack:
|
||||
"""Create an EvolutionTrack for the given card_type.
|
||||
|
||||
Thresholds are kept small and arbitrary; the unit under test only
|
||||
cares about card_type when selecting the track.
|
||||
"""
|
||||
return EvolutionTrack.create(
|
||||
name=f"Track-{card_type}",
|
||||
card_type=card_type,
|
||||
formula="pa",
|
||||
t1_threshold=10,
|
||||
t2_threshold=40,
|
||||
t3_threshold=120,
|
||||
t4_threshold=240,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests — _determine_card_type (no DB required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDetermineCardType:
|
||||
"""Unit tests for _determine_card_type, the pure position-to-type mapper.
|
||||
|
||||
The function receives a Player (or any object with a pos_1 attribute) and
|
||||
returns one of the three strings 'batter', 'sp', or 'rp'. These unit
|
||||
tests use _FakePlayer so no database is touched and failures are fast.
|
||||
"""
|
||||
|
||||
def test_starting_pitcher(self):
|
||||
"""pos_1 == 'SP' maps to card_type 'sp'.
|
||||
|
||||
SP is the canonical starting-pitcher position string stored in
|
||||
Player.pos_1 by the card-creation pipeline.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("SP")) == "sp"
|
||||
|
||||
def test_relief_pitcher(self):
|
||||
"""pos_1 == 'RP' maps to card_type 'rp'.
|
||||
|
||||
Relief pitchers carry the 'RP' position flag and must follow a
|
||||
separate evolution track with lower thresholds.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("RP")) == "rp"
|
||||
|
||||
def test_closer_pitcher(self):
|
||||
"""pos_1 == 'CP' maps to card_type 'rp'.
|
||||
|
||||
Closers share the RP evolution track; the spec explicitly lists 'CP'
|
||||
as an rp-track position.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("CP")) == "rp"
|
||||
|
||||
def test_infielder_is_batter(self):
|
||||
"""pos_1 == '1B' maps to card_type 'batter'.
|
||||
|
||||
Any non-pitcher position (1B, 2B, 3B, SS, OF, C, DH, etc.) should
|
||||
fall through to the batter track.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("1B")) == "batter"
|
||||
|
||||
def test_catcher_is_batter(self):
|
||||
"""pos_1 == 'C' maps to card_type 'batter'."""
|
||||
assert _determine_card_type(_FakePlayer("C")) == "batter"
|
||||
|
||||
def test_dh_is_batter(self):
|
||||
"""pos_1 == 'DH' maps to card_type 'batter'.
|
||||
|
||||
Designated hitters have no defensive rating but accumulate batting
|
||||
stats, so they belong on the batter track.
|
||||
"""
|
||||
assert _determine_card_type(_FakePlayer("DH")) == "batter"
|
||||
|
||||
def test_outfielder_is_batter(self):
|
||||
"""pos_1 == 'CF' maps to card_type 'batter'."""
|
||||
assert _determine_card_type(_FakePlayer("CF")) == "batter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — initialize_card_evolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInitializeCardEvolution:
|
||||
"""Integration tests for initialize_card_evolution against in-memory SQLite.
|
||||
|
||||
Each test relies on the conftest autouse fixture to get a clean database.
|
||||
We create tracks for all three card types so the function can always find
|
||||
a matching track regardless of which player position is used.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def seed_tracks(self):
|
||||
"""Create one EvolutionTrack per card_type before each test.
|
||||
|
||||
initialize_card_evolution does a DB lookup for a track matching the
|
||||
card_type. If no track exists the function must not crash (it should
|
||||
log and return None), but having tracks present lets us verify the
|
||||
happy path for all three types without repeating setup in every test.
|
||||
"""
|
||||
self.batter_track = _make_track("batter")
|
||||
self.sp_track = _make_track("sp")
|
||||
self.rp_track = _make_track("rp")
|
||||
|
||||
def test_first_card_creates_state(self, rarity, team):
|
||||
"""First acquisition creates an EvolutionCardState with zeroed values.
|
||||
|
||||
Acceptance criteria from WP-10:
|
||||
- current_tier == 0
|
||||
- current_value == 0.0
|
||||
- fully_evolved == False
|
||||
- track matches the player's card_type (batter here)
|
||||
"""
|
||||
player = _make_player(rarity, "2B")
|
||||
state = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||
|
||||
assert state is not None
|
||||
assert state.player_id == player.player_id
|
||||
assert state.team_id == team.id
|
||||
assert state.track_id == self.batter_track.id
|
||||
assert state.current_tier == 0
|
||||
assert state.current_value == 0.0
|
||||
assert state.fully_evolved is False
|
||||
|
||||
def test_duplicate_card_skips_creation(self, rarity, team):
|
||||
"""Second call for the same (player_id, team_id) is a no-op.
|
||||
|
||||
The get_or_create guarantee: if a state row already exists it must
|
||||
not be overwritten. This protects cards that have already started
|
||||
evolving — their current_tier and current_value must be preserved.
|
||||
"""
|
||||
player = _make_player(rarity, "SS")
|
||||
# First call creates the state
|
||||
state1 = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||
assert state1 is not None
|
||||
|
||||
# Simulate partial evolution progress
|
||||
state1.current_tier = 2
|
||||
state1.current_value = 250.0
|
||||
state1.save()
|
||||
|
||||
# Second call (duplicate card) must not reset progress
|
||||
state2 = initialize_card_evolution(player.player_id, team.id, "batter")
|
||||
assert state2 is not None
|
||||
|
||||
# Exactly one row in the database
|
||||
count = (
|
||||
EvolutionCardState.select()
|
||||
.where(
|
||||
EvolutionCardState.player == player,
|
||||
EvolutionCardState.team == team,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
assert count == 1
|
||||
|
||||
# Progress was NOT reset
|
||||
refreshed = EvolutionCardState.get_by_id(state1.id)
|
||||
assert refreshed.current_tier == 2
|
||||
assert refreshed.current_value == 250.0
|
||||
|
||||
def test_different_player_creates_new_state(self, rarity, team):
|
||||
"""Two different players on the same team each get their own state row.
|
||||
|
||||
Cross-player isolation: the (player_id, team_id) uniqueness means
|
||||
player A and player B must have separate rows even though team_id is
|
||||
the same.
|
||||
"""
|
||||
player_a = _make_player(rarity, "LF")
|
||||
player_b = _make_player(rarity, "RF")
|
||||
|
||||
state_a = initialize_card_evolution(player_a.player_id, team.id, "batter")
|
||||
state_b = initialize_card_evolution(player_b.player_id, team.id, "batter")
|
||||
|
||||
assert state_a is not None
|
||||
assert state_b is not None
|
||||
assert state_a.id != state_b.id
|
||||
assert state_a.player_id == player_a.player_id
|
||||
assert state_b.player_id == player_b.player_id
|
||||
|
||||
def test_sp_card_gets_sp_track(self, rarity, team):
|
||||
"""A starting pitcher is assigned the 'sp' EvolutionTrack.
|
||||
|
||||
Track selection is driven by card_type, which in turn comes from
|
||||
pos_1. This test passes card_type='sp' explicitly (mirroring the
|
||||
router hook that calls _determine_card_type first) and confirms the
|
||||
state links to the sp track, not the batter track.
|
||||
"""
|
||||
player = _make_player(rarity, "SP")
|
||||
state = initialize_card_evolution(player.player_id, team.id, "sp")
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.sp_track.id
|
||||
|
||||
def test_rp_card_gets_rp_track(self, rarity, team):
|
||||
"""A relief pitcher (RP or CP) is assigned the 'rp' EvolutionTrack."""
|
||||
player = _make_player(rarity, "RP")
|
||||
state = initialize_card_evolution(player.player_id, team.id, "rp")
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.rp_track.id
|
||||
|
||||
def test_missing_track_returns_none(self, rarity, team):
|
||||
"""If no track exists for the card_type, the function returns None.
|
||||
|
||||
This is the safe-failure path: the function must not raise an
|
||||
exception if the evolution system is misconfigured (e.g. track seed
|
||||
data missing). It logs the problem and returns None so that the
|
||||
caller (the cards router) can proceed with pack opening unaffected.
|
||||
|
||||
We use a fictional card_type that has no matching seed row.
|
||||
"""
|
||||
player = _make_player(rarity, "SP")
|
||||
# Delete the sp track to simulate missing seed data
|
||||
self.sp_track.delete_instance()
|
||||
|
||||
result = initialize_card_evolution(player.player_id, team.id, "sp")
|
||||
assert result is None
|
||||
|
||||
def test_card_type_from_pos1_batter(self, rarity, team):
|
||||
"""_determine_card_type is wired correctly for a batter position.
|
||||
|
||||
End-to-end: pass the player object directly and verify the state
|
||||
ends up on the batter track based solely on pos_1.
|
||||
"""
|
||||
player = _make_player(rarity, "3B")
|
||||
card_type = _determine_card_type(player)
|
||||
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.batter_track.id
|
||||
|
||||
def test_card_type_from_pos1_sp(self, rarity, team):
|
||||
"""_determine_card_type is wired correctly for a starting pitcher."""
|
||||
player = _make_player(rarity, "SP")
|
||||
card_type = _determine_card_type(player)
|
||||
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.sp_track.id
|
||||
|
||||
def test_card_type_from_pos1_rp(self, rarity, team):
|
||||
"""_determine_card_type correctly routes CP to the rp track."""
|
||||
player = _make_player(rarity, "CP")
|
||||
card_type = _determine_card_type(player)
|
||||
state = initialize_card_evolution(player.player_id, team.id, card_type)
|
||||
|
||||
assert state is not None
|
||||
assert state.track_id == self.rp_track.id
|
||||
@ -1,5 +1,5 @@
|
||||
"""
|
||||
Tests for evolution-related models and PlayerSeasonStats.
|
||||
Tests for evolution-related models and BattingSeasonStats.
|
||||
|
||||
Covers WP-01 acceptance criteria:
|
||||
- EvolutionTrack: CRUD and unique-name constraint
|
||||
@ -7,7 +7,7 @@ Covers WP-01 acceptance criteria:
|
||||
and FK resolution back to EvolutionTrack
|
||||
- EvolutionTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
|
||||
- EvolutionCosmetic: CRUD and unique-name constraint
|
||||
- PlayerSeasonStats: CRUD with defaults, unique-(player, team, season),
|
||||
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
|
||||
and in-place stat accumulation
|
||||
|
||||
Each test class is self-contained: fixtures from conftest.py supply the
|
||||
@ -20,11 +20,11 @@ from peewee import IntegrityError
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from app.db_engine import (
|
||||
PlayerSeasonStats,
|
||||
EvolutionCardState,
|
||||
EvolutionCosmetic,
|
||||
EvolutionTierBoost,
|
||||
EvolutionTrack,
|
||||
PlayerSeasonStats,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -244,12 +244,12 @@ class TestEvolutionCosmetic:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlayerSeasonStats
|
||||
# BattingSeasonStats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlayerSeasonStats:
|
||||
"""Tests for PlayerSeasonStats, the per-season accumulation table.
|
||||
"""Tests for BattingSeasonStats, the per-season accumulation table.
|
||||
|
||||
Each row aggregates game-by-game batting and pitching stats for one
|
||||
player on one team in one season. The three-column unique constraint
|
||||
|
||||
609
tests/test_evolution_state_api.py
Normal file
609
tests/test_evolution_state_api.py
Normal file
@ -0,0 +1,609 @@
|
||||
"""Integration tests for the evolution card state API endpoints (WP-07).
|
||||
|
||||
Tests cover:
|
||||
GET /api/v2/teams/{team_id}/evolutions
|
||||
GET /api/v2/evolution/cards/{card_id}
|
||||
|
||||
All tests require a live PostgreSQL connection (POSTGRES_HOST env var) and
|
||||
assume the evolution schema migration (WP-04) has already been applied.
|
||||
Tests auto-skip when POSTGRES_HOST is not set.
|
||||
|
||||
Test data is inserted via psycopg2 before each module fixture runs and
|
||||
cleaned up in teardown so the tests are repeatable. ON CONFLICT / CASCADE
|
||||
clauses keep the table clean even if a previous run did not complete teardown.
|
||||
|
||||
Object graph built by fixtures
|
||||
-------------------------------
|
||||
rarity_row -- a seeded rarity row
|
||||
cardset_row -- a seeded cardset row
|
||||
player_row -- a seeded player row (FK: rarity, cardset)
|
||||
team_row -- a seeded team row
|
||||
track_row -- a seeded evolution_track row (batter)
|
||||
card_row -- a seeded card row (FK: player, team, pack, pack_type, cardset)
|
||||
state_row -- a seeded evolution_card_state row (FK: player, team, track)
|
||||
|
||||
Test matrix
|
||||
-----------
|
||||
test_list_team_evolutions -- baseline: returns count + items for a team
|
||||
test_list_filter_by_card_type -- card_type query param filters by track.card_type
|
||||
test_list_filter_by_tier -- tier query param filters by current_tier
|
||||
test_list_pagination -- page/per_page params slice results correctly
|
||||
test_get_card_state_shape -- single card returns all required response fields
|
||||
test_get_card_state_next_threshold -- next_threshold is the threshold for tier above current
|
||||
test_get_card_id_resolves_player -- card_id joins Card -> Player/Team -> EvolutionCardState
|
||||
test_get_card_404_no_state -- card with no EvolutionCardState returns 404
|
||||
test_duplicate_cards_share_state -- two cards same player+team return the same state row
|
||||
test_auth_required -- missing token returns 401 on both endpoints
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
||||
_skip_no_pg = pytest.mark.skipif(
|
||||
not POSTGRES_HOST, reason="POSTGRES_HOST not set — integration tests skipped"
|
||||
)
|
||||
|
||||
AUTH_HEADER = {"Authorization": f"Bearer {os.environ.get('API_TOKEN', 'test-token')}"}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures: seed and clean up the full object graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_data(pg_conn):
|
||||
"""Insert all rows needed for state API tests; delete them after the module.
|
||||
|
||||
Returns a dict with the integer IDs of every inserted row so individual
|
||||
test functions can reference them by key.
|
||||
|
||||
Insertion order respects FK dependencies:
|
||||
rarity -> cardset -> player
|
||||
pack_type (needs cardset) -> pack (needs team + pack_type) -> card
|
||||
evolution_track -> evolution_card_state
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# Rarity
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO rarity (value, name, color)
|
||||
VALUES (99, 'WP07TestRarity', '#123456')
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
rarity_id = cur.fetchone()[0]
|
||||
|
||||
# Cardset
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO cardset (name, description, total_cards)
|
||||
VALUES ('WP07 Test Set', 'evo state api tests', 1)
|
||||
ON CONFLICT (name) DO UPDATE SET description = EXCLUDED.description
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
cardset_id = cur.fetchone()[0]
|
||||
|
||||
# Player 1 (batter)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
|
||||
image, mlbclub, franchise, description)
|
||||
VALUES ('WP07 Batter', %s, %s, 901, '1B',
|
||||
'https://example.com/wp07_b.png', 'TST', 'TST', 'wp07 test batter')
|
||||
RETURNING player_id
|
||||
""",
|
||||
(rarity_id, cardset_id),
|
||||
)
|
||||
player_id = cur.fetchone()[0]
|
||||
|
||||
# Player 2 (sp) for cross-card_type filter test
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO player (p_name, rarity_id, cardset_id, set_num, pos_1,
|
||||
image, mlbclub, franchise, description)
|
||||
VALUES ('WP07 Pitcher', %s, %s, 902, 'SP',
|
||||
'https://example.com/wp07_p.png', 'TST', 'TST', 'wp07 test pitcher')
|
||||
RETURNING player_id
|
||||
""",
|
||||
(rarity_id, cardset_id),
|
||||
)
|
||||
player2_id = cur.fetchone()[0]
|
||||
|
||||
# Team
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO team (abbrev, sname, lname, gmid, gmname, gsheet,
|
||||
wallet, team_value, collection_value, season, is_ai)
|
||||
VALUES ('WP7', 'WP07', 'WP07 Test Team', 700000001, 'wp07user',
|
||||
'https://docs.google.com/wp07', 0, 0, 0, 11, false)
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
team_id = cur.fetchone()[0]
|
||||
|
||||
# Evolution tracks
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_track (name, card_type, formula,
|
||||
t1_threshold, t2_threshold,
|
||||
t3_threshold, t4_threshold)
|
||||
VALUES ('WP07 Batter Track', 'batter', 'pa + tb * 2', 37, 149, 448, 896)
|
||||
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
batter_track_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_track (name, card_type, formula,
|
||||
t1_threshold, t2_threshold,
|
||||
t3_threshold, t4_threshold)
|
||||
VALUES ('WP07 SP Track', 'sp', 'ip + k', 10, 40, 120, 240)
|
||||
ON CONFLICT (name) DO UPDATE SET card_type = EXCLUDED.card_type
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
sp_track_id = cur.fetchone()[0]
|
||||
|
||||
# Pack type + pack (needed as FK parent for Card)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack_type (name, cost, card_count, cardset_id)
|
||||
VALUES ('WP07 Pack Type', 100, 5, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(cardset_id,),
|
||||
)
|
||||
pack_type_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack (team_id, pack_type_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(team_id, pack_type_id),
|
||||
)
|
||||
pack_id = cur.fetchone()[0]
|
||||
|
||||
# Card linking batter player to team
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||
VALUES (%s, %s, %s, 0)
|
||||
RETURNING id
|
||||
""",
|
||||
(player_id, team_id, pack_id),
|
||||
)
|
||||
card_id = cur.fetchone()[0]
|
||||
|
||||
# Second card for same player+team (shared-state test)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack (team_id, pack_type_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(team_id, pack_type_id),
|
||||
)
|
||||
pack2_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||
VALUES (%s, %s, %s, 0)
|
||||
RETURNING id
|
||||
""",
|
||||
(player_id, team_id, pack2_id),
|
||||
)
|
||||
card2_id = cur.fetchone()[0]
|
||||
|
||||
# Card with NO state (404 test)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO pack (team_id, pack_type_id)
|
||||
VALUES (%s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(team_id, pack_type_id),
|
||||
)
|
||||
pack3_id = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO card (player_id, team_id, pack_id, value)
|
||||
VALUES (%s, %s, %s, 0)
|
||||
RETURNING id
|
||||
""",
|
||||
(player2_id, team_id, pack3_id),
|
||||
)
|
||||
card_no_state_id = cur.fetchone()[0]
|
||||
|
||||
# Evolution card states
|
||||
# Batter player at tier 1, value 87.5
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_card_state
|
||||
(player_id, team_id, track_id, current_tier, current_value,
|
||||
fully_evolved, last_evaluated_at)
|
||||
VALUES (%s, %s, %s, 1, 87.5, false, '2026-03-12T14:00:00Z')
|
||||
RETURNING id
|
||||
""",
|
||||
(player_id, team_id, batter_track_id),
|
||||
)
|
||||
state_id = cur.fetchone()[0]
|
||||
|
||||
pg_conn.commit()
|
||||
|
||||
yield {
|
||||
"rarity_id": rarity_id,
|
||||
"cardset_id": cardset_id,
|
||||
"player_id": player_id,
|
||||
"player2_id": player2_id,
|
||||
"team_id": team_id,
|
||||
"batter_track_id": batter_track_id,
|
||||
"sp_track_id": sp_track_id,
|
||||
"pack_type_id": pack_type_id,
|
||||
"card_id": card_id,
|
||||
"card2_id": card2_id,
|
||||
"card_no_state_id": card_no_state_id,
|
||||
"state_id": state_id,
|
||||
}
|
||||
|
||||
# Teardown: delete in reverse FK order
|
||||
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (state_id,))
|
||||
cur.execute(
|
||||
"DELETE FROM card WHERE id = ANY(%s)",
|
||||
([card_id, card2_id, card_no_state_id],),
|
||||
)
|
||||
cur.execute("DELETE FROM pack WHERE id = ANY(%s)", ([pack_id, pack2_id, pack3_id],))
|
||||
cur.execute("DELETE FROM pack_type WHERE id = %s", (pack_type_id,))
|
||||
cur.execute(
|
||||
"DELETE FROM evolution_track WHERE id = ANY(%s)",
|
||||
([batter_track_id, sp_track_id],),
|
||||
)
|
||||
cur.execute(
|
||||
"DELETE FROM player WHERE player_id = ANY(%s)", ([player_id, player2_id],)
|
||||
)
|
||||
cur.execute("DELETE FROM team WHERE id = %s", (team_id,))
|
||||
cur.execute("DELETE FROM cardset WHERE id = %s", (cardset_id,))
|
||||
cur.execute("DELETE FROM rarity WHERE id = %s", (rarity_id,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""FastAPI TestClient backed by the real PostgreSQL database."""
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: GET /api/v2/teams/{team_id}/evolutions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_team_evolutions(client, seeded_data):
|
||||
"""GET /teams/{id}/evolutions returns count=1 and one item for the seeded state.
|
||||
|
||||
Verifies the basic list response shape: a dict with 'count' and 'items',
|
||||
and that the single item contains player_id, team_id, and current_tier.
|
||||
"""
|
||||
team_id = seeded_data["team_id"]
|
||||
resp = client.get(f"/api/v2/teams/{team_id}/evolutions", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 1
|
||||
assert len(data["items"]) == 1
|
||||
item = data["items"][0]
|
||||
assert item["player_id"] == seeded_data["player_id"]
|
||||
assert item["team_id"] == team_id
|
||||
assert item["current_tier"] == 1
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_filter_by_card_type(client, seeded_data, pg_conn):
|
||||
"""card_type filter includes states whose track.card_type matches and excludes others.
|
||||
|
||||
Seeds a second evolution_card_state for player2 (sp track) then queries
|
||||
card_type=batter (returns 1) and card_type=sp (returns 1).
|
||||
Verifies the JOIN to evolution_track and the WHERE predicate on card_type.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
# Add a state for the sp player so we have two types in this team
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_card_state
|
||||
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
|
||||
VALUES (%s, %s, %s, 0, 0.0, false)
|
||||
RETURNING id
|
||||
""",
|
||||
(seeded_data["player2_id"], seeded_data["team_id"], seeded_data["sp_track_id"]),
|
||||
)
|
||||
sp_state_id = cur.fetchone()[0]
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
team_id = seeded_data["team_id"]
|
||||
|
||||
resp_batter = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?card_type=batter", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_batter.status_code == 200
|
||||
batter_data = resp_batter.json()
|
||||
assert batter_data["count"] == 1
|
||||
assert batter_data["items"][0]["player_id"] == seeded_data["player_id"]
|
||||
|
||||
resp_sp = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?card_type=sp", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_sp.status_code == 200
|
||||
sp_data = resp_sp.json()
|
||||
assert sp_data["count"] == 1
|
||||
assert sp_data["items"][0]["player_id"] == seeded_data["player2_id"]
|
||||
finally:
|
||||
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (sp_state_id,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_filter_by_tier(client, seeded_data, pg_conn):
|
||||
"""tier filter includes only states at the specified current_tier.
|
||||
|
||||
The base fixture has player1 at tier=1. This test temporarily advances
|
||||
it to tier=2, then queries tier=1 (should return 0) and tier=2 (should
|
||||
return 1). Restores to tier=1 after assertions.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
|
||||
# Advance to tier 2
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s",
|
||||
(seeded_data["state_id"],),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
team_id = seeded_data["team_id"]
|
||||
|
||||
resp_t1 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?tier=1", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_t1.status_code == 200
|
||||
assert resp_t1.json()["count"] == 0
|
||||
|
||||
resp_t2 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?tier=2", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp_t2.status_code == 200
|
||||
t2_data = resp_t2.json()
|
||||
assert t2_data["count"] == 1
|
||||
assert t2_data["items"][0]["current_tier"] == 2
|
||||
finally:
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 1 WHERE id = %s",
|
||||
(seeded_data["state_id"],),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_pagination(client, seeded_data, pg_conn):
|
||||
"""page/per_page params slice the full result set correctly.
|
||||
|
||||
Temporarily inserts a second state (for player2 on the same team) so
|
||||
the list has 2 items. With per_page=1, page=1 returns item 1 and
|
||||
page=2 returns item 2; they must be different players.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_card_state
|
||||
(player_id, team_id, track_id, current_tier, current_value, fully_evolved)
|
||||
VALUES (%s, %s, %s, 0, 0.0, false)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
seeded_data["player2_id"],
|
||||
seeded_data["team_id"],
|
||||
seeded_data["batter_track_id"],
|
||||
),
|
||||
)
|
||||
extra_state_id = cur.fetchone()[0]
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
team_id = seeded_data["team_id"]
|
||||
|
||||
resp1 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?page=1&per_page=1", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
data1 = resp1.json()
|
||||
assert len(data1["items"]) == 1
|
||||
|
||||
resp2 = client.get(
|
||||
f"/api/v2/teams/{team_id}/evolutions?page=2&per_page=1", headers=AUTH_HEADER
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
data2 = resp2.json()
|
||||
assert len(data2["items"]) == 1
|
||||
|
||||
assert data1["items"][0]["player_id"] != data2["items"][0]["player_id"]
|
||||
finally:
|
||||
cur.execute("DELETE FROM evolution_card_state WHERE id = %s", (extra_state_id,))
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: GET /api/v2/evolution/cards/{card_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_state_shape(client, seeded_data):
|
||||
"""GET /evolution/cards/{card_id} returns all required fields.
|
||||
|
||||
Verifies the full response envelope:
|
||||
player_id, team_id, current_tier, current_value, fully_evolved,
|
||||
last_evaluated_at, next_threshold, and a nested 'track' dict
|
||||
with id, name, card_type, formula, and t1-t4 thresholds.
|
||||
"""
|
||||
card_id = seeded_data["card_id"]
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
|
||||
assert data["player_id"] == seeded_data["player_id"]
|
||||
assert data["team_id"] == seeded_data["team_id"]
|
||||
assert data["current_tier"] == 1
|
||||
assert data["current_value"] == 87.5
|
||||
assert data["fully_evolved"] is False
|
||||
|
||||
t = data["track"]
|
||||
assert t["id"] == seeded_data["batter_track_id"]
|
||||
assert t["name"] == "WP07 Batter Track"
|
||||
assert t["card_type"] == "batter"
|
||||
assert t["formula"] == "pa + tb * 2"
|
||||
assert t["t1_threshold"] == 37
|
||||
assert t["t2_threshold"] == 149
|
||||
assert t["t3_threshold"] == 448
|
||||
assert t["t4_threshold"] == 896
|
||||
|
||||
# tier=1 -> next threshold is t2_threshold
|
||||
assert data["next_threshold"] == 149
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_state_next_threshold(client, seeded_data, pg_conn):
|
||||
"""next_threshold reflects the threshold for the tier immediately above current.
|
||||
|
||||
Tier mapping:
|
||||
0 -> t1_threshold (37)
|
||||
1 -> t2_threshold (149)
|
||||
2 -> t3_threshold (448)
|
||||
3 -> t4_threshold (896)
|
||||
4 -> null (fully evolved)
|
||||
|
||||
This test advances the state to tier=2, confirms next_threshold=448,
|
||||
then to tier=4 (fully_evolved=True) and confirms next_threshold=null.
|
||||
Restores original state after assertions.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
card_id = seeded_data["card_id"]
|
||||
state_id = seeded_data["state_id"]
|
||||
|
||||
# Advance to tier 2
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 2 WHERE id = %s", (state_id,)
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
try:
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["next_threshold"] == 448 # t3_threshold
|
||||
|
||||
# Advance to tier 4 (fully evolved)
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 4, fully_evolved = true "
|
||||
"WHERE id = %s",
|
||||
(state_id,),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
resp2 = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["next_threshold"] is None
|
||||
finally:
|
||||
cur.execute(
|
||||
"UPDATE evolution_card_state SET current_tier = 1, fully_evolved = false "
|
||||
"WHERE id = %s",
|
||||
(state_id,),
|
||||
)
|
||||
pg_conn.commit()
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_id_resolves_player(client, seeded_data):
|
||||
"""card_id is resolved via the Card table to obtain (player_id, team_id).
|
||||
|
||||
The endpoint must JOIN Card -> Player + Team to find the EvolutionCardState.
|
||||
Verifies that card_id correctly maps to the right player's evolution state.
|
||||
"""
|
||||
card_id = seeded_data["card_id"]
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["player_id"] == seeded_data["player_id"]
|
||||
assert data["team_id"] == seeded_data["team_id"]
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_card_404_no_state(client, seeded_data):
|
||||
"""GET /evolution/cards/{card_id} returns 404 when no EvolutionCardState exists.
|
||||
|
||||
card_no_state_id is a card row for player2 on the team, but no
|
||||
evolution_card_state row was created for player2. The endpoint must
|
||||
return 404, not 500 or an empty response.
|
||||
"""
|
||||
card_id = seeded_data["card_no_state_id"]
|
||||
resp = client.get(f"/api/v2/evolution/cards/{card_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_duplicate_cards_share_state(client, seeded_data):
|
||||
"""Two Card rows for the same player+team share one EvolutionCardState.
|
||||
|
||||
card_id and card2_id both belong to player_id on team_id. Because the
|
||||
unique-(player,team) constraint means only one state row can exist, both
|
||||
card IDs must resolve to the same state data.
|
||||
"""
|
||||
card1_id = seeded_data["card_id"]
|
||||
card2_id = seeded_data["card2_id"]
|
||||
|
||||
resp1 = client.get(f"/api/v2/evolution/cards/{card1_id}", headers=AUTH_HEADER)
|
||||
resp2 = client.get(f"/api/v2/evolution/cards/{card2_id}", headers=AUTH_HEADER)
|
||||
|
||||
assert resp1.status_code == 200
|
||||
assert resp2.status_code == 200
|
||||
data1 = resp1.json()
|
||||
data2 = resp2.json()
|
||||
|
||||
assert data1["player_id"] == data2["player_id"] == seeded_data["player_id"]
|
||||
assert data1["current_tier"] == data2["current_tier"] == 1
|
||||
assert data1["current_value"] == data2["current_value"] == 87.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_auth_required(client, seeded_data):
|
||||
"""Both endpoints return 401 when no Bearer token is provided.
|
||||
|
||||
Verifies that the valid_token dependency is enforced on:
|
||||
GET /api/v2/teams/{id}/evolutions
|
||||
GET /api/v2/evolution/cards/{id}
|
||||
"""
|
||||
team_id = seeded_data["team_id"]
|
||||
card_id = seeded_data["card_id"]
|
||||
|
||||
resp_list = client.get(f"/api/v2/teams/{team_id}/evolutions")
|
||||
assert resp_list.status_code == 401
|
||||
|
||||
resp_card = client.get(f"/api/v2/evolution/cards/{card_id}")
|
||||
assert resp_card.status_code == 401
|
||||
@ -2,8 +2,9 @@
|
||||
Tests for app/services/season_stats.py — update_season_stats().
|
||||
|
||||
What: Verify that the incremental stat accumulation function correctly
|
||||
aggregates StratPlay and Decision rows into PlayerSeasonStats, handles
|
||||
duplicate calls idempotently, and accumulates stats across multiple games.
|
||||
aggregates StratPlay and Decision rows into BattingSeasonStats and
|
||||
PitchingSeasonStats, handles duplicate calls idempotently, and
|
||||
accumulates stats across multiple games.
|
||||
|
||||
Why: This is the core bookkeeping engine for card evolution scoring. A
|
||||
double-count bug, a missed Decision merge, or a team-isolation failure
|
||||
@ -20,10 +21,11 @@ import app.services.season_stats as _season_stats_module
|
||||
import pytest
|
||||
|
||||
from app.db_engine import (
|
||||
BattingSeasonStats,
|
||||
Cardset,
|
||||
Decision,
|
||||
PitchingSeasonStats,
|
||||
Player,
|
||||
PlayerSeasonStats,
|
||||
Rarity,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
@ -36,9 +38,9 @@ from tests.conftest import _test_db
|
||||
# Module-level patch: redirect season_stats.db to the test database
|
||||
# ---------------------------------------------------------------------------
|
||||
# season_stats.py holds a module-level reference to the `db` object imported
|
||||
# from db_engine. When test models are rebound to _test_db via bind(), the
|
||||
# from db_engine. When test models are rebound to _test_db via bind(), the
|
||||
# `db` object inside season_stats still points at the original production db
|
||||
# (SQLite file or PostgreSQL). We replace it here so that db.atomic() in
|
||||
# (SQLite file or PostgreSQL). We replace it here so that db.atomic() in
|
||||
# update_season_stats() operates on the same in-memory connection that the
|
||||
# test fixtures write to.
|
||||
_season_stats_module.db = _test_db
|
||||
@ -262,20 +264,20 @@ def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher
|
||||
result = update_season_stats(game.id)
|
||||
|
||||
assert result["batters_updated"] >= 1
|
||||
stats = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == player_batter,
|
||||
PlayerSeasonStats.team == team_a,
|
||||
PlayerSeasonStats.season == 11,
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 4
|
||||
assert stats.ab == 3
|
||||
assert stats.hits == 2
|
||||
assert stats.hr == 1
|
||||
assert stats.so == 1
|
||||
assert stats.strikeouts == 1
|
||||
assert stats.bb == 1
|
||||
assert stats.rbi == 1
|
||||
assert stats.runs == 1
|
||||
assert stats.games_batting == 1
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_single_game_pitching_stats(
|
||||
@ -332,16 +334,16 @@ def test_single_game_pitching_stats(
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
stats = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == player_pitcher,
|
||||
PlayerSeasonStats.team == team_b,
|
||||
PlayerSeasonStats.season == 11,
|
||||
stats = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == player_pitcher,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
PitchingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.outs == 1 # one strikeout = one out recorded
|
||||
assert stats.k == 1 # batter's so → pitcher's k
|
||||
assert stats.strikeouts == 1 # batter's so → pitcher's strikeouts
|
||||
assert stats.hits_allowed == 1 # batter's hit → pitcher hits_allowed
|
||||
assert stats.bb_allowed == 1 # batter's bb → pitcher bb_allowed
|
||||
assert stats.games_pitching == 1
|
||||
assert stats.bb == 1 # batter's bb → pitcher bb (walks allowed)
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_decision_integration(team_a, team_b, player_batter, player_pitcher, game):
|
||||
@ -382,10 +384,10 @@ def test_decision_integration(team_a, team_b, player_batter, player_pitcher, gam
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
stats = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == player_pitcher,
|
||||
PlayerSeasonStats.team == team_b,
|
||||
PlayerSeasonStats.season == 11,
|
||||
stats = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == player_pitcher,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
PitchingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.wins == 1
|
||||
assert stats.losses == 0
|
||||
@ -395,17 +397,13 @@ def test_double_count_prevention(team_a, team_b, player_batter, player_pitcher,
|
||||
"""Calling update_season_stats() twice for the same game must not double the stats.
|
||||
|
||||
What: Process a game once (pa=3), then immediately call the function
|
||||
again with the same game_id. The second call detects via the
|
||||
PlayerSeasonStats.last_game FK check that this game is still the
|
||||
most-recently-processed game and returns early with 'skipped'=True.
|
||||
The resulting pa should still be 3, not 6.
|
||||
again with the same game_id. The second call finds the ProcessedGame
|
||||
ledger row and returns early with 'skipped'=True. The resulting pa
|
||||
should still be 3, not 6.
|
||||
|
||||
Why: The bot infrastructure may deliver game-complete events more than
|
||||
once (network retries, message replays). The guard prevents
|
||||
double-counting when the replayed game is still the last game
|
||||
processed for those players. Note: this test only covers same-game
|
||||
immediate replay — out-of-order re-delivery (game G after G+1) is a
|
||||
known limitation tracked in issue #105.
|
||||
once (network retries, message replays). The ProcessedGame ledger
|
||||
provides full idempotency for all replay scenarios.
|
||||
"""
|
||||
for i in range(3):
|
||||
make_play(
|
||||
@ -428,17 +426,17 @@ def test_double_count_prevention(team_a, team_b, player_batter, player_pitcher,
|
||||
assert second_result["batters_updated"] == 0
|
||||
assert second_result["pitchers_updated"] == 0
|
||||
|
||||
stats = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == player_batter,
|
||||
PlayerSeasonStats.team == team_a,
|
||||
PlayerSeasonStats.season == 11,
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
# Must still be 3, not 6
|
||||
assert stats.pa == 3
|
||||
|
||||
|
||||
def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher):
|
||||
"""Stats from two separate games are summed in a single PlayerSeasonStats row.
|
||||
"""Stats from two separate games are summed in a single BattingSeasonStats row.
|
||||
|
||||
What: Process game 1 (pa=2) then game 2 (pa=3) for the same batter/team.
|
||||
After both updates the stats row should show pa=5.
|
||||
@ -485,13 +483,13 @@ def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher):
|
||||
update_season_stats(game1.id)
|
||||
update_season_stats(game2.id)
|
||||
|
||||
stats = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == player_batter,
|
||||
PlayerSeasonStats.team == team_a,
|
||||
PlayerSeasonStats.season == 11,
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 5
|
||||
assert stats.games_batting == 2
|
||||
assert stats.games == 2
|
||||
|
||||
|
||||
def test_two_team_game(team_a, team_b):
|
||||
@ -562,33 +560,102 @@ def test_two_team_game(team_a, team_b):
|
||||
update_season_stats(game.id)
|
||||
|
||||
# Team A's batter: 2 PA, 1 hit, 1 SO
|
||||
stats_ba = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == batter_a,
|
||||
PlayerSeasonStats.team == team_a,
|
||||
stats_ba = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == batter_a,
|
||||
BattingSeasonStats.team == team_a,
|
||||
)
|
||||
assert stats_ba.pa == 2
|
||||
assert stats_ba.hits == 1
|
||||
assert stats_ba.so == 1
|
||||
assert stats_ba.strikeouts == 1
|
||||
|
||||
# Team B's batter: 1 PA, 1 BB
|
||||
stats_bb = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == batter_b,
|
||||
PlayerSeasonStats.team == team_b,
|
||||
stats_bb = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == batter_b,
|
||||
BattingSeasonStats.team == team_b,
|
||||
)
|
||||
assert stats_bb.pa == 1
|
||||
assert stats_bb.bb == 1
|
||||
|
||||
# Team B's pitcher (faced team A's batter): 1 hit allowed, 1 K
|
||||
stats_pb = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == pitcher_b,
|
||||
PlayerSeasonStats.team == team_b,
|
||||
# Team B's pitcher (faced team A's batter): 1 hit allowed, 1 strikeout
|
||||
stats_pb = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == pitcher_b,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
)
|
||||
assert stats_pb.hits_allowed == 1
|
||||
assert stats_pb.k == 1
|
||||
assert stats_pb.strikeouts == 1
|
||||
|
||||
# Team A's pitcher (faced team B's batter): 1 BB allowed
|
||||
stats_pa = PlayerSeasonStats.get(
|
||||
PlayerSeasonStats.player == pitcher_a,
|
||||
PlayerSeasonStats.team == team_a,
|
||||
stats_pa = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == pitcher_a,
|
||||
PitchingSeasonStats.team == team_a,
|
||||
)
|
||||
assert stats_pa.bb_allowed == 1
|
||||
assert stats_pa.bb == 1
|
||||
|
||||
|
||||
def test_out_of_order_replay_prevented(team_a, team_b, player_batter, player_pitcher):
|
||||
"""Out-of-order re-delivery of game G (after G+1 was processed) must not double-count.
|
||||
|
||||
What: Process game G+1 first (pa=2), then process game G (pa=3). Now
|
||||
re-deliver game G. The third call must return 'skipped'=True and leave
|
||||
the batter's pa unchanged at 5 (3 + 2), not 8 (3 + 2 + 3).
|
||||
|
||||
Why: This is the failure mode that the old last_game FK guard could not
|
||||
catch. After G+1 is processed, no BattingSeasonStats row carries
|
||||
last_game=G anymore (it was overwritten to G+1). The old guard would
|
||||
have returned already_processed=False and double-counted. The
|
||||
ProcessedGame ledger fixes this by keying on game_id independently of
|
||||
the stats rows.
|
||||
"""
|
||||
game_g = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
game_g1 = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
|
||||
# Game G: 3 plate appearances
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game_g,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# Game G+1: 2 plate appearances
|
||||
for i in range(2):
|
||||
make_play(
|
||||
game_g1,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# Process G+1 first, then G — simulates out-of-order delivery
|
||||
update_season_stats(game_g1.id)
|
||||
update_season_stats(game_g.id)
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 5 # 3 (game G) + 2 (game G+1)
|
||||
|
||||
# Re-deliver game G — must be blocked by ProcessedGame ledger
|
||||
replay_result = update_season_stats(game_g.id)
|
||||
assert replay_result.get("skipped") is True
|
||||
|
||||
# Stats must remain at 5, not 8
|
||||
stats.refresh()
|
||||
assert stats.pa == 5
|
||||
|
||||
Loading…
Reference in New Issue
Block a user