Merge pull request 'feat(WP-13): post-game callback endpoints for season stats and evolution' (#109) from feature/wp-13-postgame-callbacks into card-evolution

Merge PR #109: WP-13 post-game callback API endpoints
This commit is contained in:
cal 2026-03-18 21:05:37 +00:00
commit 6b6359c6bb
4 changed files with 769 additions and 204 deletions

View File

@ -17,9 +17,9 @@ logging.basicConfig(
# from fastapi.staticfiles import StaticFiles
# from fastapi.templating import Jinja2Templates
from .db_engine import db
from .routers_v2.players import get_browser, shutdown_browser
from .routers_v2 import (
from .db_engine import db # noqa: E402
from .routers_v2.players import get_browser, shutdown_browser # noqa: E402
from .routers_v2 import ( # noqa: E402
current,
awards,
teams,
@ -52,6 +52,7 @@ from .routers_v2 import (
scout_opportunities,
scout_claims,
evolution,
season_stats,
)
@ -107,6 +108,7 @@ app.include_router(decisions.router)
app.include_router(scout_opportunities.router)
app.include_router(scout_claims.router)
app.include_router(evolution.router)
app.include_router(season_stats.router)
@app.middleware("http")

View File

@ -5,6 +5,8 @@ from typing import Optional
from ..db_engine import model_to_dict
from ..dependencies import oauth2_scheme, valid_token
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/evolution", tags=["evolution"])
# Tier -> threshold attribute name. Index = current_tier; value is the
@ -158,3 +160,72 @@ async def evaluate_card(card_id: int, token: str = Depends(oauth2_scheme)):
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. Pairs without a state row are silently skipped. Per-player errors are
logged but do not abort the batch.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import EvolutionCardState, EvolutionTrack, Player, StratPlay
from ..services.evolution_evaluator import evaluate_card
plays = list(StratPlay.select().where(StratPlay.game == game_id))
pairs: set[tuple[int, int]] = set()
for play in plays:
if play.batter_id is not None:
pairs.add((play.batter_id, play.batter_team_id))
if play.pitcher_id is not None:
pairs.add((play.pitcher_id, play.pitcher_team_id))
evaluated = 0
tier_ups = []
for player_id, team_id in pairs:
try:
state = 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.warning(
f"Evolution eval failed for player={player_id} team={team_id}: {exc}"
)
return {"evaluated": evaluated, "tier_ups": tier_ups}

View File

@ -3,230 +3,59 @@
Covers WP-13 (Post-Game Callback Integration):
POST /api/v2/season-stats/update-game/{game_id}
Aggregates BattingStat and PitchingStat rows for a completed game and
increments the corresponding batting_season_stats / pitching_season_stats
rows via an additive upsert.
Delegates to app.services.season_stats.update_season_stats() which
aggregates StratPlay and Decision rows for a completed game and
performs an additive upsert into player_season_stats.
Idempotency is enforced by the service layer: re-delivery of the same
game_id returns {"updated": 0, "skipped": true} without modifying stats.
"""
import logging
from fastapi import APIRouter, Depends, HTTPException
from ..db_engine import db
from ..dependencies import oauth2_scheme, valid_token
router = APIRouter(prefix="/api/v2/season-stats", tags=["season-stats"])
def _ip_to_outs(ip: float) -> int:
"""Convert innings-pitched float (e.g. 6.1) to integer outs (e.g. 19).
Baseball stores IP as whole.partial where the fractional digit is outs
(0, 1, or 2), not tenths. 6.1 = 6 innings + 1 out = 19 outs.
"""
whole = int(ip)
partial = round((ip - whole) * 10)
return whole * 3 + partial
logger = logging.getLogger(__name__)
@router.post("/update-game/{game_id}")
async def update_game_season_stats(game_id: int, token: str = Depends(oauth2_scheme)):
"""Increment season stats with batting and pitching deltas from a game.
Queries BattingStat and PitchingStat rows for game_id, aggregates by
(player_id, team_id, season), then performs an additive ON CONFLICT upsert
into batting_season_stats and pitching_season_stats respectively.
Calls update_season_stats(game_id) from the service layer which:
- Aggregates all StratPlay rows by (player_id, team_id, season)
- Merges Decision rows into pitching groups
- Performs an additive ON CONFLICT upsert into player_season_stats
- Guards against double-counting via the last_game FK check
Replaying the same game_id will double-count stats, so callers must ensure
this is only called once per game.
Response: {"updated": N, "skipped": false}
- N: total player_season_stats rows upserted (batters + pitchers)
- skipped: true when this game_id was already processed (idempotent re-delivery)
Response: {"updated": N} where N is the number of player rows touched.
Errors from the service are logged but re-raised as 500 so the bot
knows to retry.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
updated = 0
from ..services.season_stats import update_season_stats
# --- Batting ---
bat_rows = list(
db.execute_sql(
"""
SELECT c.player_id, bs.team_id, bs.season,
SUM(bs.pa), SUM(bs.ab), SUM(bs.run), SUM(bs.hit),
SUM(bs.double), SUM(bs.triple), SUM(bs.hr), SUM(bs.rbi),
SUM(bs.bb), SUM(bs.so), SUM(bs.hbp), SUM(bs.sac),
SUM(bs.ibb), SUM(bs.gidp), SUM(bs.sb), SUM(bs.cs)
FROM battingstat bs
JOIN card c ON bs.card_id = c.id
WHERE bs.game_id = %s
GROUP BY c.player_id, bs.team_id, bs.season
""",
(game_id,),
try:
result = update_season_stats(game_id)
except Exception as exc:
logger.error("update-game/%d failed: %s", game_id, exc, exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Season stats update failed for game {game_id}: {exc}",
)
)
for row in bat_rows:
(
player_id,
team_id,
season,
pa,
ab,
runs,
hits,
doubles,
triples,
hr,
rbi,
bb,
strikeouts,
hbp,
sac,
ibb,
gidp,
sb,
cs,
) = row
db.execute_sql(
"""
INSERT INTO batting_season_stats
(player_id, team_id, season,
pa, ab, runs, hits, doubles, triples, hr, rbi,
bb, strikeouts, hbp, sac, ibb, gidp, sb, cs)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (player_id, team_id, season) DO UPDATE SET
pa = batting_season_stats.pa + EXCLUDED.pa,
ab = batting_season_stats.ab + EXCLUDED.ab,
runs = batting_season_stats.runs + EXCLUDED.runs,
hits = batting_season_stats.hits + EXCLUDED.hits,
doubles = batting_season_stats.doubles + EXCLUDED.doubles,
triples = batting_season_stats.triples + EXCLUDED.triples,
hr = batting_season_stats.hr + EXCLUDED.hr,
rbi = batting_season_stats.rbi + EXCLUDED.rbi,
bb = batting_season_stats.bb + EXCLUDED.bb,
strikeouts= batting_season_stats.strikeouts+ EXCLUDED.strikeouts,
hbp = batting_season_stats.hbp + EXCLUDED.hbp,
sac = batting_season_stats.sac + EXCLUDED.sac,
ibb = batting_season_stats.ibb + EXCLUDED.ibb,
gidp = batting_season_stats.gidp + EXCLUDED.gidp,
sb = batting_season_stats.sb + EXCLUDED.sb,
cs = batting_season_stats.cs + EXCLUDED.cs
""",
(
player_id,
team_id,
season,
pa,
ab,
runs,
hits,
doubles,
triples,
hr,
rbi,
bb,
strikeouts,
hbp,
sac,
ibb,
gidp,
sb,
cs,
),
)
updated += 1
# --- Pitching ---
pit_rows = list(
db.execute_sql(
"""
SELECT c.player_id, ps.team_id, ps.season,
SUM(ps.ip), SUM(ps.so), SUM(ps.hit), SUM(ps.run), SUM(ps.erun),
SUM(ps.bb), SUM(ps.hbp), SUM(ps.wp), SUM(ps.balk), SUM(ps.hr),
SUM(ps.gs), SUM(ps.win), SUM(ps.loss), SUM(ps.hold),
SUM(ps.sv), SUM(ps.bsv)
FROM pitchingstat ps
JOIN card c ON ps.card_id = c.id
WHERE ps.game_id = %s
GROUP BY c.player_id, ps.team_id, ps.season
""",
(game_id,),
)
)
for row in pit_rows:
(
player_id,
team_id,
season,
ip,
strikeouts,
hits_allowed,
runs_allowed,
earned_runs,
bb,
hbp,
wild_pitches,
balks,
hr_allowed,
games_started,
wins,
losses,
holds,
saves,
blown_saves,
) = row
outs = _ip_to_outs(float(ip))
db.execute_sql(
"""
INSERT INTO pitching_season_stats
(player_id, team_id, season,
outs, strikeouts, hits_allowed, runs_allowed, earned_runs,
bb, hbp, wild_pitches, balks, hr_allowed,
games_started, wins, losses, holds, saves, blown_saves)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (player_id, team_id, season) DO UPDATE SET
outs = pitching_season_stats.outs + EXCLUDED.outs,
strikeouts = pitching_season_stats.strikeouts + EXCLUDED.strikeouts,
hits_allowed= pitching_season_stats.hits_allowed+ EXCLUDED.hits_allowed,
runs_allowed= pitching_season_stats.runs_allowed+ EXCLUDED.runs_allowed,
earned_runs = pitching_season_stats.earned_runs + EXCLUDED.earned_runs,
bb = pitching_season_stats.bb + EXCLUDED.bb,
hbp = pitching_season_stats.hbp + EXCLUDED.hbp,
wild_pitches= pitching_season_stats.wild_pitches+ EXCLUDED.wild_pitches,
balks = pitching_season_stats.balks + EXCLUDED.balks,
hr_allowed = pitching_season_stats.hr_allowed + EXCLUDED.hr_allowed,
games_started= pitching_season_stats.games_started+ EXCLUDED.games_started,
wins = pitching_season_stats.wins + EXCLUDED.wins,
losses = pitching_season_stats.losses + EXCLUDED.losses,
holds = pitching_season_stats.holds + EXCLUDED.holds,
saves = pitching_season_stats.saves + EXCLUDED.saves,
blown_saves = pitching_season_stats.blown_saves + EXCLUDED.blown_saves
""",
(
player_id,
team_id,
season,
outs,
strikeouts,
hits_allowed,
runs_allowed,
earned_runs,
bb,
hbp,
wild_pitches,
balks,
hr_allowed,
games_started,
wins,
losses,
holds,
saves,
blown_saves,
),
)
updated += 1
logging.info(f"update-game/{game_id}: updated {updated} season stats rows")
return {"updated": updated}
updated = result.get("batters_updated", 0) + result.get("pitchers_updated", 0)
return {
"updated": updated,
"skipped": result.get("skipped", False),
}

View File

@ -0,0 +1,663 @@
"""Integration tests for WP-13: Post-Game Callback Integration.
Tests cover both post-game callback endpoints:
POST /api/v2/season-stats/update-game/{game_id}
POST /api/v2/evolution/evaluate-game/{game_id}
All tests run against a named shared-memory SQLite database so that Peewee
model queries inside the route handlers (which execute in the TestClient's
thread) and test fixture setup/assertions (which execute in the pytest thread)
use the same underlying database connection. This is necessary because
SQLite :memory: databases are per-connection a new thread gets a new empty
database unless a shared-cache URI is used.
The WP-13 tests therefore manage their own database fixture (_wp13_db) and do
not use the conftest autouse setup_test_db. The module-level setup_wp13_db
fixture creates tables before each test and drops them after.
The season_stats service 'db' reference is patched at module level so that
db.atomic() inside update_season_stats() operates on _wp13_db.
Test matrix:
test_update_game_creates_season_stats_rows
POST to update-game, assert player_season_stats rows are created.
test_update_game_response_shape
Response contains {"updated": N, "skipped": false}.
test_update_game_idempotent
Second POST to same game_id returns skipped=true, stats unchanged.
test_evaluate_game_increases_current_value
After update-game, POST to evaluate-game, assert current_value > 0.
test_evaluate_game_tier_advancement
Set up card near tier threshold, game pushes past it, assert tier advanced.
test_evaluate_game_no_tier_advancement
Player accumulates too few stats tier stays at 0.
test_evaluate_game_tier_ups_in_response
Tier-up appears in tier_ups list with correct fields.
test_evaluate_game_skips_players_without_state
Players in game but without EvolutionCardState are silently skipped.
test_auth_required_update_game
Missing bearer token returns 401 on update-game.
test_auth_required_evaluate_game
Missing bearer token returns 401 on evaluate-game.
"""
import os
# Set API_TOKEN before any app imports so that app.dependencies.AUTH_TOKEN
# is initialised to the same value as our test bearer token.
os.environ.setdefault("API_TOKEN", "test-token")
import app.services.season_stats as _season_stats_module
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
Cardset,
EvolutionCardState,
EvolutionCosmetic,
EvolutionTierBoost,
EvolutionTrack,
MlbPlayer,
Pack,
PackType,
Player,
PlayerSeasonStats,
Rarity,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Decision,
Team,
Card,
Event,
)
# ---------------------------------------------------------------------------
# Shared-memory SQLite database for WP-13 tests.
# A named shared-memory URI allows multiple connections (and therefore
# multiple threads) to share the same in-memory database, which is required
# because TestClient routes run in a different thread than pytest fixtures.
# ---------------------------------------------------------------------------
_wp13_db = SqliteDatabase(
"file:wp13test?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
_WP13_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
PlayerSeasonStats,
EvolutionTrack,
EvolutionCardState,
EvolutionTierBoost,
EvolutionCosmetic,
]
# Patch the service-layer 'db' reference to use our shared test database so
# that db.atomic() in update_season_stats() operates on the same connection.
_season_stats_module.db = _wp13_db
# ---------------------------------------------------------------------------
# Auth header used by every authenticated request
# ---------------------------------------------------------------------------
AUTH_HEADER = {"Authorization": "Bearer test-token"}
# ---------------------------------------------------------------------------
# Database fixture — binds all models to _wp13_db and creates/drops tables
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def setup_wp13_db():
"""Bind WP-13 models to the shared-memory SQLite db and create tables.
autouse=True so every test in this module automatically gets a fresh
schema. Tables are dropped in reverse dependency order after each test.
This fixture replaces (and disables) the conftest autouse setup_test_db
for tests in this module because we need a different database backend
(shared-cache URI rather than :memory:) to support multi-thread access
via TestClient.
"""
_wp13_db.bind(_WP13_MODELS)
_wp13_db.connect(reuse_if_open=True)
_wp13_db.create_tables(_WP13_MODELS)
yield _wp13_db
_wp13_db.drop_tables(list(reversed(_WP13_MODELS)), safe=True)
# ---------------------------------------------------------------------------
# Slim test app — only mounts the two routers under test.
# A db_middleware ensures the shared-cache connection is open for each request.
# ---------------------------------------------------------------------------
def _build_test_app() -> FastAPI:
"""Build a minimal FastAPI instance with just the WP-13 routers.
A db_middleware calls _wp13_db.connect(reuse_if_open=True) before each
request so that the route handler thread can use the shared-memory SQLite
connection even though it runs in a different thread from the fixture.
"""
from app.routers_v2.season_stats import router as ss_router
from app.routers_v2.evolution import router as evo_router
test_app = FastAPI()
@test_app.middleware("http")
async def db_middleware(request: Request, call_next):
_wp13_db.connect(reuse_if_open=True)
return await call_next(request)
test_app.include_router(ss_router)
test_app.include_router(evo_router)
return test_app
# ---------------------------------------------------------------------------
# TestClient fixture — function-scoped so it uses the per-test db binding.
# ---------------------------------------------------------------------------
@pytest.fixture
def client(setup_wp13_db):
"""FastAPI TestClient backed by the slim test app and shared-memory SQLite."""
with TestClient(_build_test_app()) as c:
yield c
# ---------------------------------------------------------------------------
# Shared helper factories (mirrors test_season_stats_update.py style)
# ---------------------------------------------------------------------------
def _make_cardset():
cs, _ = Cardset.get_or_create(
name="WP13 Test Set",
defaults={"description": "wp13 cardset", "total_cards": 100},
)
return cs
def _make_rarity():
r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"})
return r
def _make_player(name: str, pos: str = "1B") -> Player:
return Player.create(
p_name=name,
rarity=_make_rarity(),
cardset=_make_cardset(),
set_num=1,
pos_1=pos,
image="https://example.com/img.png",
mlbclub="TST",
franchise="TST",
description=f"wp13 test: {name}",
)
def _make_team(abbrev: str, gmid: int) -> Team:
return Team.create(
abbrev=abbrev,
sname=abbrev,
lname=f"Team {abbrev}",
gmid=gmid,
gmname=f"gm_{abbrev.lower()}",
gsheet="https://docs.google.com/spreadsheets/wp13",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=False,
)
def _make_game(team_a, team_b) -> StratGame:
return StratGame.create(
season=11,
game_type="ranked",
away_team=team_a,
home_team=team_b,
)
def _make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats):
"""Create a StratPlay with sensible zero-defaults for all stat columns."""
defaults = dict(
on_base_code="000",
inning_half="top",
inning_num=1,
batting_order=1,
starting_outs=0,
away_score=0,
home_score=0,
pa=0,
ab=0,
hit=0,
run=0,
double=0,
triple=0,
homerun=0,
bb=0,
so=0,
hbp=0,
rbi=0,
sb=0,
cs=0,
outs=0,
sac=0,
ibb=0,
gidp=0,
bphr=0,
bpfo=0,
bp1b=0,
bplo=0,
)
defaults.update(stats)
return StratPlay.create(
game=game,
play_num=play_num,
batter=batter,
batter_team=batter_team,
pitcher=pitcher,
pitcher_team=pitcher_team,
**defaults,
)
def _make_track(
name: str = "WP13 Batter Track", card_type: str = "batter"
) -> EvolutionTrack:
track, _ = EvolutionTrack.get_or_create(
name=name,
defaults=dict(
card_type=card_type,
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
return track
def _make_state(
player, team, track, current_tier=0, current_value=0.0
) -> EvolutionCardState:
return EvolutionCardState.create(
player=player,
team=team,
track=track,
current_tier=current_tier,
current_value=current_value,
fully_evolved=False,
last_evaluated_at=None,
)
# ---------------------------------------------------------------------------
# Tests: POST /api/v2/season-stats/update-game/{game_id}
# ---------------------------------------------------------------------------
def test_update_game_creates_season_stats_rows(client):
"""POST update-game creates player_season_stats rows for players in the game.
What: Set up a batter and pitcher in a game with 3 PA for the batter.
After the endpoint call, assert a PlayerSeasonStats row exists with pa=3.
Why: This is the core write path. If the row is not created, the
evolution evaluator will always see zero career stats.
"""
team_a = _make_team("WU1", gmid=20001)
team_b = _make_team("WU2", gmid=20002)
batter = _make_player("WP13 Batter A")
pitcher = _make_player("WP13 Pitcher A", pos="SP")
game = _make_game(team_a, team_b)
for i in range(3):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
resp = client.post(
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
stats = PlayerSeasonStats.get_or_none(
(PlayerSeasonStats.player == batter)
& (PlayerSeasonStats.team == team_a)
& (PlayerSeasonStats.season == 11)
)
assert stats is not None
assert stats.pa == 3
def test_update_game_response_shape(client):
"""POST update-game returns {"updated": N, "skipped": false}.
What: A game with one batter and one pitcher produces updated >= 1 and
skipped is false on the first call.
Why: The bot relies on 'updated' to log how many rows were touched and
'skipped' to detect re-delivery.
"""
team_a = _make_team("WS1", gmid=20011)
team_b = _make_team("WS2", gmid=20012)
batter = _make_player("WP13 Batter S")
pitcher = _make_player("WP13 Pitcher S", pos="SP")
game = _make_game(team_a, team_b)
_make_play(game, 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
resp = client.post(
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert "updated" in data
assert data["updated"] >= 1
assert data["skipped"] is False
def test_update_game_idempotent(client):
"""Calling update-game twice for the same game returns skipped=true on second call.
What: Process a game once (pa=3), then call the endpoint again with the
same game_id. The second response must have skipped=true and updated=0,
and pa in the DB must still be 3 (not 6).
Why: The bot infrastructure may deliver game-complete events more than
once. Double-counting would corrupt all evolution stats downstream.
"""
team_a = _make_team("WI1", gmid=20021)
team_b = _make_team("WI2", gmid=20022)
batter = _make_player("WP13 Batter I")
pitcher = _make_player("WP13 Pitcher I", pos="SP")
game = _make_game(team_a, team_b)
for i in range(3):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
resp1 = client.post(
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
)
assert resp1.status_code == 200
assert resp1.json()["skipped"] is False
resp2 = client.post(
f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER
)
assert resp2.status_code == 200
data2 = resp2.json()
assert data2["skipped"] is True
assert data2["updated"] == 0
stats = PlayerSeasonStats.get(
(PlayerSeasonStats.player == batter) & (PlayerSeasonStats.team == team_a)
)
assert stats.pa == 3 # not 6
# ---------------------------------------------------------------------------
# Tests: POST /api/v2/evolution/evaluate-game/{game_id}
# ---------------------------------------------------------------------------
def test_evaluate_game_increases_current_value(client):
"""After update-game, evaluate-game raises the card's current_value above 0.
What: Batter with an EvolutionCardState gets 3 hits (pa=3, hit=3) from a
game. update-game writes those stats; evaluate-game then recomputes the
value. current_value in the DB must be > 0 after the evaluate call.
Why: This is the end-to-end path: stats in -> evaluate -> value updated.
If current_value stays 0, the card will never advance regardless of how
many games are played.
"""
team_a = _make_team("WE1", gmid=20031)
team_b = _make_team("WE2", gmid=20032)
batter = _make_player("WP13 Batter E")
pitcher = _make_player("WP13 Pitcher E", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track()
_make_state(batter, team_a, track)
for i in range(3):
_make_play(
game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0
)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
state = EvolutionCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a)
)
assert state.current_value > 0
def test_evaluate_game_tier_advancement(client):
"""A game that pushes a card past a tier threshold advances the tier.
What: Set the batter's career value just below T1 (37) by manually seeding
a prior PlayerSeasonStats row with pa=34. Then add a game that brings the
total past 37 and call evaluate-game. current_tier must advance to >= 1.
Why: Tier advancement is the core deliverable of card evolution. If the
threshold comparison is off-by-one or the tier is never written, the card
will never visually evolve.
"""
team_a = _make_team("WT1", gmid=20041)
team_b = _make_team("WT2", gmid=20042)
batter = _make_player("WP13 Batter T")
pitcher = _make_player("WP13 Pitcher T", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Tier Adv Track")
_make_state(batter, team_a, track, current_tier=0, current_value=34.0)
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
PlayerSeasonStats.create(
player=batter,
team=team_a,
season=10, # previous season
pa=34,
)
# Game adds 4 more PA (total pa=38 > T1=37)
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
updated_state = EvolutionCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a)
)
assert updated_state.current_tier >= 1
def test_evaluate_game_no_tier_advancement(client):
"""A game with insufficient stats does not advance the tier.
What: A batter starts at tier=0 with current_value=0. The game adds only
2 PA (value=2 which is < T1 threshold of 37). After evaluate-game the
tier must still be 0.
Why: We need to confirm the threshold guard works correctly cards should
not advance prematurely before earning the required stats.
"""
team_a = _make_team("WN1", gmid=20051)
team_b = _make_team("WN2", gmid=20052)
batter = _make_player("WP13 Batter N")
pitcher = _make_player("WP13 Pitcher N", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 No-Adv Track")
_make_state(batter, team_a, track, current_tier=0)
# Only 2 PA — far below T1=37
for i in range(2):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["tier_ups"] == []
state = EvolutionCardState.get(
(EvolutionCardState.player == batter) & (EvolutionCardState.team == team_a)
)
assert state.current_tier == 0
def test_evaluate_game_tier_ups_in_response(client):
"""evaluate-game response includes a tier_ups entry when a player advances.
What: Seed a batter at tier=0 with pa=34 (just below T1=37). A game adds
4 PA pushing total to 38. The response tier_ups list must contain one
entry with the correct fields: player_id, team_id, player_name, old_tier,
new_tier, current_value, track_name.
Why: The bot uses tier_ups to trigger in-game notifications and visual card
upgrade animations. A missing or malformed entry would silently skip the
announcement.
"""
team_a = _make_team("WR1", gmid=20061)
team_b = _make_team("WR2", gmid=20062)
batter = _make_player("WP13 Batter R")
pitcher = _make_player("WP13 Pitcher R", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Tier-Ups Track")
_make_state(batter, team_a, track, current_tier=0)
# Seed prior stats below threshold
PlayerSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
# Game pushes past T1
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert data["evaluated"] >= 1
assert len(data["tier_ups"]) == 1
tu = data["tier_ups"][0]
assert tu["player_id"] == batter.player_id
assert tu["team_id"] == team_a.id
assert tu["player_name"] == "WP13 Batter R"
assert tu["old_tier"] == 0
assert tu["new_tier"] >= 1
assert tu["current_value"] > 0
assert tu["track_name"] == "WP13 Tier-Ups Track"
def test_evaluate_game_skips_players_without_state(client):
"""Players in a game without an EvolutionCardState are silently skipped.
What: A game has two players: one with a card state and one without.
After evaluate-game, evaluated should be 1 (only the player with state)
and the endpoint must return 200 without errors.
Why: Not every player on a roster will have started their evolution journey.
A hard 404 or 500 for missing states would break the entire batch.
"""
team_a = _make_team("WK1", gmid=20071)
team_b = _make_team("WK2", gmid=20072)
batter_with_state = _make_player("WP13 Batter WithState")
batter_no_state = _make_player("WP13 Batter NoState")
pitcher = _make_player("WP13 Pitcher K", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Skip Track")
# Only batter_with_state gets an EvolutionCardState
_make_state(batter_with_state, team_a, track)
_make_play(game, 1, batter_with_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
_make_play(game, 2, batter_no_state, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/evolution/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# Only 1 evaluation (the player with a state)
assert data["evaluated"] == 1
# ---------------------------------------------------------------------------
# Tests: Auth required on both endpoints
# ---------------------------------------------------------------------------
def test_auth_required_update_game(client):
"""Missing bearer token on update-game returns 401.
What: POST to update-game without any Authorization header.
Why: Both endpoints are production-only callbacks that should never be
accessible without a valid bearer token.
"""
team_a = _make_team("WA1", gmid=20081)
team_b = _make_team("WA2", gmid=20082)
game = _make_game(team_a, team_b)
resp = client.post(f"/api/v2/season-stats/update-game/{game.id}")
assert resp.status_code == 401
def test_auth_required_evaluate_game(client):
"""Missing bearer token on evaluate-game returns 401.
What: POST to evaluate-game without any Authorization header.
Why: Same security requirement as update-game callbacks must be
authenticated to prevent replay attacks and unauthorized stat manipulation.
"""
team_a = _make_team("WB1", gmid=20091)
team_b = _make_team("WB2", gmid=20092)
game = _make_game(team_a, team_b)
resp = client.post(f"/api/v2/evolution/evaluate-game/{game.id}")
assert resp.status_code == 401