Compare commits
49 Commits
689ff4b70f
...
64b6225c41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b6225c41 | ||
|
|
fe3dc0e4d2 | ||
| c69082e3ee | |||
|
|
d1d9159edf | ||
|
|
eba23369ca | ||
|
|
db6f8d9b66 | ||
| f12aa858c1 | |||
|
|
c935c50a96 | ||
|
|
b8c55b5723 | ||
|
|
f7bc248a9f | ||
|
|
da9eaa1692 | ||
|
|
23d36e7903 | ||
|
|
40347f8b87 | ||
|
|
d158a4ad4e | ||
|
|
c6f59277bd | ||
|
|
926c18af70 | ||
| dcf9036140 | |||
| d0c4bd3bbd | |||
| d8d1b2ac2f | |||
| ac13597fa2 | |||
|
|
419fb757df | ||
|
|
6580c1b431 | ||
|
|
bd8e4578cc | ||
|
|
4ed62dea2c | ||
| 6d972114b7 | |||
| 84a45d9caa | |||
| 47dcdf00c4 | |||
| a6cf4eea01 | |||
| 569dc07864 | |||
|
|
f471354e39 | ||
| 07caa613d4 | |||
| 32ca21558e | |||
| 01c8aa140c | |||
| 223743d89f | |||
| 44763a07ec | |||
| 8d0111df32 | |||
| 8a437c7ef3 | |||
|
|
6ab50ba5f2 | ||
|
|
c262bb431e | ||
|
|
ddf6ff5961 | ||
|
|
40e988ac9d | ||
|
|
25f04892c2 | ||
|
|
8dfc5ef371 | ||
|
|
4bfd878486 | ||
|
|
4445acb7d0 | ||
| b6290226d5 | |||
| dfe637c808 | |||
|
|
c3732ef33e | ||
|
|
2c4ff01ff8 |
@ -55,8 +55,6 @@ jobs:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=registry,ref=manticorum67/paper-dynasty-database:buildcache
|
||||
cache-to: type=registry,ref=manticorum67/paper-dynasty-database:buildcache,mode=max
|
||||
|
||||
- name: Tag release
|
||||
if: success() && github.ref == 'refs/heads/main'
|
||||
|
||||
37
Dockerfile
37
Dockerfile
@ -1,41 +1,12 @@
|
||||
FROM tiangolo/uvicorn-gunicorn-fastapi:python3.11
|
||||
FROM python:3.11-slim-bookworm
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Chrome dependency Instalation
|
||||
# RUN apt-get update && apt-get install -y \
|
||||
# fonts-liberation \
|
||||
# libasound2 \
|
||||
# libatk-bridge2.0-0 \
|
||||
# libatk1.0-0 \
|
||||
# libatspi2.0-0 \
|
||||
# libcups2 \
|
||||
# libdbus-1-3 \
|
||||
# libdrm2 \
|
||||
# libgbm1 \
|
||||
# libgtk-3-0 \
|
||||
# # libgtk-4-1 \
|
||||
# libnspr4 \
|
||||
# libnss3 \
|
||||
# libwayland-client0 \
|
||||
# libxcomposite1 \
|
||||
# libxdamage1 \
|
||||
# libxfixes3 \
|
||||
# libxkbcommon0 \
|
||||
# libxrandr2 \
|
||||
# xdg-utils \
|
||||
# libu2f-udev \
|
||||
# libvulkan1
|
||||
# # Chrome instalation
|
||||
# RUN curl -LO https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
|
||||
# RUN apt-get install -y ./google-chrome-stable_current_amd64.deb
|
||||
# RUN rm google-chrome-stable_current_amd64.deb
|
||||
# # Check chrome version
|
||||
# RUN echo "Chrome: " && google-chrome --version
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN playwright install chromium
|
||||
RUN playwright install-deps chromium
|
||||
|
||||
COPY ./app /app/app
|
||||
COPY ./app /usr/src/app/app
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
|
||||
|
||||
203
app/db_engine.py
203
app/db_engine.py
@ -1050,8 +1050,129 @@ decision_index = ModelIndex(Decision, (Decision.game, Decision.pitcher), unique=
|
||||
Decision.add_index(decision_index)
|
||||
|
||||
|
||||
class BattingSeasonStats(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
season = IntegerField()
|
||||
games = 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)
|
||||
rbi = IntegerField(default=0)
|
||||
runs = IntegerField(default=0)
|
||||
bb = IntegerField(default=0)
|
||||
strikeouts = IntegerField(default=0)
|
||||
hbp = IntegerField(default=0)
|
||||
sac = IntegerField(default=0)
|
||||
ibb = IntegerField(default=0)
|
||||
gidp = IntegerField(default=0)
|
||||
sb = IntegerField(default=0)
|
||||
cs = IntegerField(default=0)
|
||||
last_game = ForeignKeyField(StratGame, null=True)
|
||||
last_updated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "batting_season_stats"
|
||||
|
||||
|
||||
bss_unique_index = ModelIndex(
|
||||
BattingSeasonStats,
|
||||
(BattingSeasonStats.player, BattingSeasonStats.team, BattingSeasonStats.season),
|
||||
unique=True,
|
||||
)
|
||||
BattingSeasonStats.add_index(bss_unique_index)
|
||||
|
||||
bss_team_season_index = ModelIndex(
|
||||
BattingSeasonStats,
|
||||
(BattingSeasonStats.team, BattingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
BattingSeasonStats.add_index(bss_team_season_index)
|
||||
|
||||
bss_player_season_index = ModelIndex(
|
||||
BattingSeasonStats,
|
||||
(BattingSeasonStats.player, BattingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
BattingSeasonStats.add_index(bss_player_season_index)
|
||||
|
||||
|
||||
class PitchingSeasonStats(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
season = IntegerField()
|
||||
games = IntegerField(default=0)
|
||||
games_started = IntegerField(default=0)
|
||||
outs = IntegerField(default=0)
|
||||
strikeouts = IntegerField(default=0)
|
||||
bb = IntegerField(default=0)
|
||||
hits_allowed = IntegerField(default=0)
|
||||
runs_allowed = IntegerField(default=0)
|
||||
earned_runs = IntegerField(default=0)
|
||||
hr_allowed = IntegerField(default=0)
|
||||
hbp = IntegerField(default=0)
|
||||
wild_pitches = IntegerField(default=0)
|
||||
balks = IntegerField(default=0)
|
||||
wins = IntegerField(default=0)
|
||||
losses = IntegerField(default=0)
|
||||
holds = IntegerField(default=0)
|
||||
saves = IntegerField(default=0)
|
||||
blown_saves = IntegerField(default=0)
|
||||
last_game = ForeignKeyField(StratGame, null=True)
|
||||
last_updated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "pitching_season_stats"
|
||||
|
||||
|
||||
pitss_unique_index = ModelIndex(
|
||||
PitchingSeasonStats,
|
||||
(PitchingSeasonStats.player, PitchingSeasonStats.team, PitchingSeasonStats.season),
|
||||
unique=True,
|
||||
)
|
||||
PitchingSeasonStats.add_index(pitss_unique_index)
|
||||
|
||||
pitss_team_season_index = ModelIndex(
|
||||
PitchingSeasonStats,
|
||||
(PitchingSeasonStats.team, PitchingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
PitchingSeasonStats.add_index(pitss_team_season_index)
|
||||
|
||||
pitss_player_season_index = ModelIndex(
|
||||
PitchingSeasonStats,
|
||||
(PitchingSeasonStats.player, PitchingSeasonStats.season),
|
||||
unique=False,
|
||||
)
|
||||
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], safe=True)
|
||||
db.create_tables(
|
||||
[
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
],
|
||||
safe=True,
|
||||
)
|
||||
|
||||
|
||||
class ScoutOpportunity(BaseModel):
|
||||
@ -1089,6 +1210,86 @@ if not SKIP_TABLE_CREATION:
|
||||
db.create_tables([ScoutOpportunity, ScoutClaim], safe=True)
|
||||
|
||||
|
||||
class EvolutionTrack(BaseModel):
|
||||
name = CharField(unique=True)
|
||||
card_type = CharField() # 'batter', 'sp', 'rp'
|
||||
formula = CharField() # e.g. "pa + tb * 2"
|
||||
t1_threshold = IntegerField()
|
||||
t2_threshold = IntegerField()
|
||||
t3_threshold = IntegerField()
|
||||
t4_threshold = IntegerField()
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_track"
|
||||
|
||||
|
||||
class EvolutionCardState(BaseModel):
|
||||
player = ForeignKeyField(Player)
|
||||
team = ForeignKeyField(Team)
|
||||
track = ForeignKeyField(EvolutionTrack)
|
||||
current_tier = IntegerField(default=0) # 0-4
|
||||
current_value = FloatField(default=0.0)
|
||||
fully_evolved = BooleanField(default=False)
|
||||
last_evaluated_at = DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_card_state"
|
||||
|
||||
|
||||
evolution_card_state_index = ModelIndex(
|
||||
EvolutionCardState,
|
||||
(EvolutionCardState.player, EvolutionCardState.team),
|
||||
unique=True,
|
||||
)
|
||||
EvolutionCardState.add_index(evolution_card_state_index)
|
||||
|
||||
|
||||
class EvolutionTierBoost(BaseModel):
|
||||
track = ForeignKeyField(EvolutionTrack)
|
||||
tier = IntegerField() # 1-4
|
||||
boost_type = CharField() # e.g. 'rating', 'stat'
|
||||
boost_target = CharField() # e.g. 'contact_vl', 'power_vr'
|
||||
boost_value = FloatField(default=0.0)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_tier_boost"
|
||||
|
||||
|
||||
evolution_tier_boost_index = ModelIndex(
|
||||
EvolutionTierBoost,
|
||||
(
|
||||
EvolutionTierBoost.track,
|
||||
EvolutionTierBoost.tier,
|
||||
EvolutionTierBoost.boost_type,
|
||||
EvolutionTierBoost.boost_target,
|
||||
),
|
||||
unique=True,
|
||||
)
|
||||
EvolutionTierBoost.add_index(evolution_tier_boost_index)
|
||||
|
||||
|
||||
class EvolutionCosmetic(BaseModel):
|
||||
name = CharField(unique=True)
|
||||
tier_required = IntegerField(default=0)
|
||||
cosmetic_type = CharField() # 'frame', 'badge', 'theme'
|
||||
css_class = CharField(null=True)
|
||||
asset_url = CharField(null=True)
|
||||
|
||||
class Meta:
|
||||
database = db
|
||||
table_name = "evolution_cosmetic"
|
||||
|
||||
|
||||
if not SKIP_TABLE_CREATION:
|
||||
db.create_tables(
|
||||
[EvolutionTrack, EvolutionCardState, EvolutionTierBoost, EvolutionCosmetic],
|
||||
safe=True,
|
||||
)
|
||||
|
||||
|
||||
db.close()
|
||||
|
||||
# scout_db = SqliteDatabase(
|
||||
|
||||
17
app/main.py
17
app/main.py
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
@ -17,6 +18,7 @@ logging.basicConfig(
|
||||
# from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .db_engine import db
|
||||
from .routers_v2.players import get_browser, shutdown_browser
|
||||
from .routers_v2 import (
|
||||
current,
|
||||
awards,
|
||||
@ -49,10 +51,22 @@ from .routers_v2 import (
|
||||
stratplays,
|
||||
scout_opportunities,
|
||||
scout_claims,
|
||||
evolution,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
# Startup: warm up the persistent Chromium browser
|
||||
await get_browser()
|
||||
yield
|
||||
# Shutdown: clean up browser and playwright
|
||||
await shutdown_browser()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
# root_path='/api',
|
||||
lifespan=lifespan,
|
||||
responses={404: {"description": "Not found"}},
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc",
|
||||
@ -92,6 +106,7 @@ app.include_router(stratplays.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(scout_opportunities.router)
|
||||
app.include_router(scout_claims.router)
|
||||
app.include_router(evolution.router)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@ -114,4 +129,4 @@ async def get_docs(req: Request):
|
||||
|
||||
@app.get("/api/openapi.json", include_in_schema=False)
|
||||
async def openapi():
|
||||
return get_openapi(title="Paper Dynasty API", version=f"0.1.1", routes=app.routes)
|
||||
return get_openapi(title="Paper Dynasty API", version="0.1.1", routes=app.routes)
|
||||
|
||||
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
7
app/models/season_stats.py
Normal file
7
app/models/season_stats.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Season stats ORM models.
|
||||
|
||||
Models are defined in db_engine alongside all other Peewee models; this
|
||||
module re-exports them so callers can import from `app.models.season_stats`.
|
||||
"""
|
||||
|
||||
from ..db_engine import BattingSeasonStats, PitchingSeasonStats # noqa: F401
|
||||
@ -7,11 +7,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
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix='/api/v2/cards',
|
||||
tags=['cards']
|
||||
)
|
||||
router = APIRouter(prefix="/api/v2/cards", tags=["cards"])
|
||||
|
||||
|
||||
class CardPydantic(pydantic.BaseModel):
|
||||
@ -26,12 +22,20 @@ class CardModel(pydantic.BaseModel):
|
||||
cards: List[CardPydantic]
|
||||
|
||||
|
||||
@router.get('')
|
||||
@router.get("")
|
||||
async def get_cards(
|
||||
player_id: Optional[int] = None, team_id: Optional[int] = None, pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None, min_value: Optional[int] = None, max_value: Optional[int] = None, variant: Optional[int] = None,
|
||||
order_by: Optional[str] = None, limit: Optional[int] = None, dupes: Optional[bool] = None,
|
||||
csv: Optional[bool] = None):
|
||||
player_id: Optional[int] = None,
|
||||
team_id: Optional[int] = None,
|
||||
pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None,
|
||||
min_value: Optional[int] = None,
|
||||
max_value: Optional[int] = None,
|
||||
variant: Optional[int] = None,
|
||||
order_by: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
dupes: Optional[bool] = None,
|
||||
csv: Optional[bool] = None,
|
||||
):
|
||||
all_cards = Card.select()
|
||||
|
||||
# if all_cards.count() == 0:
|
||||
@ -65,7 +69,7 @@ async def get_cards(
|
||||
if max_value is not None:
|
||||
all_cards = all_cards.where(Card.value <= max_value)
|
||||
if order_by is not None:
|
||||
if order_by.lower() == 'new':
|
||||
if order_by.lower() == "new":
|
||||
all_cards = all_cards.order_by(-Card.id)
|
||||
else:
|
||||
all_cards = all_cards.order_by(Card.id)
|
||||
@ -73,8 +77,10 @@ async def get_cards(
|
||||
all_cards = all_cards.limit(limit)
|
||||
if dupes:
|
||||
if team_id is None:
|
||||
raise HTTPException(status_code=400, detail='Dupe checking must include a team_id')
|
||||
logging.debug(f'dupe check')
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Dupe checking must include a team_id"
|
||||
)
|
||||
logging.debug(f"dupe check")
|
||||
p_query = Card.select(Card.player).where(Card.team_id == team_id)
|
||||
seen = set()
|
||||
dupes = []
|
||||
@ -90,38 +96,52 @@ async def get_cards(
|
||||
# raise HTTPException(status_code=404, detail=f'No cards found')
|
||||
|
||||
if csv:
|
||||
data_list = [['id', 'player', 'cardset', 'rarity', 'team', 'pack', 'value']] #, 'variant']]
|
||||
data_list = [
|
||||
["id", "player", "cardset", "rarity", "team", "pack", "value"]
|
||||
] # , 'variant']]
|
||||
for line in all_cards:
|
||||
data_list.append(
|
||||
[
|
||||
line.id, line.player.p_name, line.player.cardset, line.player.rarity, line.team.abbrev, line.pack,
|
||||
line.id,
|
||||
line.player.p_name,
|
||||
line.player.cardset,
|
||||
line.player.rarity,
|
||||
line.team.abbrev,
|
||||
line.pack,
|
||||
line.value, # line.variant
|
||||
]
|
||||
)
|
||||
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
||||
|
||||
return Response(content=return_val, media_type='text/csv')
|
||||
return Response(content=return_val, media_type="text/csv")
|
||||
|
||||
else:
|
||||
return_val = {'count': all_cards.count(), 'cards': []}
|
||||
for x in all_cards:
|
||||
card_list = list(all_cards)
|
||||
player_ids = [c.player_id for c in card_list if c.player_id is not None]
|
||||
dex_by_player = {}
|
||||
if player_ids:
|
||||
for row in Paperdex.select().where(Paperdex.player_id << player_ids):
|
||||
dex_by_player.setdefault(row.player_id, []).append(row)
|
||||
return_val = {"count": len(card_list), "cards": []}
|
||||
for x in card_list:
|
||||
|
||||
this_record = model_to_dict(x)
|
||||
logging.debug(f'this_record: {this_record}')
|
||||
logging.debug(f"this_record: {this_record}")
|
||||
|
||||
this_dex = Paperdex.select().where(Paperdex.player == x)
|
||||
this_record['player']['paperdex'] = {'count': this_dex.count(), 'paperdex': []}
|
||||
for y in this_dex:
|
||||
this_record['player']['paperdex']['paperdex'].append(model_to_dict(y, recurse=False))
|
||||
entries = dex_by_player.get(x.player_id, [])
|
||||
this_record["player"]["paperdex"] = {
|
||||
"count": len(entries),
|
||||
"paperdex": [model_to_dict(y, recurse=False) for y in entries],
|
||||
}
|
||||
|
||||
return_val['cards'].append(this_record)
|
||||
return_val["cards"].append(this_record)
|
||||
|
||||
# return_val['cards'].append(model_to_dict(x))
|
||||
|
||||
return return_val
|
||||
|
||||
|
||||
@router.get('/{card_id}')
|
||||
@router.get("/{card_id}")
|
||||
async def v1_cards_get_one(card_id, csv: Optional[bool] = False):
|
||||
try:
|
||||
this_card = Card.get_by_id(card_id)
|
||||
@ -130,25 +150,31 @@ async def v1_cards_get_one(card_id, csv: Optional[bool] = False):
|
||||
|
||||
if csv:
|
||||
data_list = [
|
||||
['id', 'player', 'team', 'pack', 'value'],
|
||||
[this_card.id, this_card.player, this_card.team.abbrev, this_card.pack, this_card.value]
|
||||
["id", "player", "team", "pack", "value"],
|
||||
[
|
||||
this_card.id,
|
||||
this_card.player,
|
||||
this_card.team.abbrev,
|
||||
this_card.pack,
|
||||
this_card.value,
|
||||
],
|
||||
]
|
||||
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
||||
|
||||
return Response(content=return_val, media_type='text/csv')
|
||||
return Response(content=return_val, media_type="text/csv")
|
||||
|
||||
else:
|
||||
return_val = model_to_dict(this_card)
|
||||
return return_val
|
||||
|
||||
|
||||
@router.post('')
|
||||
@router.post("")
|
||||
async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to post cards. This event has been logged.'
|
||||
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
|
||||
@ -157,7 +183,7 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
player_ids = []
|
||||
inc_dex = True
|
||||
this_team = Team.get_by_id(cards.cards[0].team_id)
|
||||
if this_team.is_ai or 'Gauntlet' in this_team.abbrev:
|
||||
if this_team.is_ai or "Gauntlet" in this_team.abbrev:
|
||||
inc_dex = False
|
||||
|
||||
# new_dex = []
|
||||
@ -177,11 +203,15 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
|
||||
with db.atomic():
|
||||
Card.bulk_create(new_cards, batch_size=15)
|
||||
cost_query = Player.update(cost=Player.cost + 1).where(Player.player_id << player_ids)
|
||||
cost_query = Player.update(cost=Player.cost + 1).where(
|
||||
Player.player_id << player_ids
|
||||
)
|
||||
cost_query.execute()
|
||||
# sheets.post_new_cards(SHEETS_AUTH, lc_id)
|
||||
|
||||
raise HTTPException(status_code=200, detail=f'{len(new_cards)} cards have been added')
|
||||
raise HTTPException(
|
||||
status_code=200, detail=f"{len(new_cards)} cards have been added"
|
||||
)
|
||||
|
||||
|
||||
# @router.post('/ai-update')
|
||||
@ -198,21 +228,27 @@ async def v1_cards_post(cards: CardModel, token: str = Depends(oauth2_scheme)):
|
||||
# raise HTTPException(status_code=200, detail=f'Just sent AI cards to sheets')
|
||||
|
||||
|
||||
@router.post('/legal-check/{rarity_name}')
|
||||
@router.post("/legal-check/{rarity_name}")
|
||||
async def v1_cards_legal_check(
|
||||
rarity_name: str, card_id: list = Query(default=None), token: str = Depends(oauth2_scheme)):
|
||||
rarity_name: str,
|
||||
card_id: list = Query(default=None),
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='Unauthorized'
|
||||
)
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if rarity_name not in CARDSETS.keys():
|
||||
return f'Rarity name {rarity_name} not a valid check'
|
||||
return f"Rarity name {rarity_name} not a valid check"
|
||||
|
||||
# Handle case where card_id is passed as a stringified list
|
||||
if card_id and len(card_id) == 1 and isinstance(card_id[0], str) and card_id[0].startswith('['):
|
||||
if (
|
||||
card_id
|
||||
and len(card_id) == 1
|
||||
and isinstance(card_id[0], str)
|
||||
and card_id[0].startswith("[")
|
||||
):
|
||||
import ast
|
||||
|
||||
try:
|
||||
card_id = [int(x) for x in ast.literal_eval(card_id[0])]
|
||||
except (ValueError, SyntaxError):
|
||||
@ -222,48 +258,51 @@ async def v1_cards_legal_check(
|
||||
all_cards = Card.select().where(Card.id << card_id)
|
||||
|
||||
for x in all_cards:
|
||||
if x.player.cardset_id not in CARDSETS[rarity_name]['human']:
|
||||
if x.player.cardset_id not in CARDSETS[rarity_name]["human"]:
|
||||
if x.player.p_name in x.player.description:
|
||||
bad_cards.append(x.player.description)
|
||||
else:
|
||||
bad_cards.append(f'{x.player.description} {x.player.p_name}')
|
||||
bad_cards.append(f"{x.player.description} {x.player.p_name}")
|
||||
|
||||
return {'count': len(bad_cards), 'bad_cards': bad_cards}
|
||||
return {"count": len(bad_cards), "bad_cards": bad_cards}
|
||||
|
||||
|
||||
@router.post('/post-update/{starting_id}')
|
||||
@router.post("/post-update/{starting_id}")
|
||||
async def v1_cards_post_update(starting_id: int, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to update card lists. This event has been logged.'
|
||||
detail="You are not authorized to update card lists. This event has been logged.",
|
||||
)
|
||||
|
||||
# sheets.post_new_cards(SHEETS_AUTH, starting_id)
|
||||
raise HTTPException(status_code=200, detail=f'Just sent cards to sheets starting at ID {starting_id}')
|
||||
raise HTTPException(
|
||||
status_code=200,
|
||||
detail=f"Just sent cards to sheets starting at ID {starting_id}",
|
||||
)
|
||||
|
||||
|
||||
@router.post('/post-delete')
|
||||
@router.post("/post-delete")
|
||||
async def v1_cards_post_delete(del_ids: str, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to delete card lists. This event has been logged.'
|
||||
detail="You are not authorized to delete card lists. This event has been logged.",
|
||||
)
|
||||
|
||||
logging.info(f'del_ids: {del_ids} / type: {type(del_ids)}')
|
||||
logging.info(f"del_ids: {del_ids} / type: {type(del_ids)}")
|
||||
# sheets.post_deletion(SHEETS_AUTH, del_ids.split(','))
|
||||
|
||||
|
||||
@router.post('/wipe-team/{team_id}')
|
||||
@router.post("/wipe-team/{team_id}")
|
||||
async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to wipe teams. This event has been logged.'
|
||||
detail="You are not authorized to wipe teams. This event has been logged.",
|
||||
)
|
||||
|
||||
try:
|
||||
@ -273,19 +312,27 @@ async def v1_cards_wipe_team(team_id: int, token: str = Depends(oauth2_scheme)):
|
||||
raise HTTPException(status_code=404, detail=f'Team {team_id} not found')
|
||||
|
||||
t_query = Card.update(team=None).where(Card.team == this_team).execute()
|
||||
return f'Wiped {t_query} cards'
|
||||
return f"Wiped {t_query} cards"
|
||||
|
||||
|
||||
@router.patch('/{card_id}')
|
||||
@router.patch("/{card_id}")
|
||||
async def v1_cards_patch(
|
||||
card_id, player_id: Optional[int] = None, team_id: Optional[int] = None, pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None, variant: Optional[int] = None, roster1_id: Optional[int] = None, roster2_id: Optional[int] = None,
|
||||
roster3_id: Optional[int] = None, token: str = Depends(oauth2_scheme)):
|
||||
card_id,
|
||||
player_id: Optional[int] = None,
|
||||
team_id: Optional[int] = None,
|
||||
pack_id: Optional[int] = None,
|
||||
value: Optional[int] = None,
|
||||
variant: Optional[int] = None,
|
||||
roster1_id: Optional[int] = None,
|
||||
roster2_id: Optional[int] = None,
|
||||
roster3_id: Optional[int] = None,
|
||||
token: str = Depends(oauth2_scheme),
|
||||
):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to patch cards. This event has been logged.'
|
||||
detail="You are not authorized to patch cards. This event has been logged.",
|
||||
)
|
||||
try:
|
||||
this_card = Card.get_by_id(card_id)
|
||||
@ -318,17 +365,17 @@ async def v1_cards_patch(
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=418,
|
||||
detail='Well slap my ass and call me a teapot; I could not save that rarity'
|
||||
detail="Well slap my ass and call me a teapot; I could not save that rarity",
|
||||
)
|
||||
|
||||
|
||||
@router.delete('/{card_id}')
|
||||
@router.delete("/{card_id}")
|
||||
async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning('Bad Token: [REDACTED]')
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail='You are not authorized to delete packs. This event has been logged.'
|
||||
detail="You are not authorized to delete packs. This event has been logged.",
|
||||
)
|
||||
try:
|
||||
this_card = Card.get_by_id(card_id)
|
||||
@ -338,6 +385,6 @@ async def v1_cards_delete(card_id, token: str = Depends(oauth2_scheme)):
|
||||
count = this_card.delete_instance()
|
||||
|
||||
if count == 1:
|
||||
raise HTTPException(status_code=200, detail=f'Card {card_id} has been deleted')
|
||||
raise HTTPException(status_code=200, detail=f"Card {card_id} has been deleted")
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=f'Card {card_id} was not deleted')
|
||||
raise HTTPException(status_code=500, detail=f"Card {card_id} was not deleted")
|
||||
|
||||
@ -9,7 +9,9 @@ from typing import Optional, List, Literal
|
||||
import logging
|
||||
import pydantic
|
||||
from pandas import DataFrame
|
||||
from playwright.async_api import async_playwright
|
||||
import asyncio as _asyncio
|
||||
|
||||
from playwright.async_api import async_playwright, Browser, Playwright
|
||||
|
||||
from ..card_creation import get_batter_card_data, get_pitcher_card_data
|
||||
from ..db_engine import (
|
||||
@ -31,6 +33,62 @@ from ..db_engine import (
|
||||
from ..db_helpers import upsert_players
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent browser instance (WP-02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_browser: Browser | None = None
|
||||
_playwright: Playwright | None = None
|
||||
_browser_lock = _asyncio.Lock()
|
||||
|
||||
|
||||
async def get_browser() -> Browser:
|
||||
"""Get or create persistent Chromium browser instance.
|
||||
|
||||
Reuses a single browser across all card renders, eliminating the ~1-1.5s
|
||||
per-request launch/teardown overhead. Automatically reconnects if the
|
||||
browser process has died.
|
||||
|
||||
Uses an asyncio.Lock to prevent concurrent requests from racing to
|
||||
launch multiple Chromium processes.
|
||||
"""
|
||||
global _browser, _playwright
|
||||
async with _browser_lock:
|
||||
if _browser is None or not _browser.is_connected():
|
||||
if _playwright is not None:
|
||||
try:
|
||||
await _playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
_playwright = await async_playwright().start()
|
||||
_browser = await _playwright.chromium.launch(
|
||||
args=["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
)
|
||||
return _browser
|
||||
|
||||
|
||||
async def shutdown_browser():
|
||||
"""Clean shutdown of the persistent browser.
|
||||
|
||||
Called by the FastAPI lifespan handler on application exit so the
|
||||
Chromium process is not left orphaned.
|
||||
"""
|
||||
global _browser, _playwright
|
||||
if _browser:
|
||||
try:
|
||||
await _browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
_browser = None
|
||||
if _playwright:
|
||||
try:
|
||||
await _playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
_playwright = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Franchise normalization: Convert city+team names to city-agnostic team names
|
||||
# This enables cross-era player matching (e.g., 'Oakland Athletics' -> 'Athletics')
|
||||
FRANCHISE_NORMALIZE = {
|
||||
@ -144,7 +202,7 @@ async def get_players(
|
||||
):
|
||||
all_players = Player.select()
|
||||
if all_players.count() == 0:
|
||||
raise HTTPException(status_code=404, detail=f"There are no players to filter")
|
||||
raise HTTPException(status_code=404, detail="There are no players to filter")
|
||||
|
||||
if name is not None:
|
||||
all_players = all_players.where(fn.Lower(Player.p_name) == name.lower())
|
||||
@ -674,7 +732,7 @@ async def get_batter_card(
|
||||
)
|
||||
|
||||
headers = {"Cache-Control": "public, max-age=86400"}
|
||||
filename = (
|
||||
_filename = (
|
||||
f"{this_player.description} {this_player.p_name} {card_type} {d}-v{variant}"
|
||||
)
|
||||
if (
|
||||
@ -799,16 +857,17 @@ async def get_batter_card(
|
||||
logging.debug(f"body:\n{html_response.body.decode('UTF-8')}")
|
||||
|
||||
file_path = f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png"
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch()
|
||||
page = await browser.new_page()
|
||||
browser = await get_browser()
|
||||
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
||||
try:
|
||||
await page.set_content(html_response.body.decode("UTF-8"))
|
||||
await page.screenshot(
|
||||
path=file_path,
|
||||
type="png",
|
||||
clip={"x": 0.0, "y": 0, "width": 1200, "height": 600},
|
||||
)
|
||||
await browser.close()
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
# hti = Html2Image(
|
||||
# browser='chrome',
|
||||
|
||||
@ -36,14 +36,3 @@ async def get_player_keys(player_id: list = Query(default=None)):
|
||||
|
||||
return_val = {"count": len(all_keys), "keys": [dict(x) for x in all_keys]}
|
||||
return return_val
|
||||
|
||||
|
||||
@router.post("/live-update/pitching")
|
||||
def live_update_pitching(files: BattingFiles, token: str = Depends(oauth2_scheme)):
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(
|
||||
status_code=401, detail="You are not authorized to initiate live updates."
|
||||
)
|
||||
|
||||
return files.dict()
|
||||
|
||||
232
app/routers_v2/season_stats.py
Normal file
232
app/routers_v2/season_stats.py
Normal file
@ -0,0 +1,232 @@
|
||||
"""Season stats API endpoints.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Replaying the same game_id will double-count stats, so callers must ensure
|
||||
this is only called once per game.
|
||||
|
||||
Response: {"updated": N} where N is the number of player rows touched.
|
||||
"""
|
||||
if not valid_token(token):
|
||||
logging.warning("Bad Token: [REDACTED]")
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
updated = 0
|
||||
|
||||
# --- 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,),
|
||||
)
|
||||
)
|
||||
|
||||
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}
|
||||
0
app/seed/__init__.py
Normal file
0
app/seed/__init__.py
Normal file
29
app/seed/evolution_tracks.json
Normal file
29
app/seed/evolution_tracks.json
Normal file
@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"name": "Batter Track",
|
||||
"card_type": "batter",
|
||||
"formula": "pa + tb * 2",
|
||||
"t1_threshold": 37,
|
||||
"t2_threshold": 149,
|
||||
"t3_threshold": 448,
|
||||
"t4_threshold": 896
|
||||
},
|
||||
{
|
||||
"name": "Starting Pitcher Track",
|
||||
"card_type": "sp",
|
||||
"formula": "ip + k",
|
||||
"t1_threshold": 10,
|
||||
"t2_threshold": 40,
|
||||
"t3_threshold": 120,
|
||||
"t4_threshold": 240
|
||||
},
|
||||
{
|
||||
"name": "Relief Pitcher Track",
|
||||
"card_type": "rp",
|
||||
"formula": "ip + k",
|
||||
"t1_threshold": 3,
|
||||
"t2_threshold": 12,
|
||||
"t3_threshold": 35,
|
||||
"t4_threshold": 70
|
||||
}
|
||||
]
|
||||
66
app/seed/evolution_tracks.py
Normal file
66
app/seed/evolution_tracks.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Seed script for EvolutionTrack records.
|
||||
|
||||
Loads track definitions from evolution_tracks.json and upserts them into the
|
||||
database using get_or_create keyed on name. Existing tracks have their
|
||||
thresholds and formula updated to match the JSON in case values have changed.
|
||||
|
||||
Can be run standalone:
|
||||
python -m app.seed.evolution_tracks
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from app.db_engine import EvolutionTrack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_JSON_PATH = Path(__file__).parent / "evolution_tracks.json"
|
||||
|
||||
|
||||
def seed_evolution_tracks() -> list[EvolutionTrack]:
|
||||
"""Upsert evolution tracks from JSON seed data.
|
||||
|
||||
Returns a list of EvolutionTrack instances that were created or updated.
|
||||
"""
|
||||
raw = _JSON_PATH.read_text(encoding="utf-8")
|
||||
track_defs = json.loads(raw)
|
||||
|
||||
results: list[EvolutionTrack] = []
|
||||
|
||||
for defn in track_defs:
|
||||
track, created = EvolutionTrack.get_or_create(
|
||||
name=defn["name"],
|
||||
defaults={
|
||||
"card_type": defn["card_type"],
|
||||
"formula": defn["formula"],
|
||||
"t1_threshold": defn["t1_threshold"],
|
||||
"t2_threshold": defn["t2_threshold"],
|
||||
"t3_threshold": defn["t3_threshold"],
|
||||
"t4_threshold": defn["t4_threshold"],
|
||||
},
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update mutable fields in case the JSON values changed.
|
||||
track.card_type = defn["card_type"]
|
||||
track.formula = defn["formula"]
|
||||
track.t1_threshold = defn["t1_threshold"]
|
||||
track.t2_threshold = defn["t2_threshold"]
|
||||
track.t3_threshold = defn["t3_threshold"]
|
||||
track.t4_threshold = defn["t4_threshold"]
|
||||
track.save()
|
||||
|
||||
action = "created" if created else "updated"
|
||||
logger.info("[%s] %s (card_type=%s)", action, track.name, track.card_type)
|
||||
results.append(track)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Seeding evolution tracks...")
|
||||
tracks = seed_evolution_tracks()
|
||||
logger.info("Done. %d track(s) processed.", len(tracks))
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
119
app/services/formula_engine.py
Normal file
119
app/services/formula_engine.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Formula engine for evolution value computation (WP-09).
|
||||
|
||||
Three pure functions that compute a numeric evolution value from career stats,
|
||||
plus helpers for formula dispatch and tier classification.
|
||||
|
||||
Stats attributes expected by each formula:
|
||||
compute_batter_value: pa, hits, doubles, triples, hr (from BattingSeasonStats)
|
||||
compute_sp_value: outs, strikeouts (from PitchingSeasonStats)
|
||||
compute_rp_value: outs, strikeouts (from PitchingSeasonStats)
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class BatterStats(Protocol):
|
||||
pa: int
|
||||
hits: int
|
||||
doubles: int
|
||||
triples: int
|
||||
hr: int
|
||||
|
||||
|
||||
class PitcherStats(Protocol):
|
||||
outs: int
|
||||
strikeouts: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core formula functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_batter_value(stats) -> float:
|
||||
"""PA + (TB x 2) where TB = 1B + 2x2B + 3x3B + 4xHR."""
|
||||
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)
|
||||
|
||||
|
||||
def _pitcher_value(stats) -> float:
|
||||
return stats.outs / 3 + stats.strikeouts
|
||||
|
||||
|
||||
def compute_sp_value(stats) -> float:
|
||||
"""IP + K where IP = outs / 3."""
|
||||
return _pitcher_value(stats)
|
||||
|
||||
|
||||
def compute_rp_value(stats) -> float:
|
||||
"""IP + K (same formula as SP; thresholds differ)."""
|
||||
return _pitcher_value(stats)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch and tier helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FORMULA_DISPATCH = {
|
||||
"batter": compute_batter_value,
|
||||
"sp": compute_sp_value,
|
||||
"rp": compute_rp_value,
|
||||
}
|
||||
|
||||
|
||||
def compute_value_for_track(card_type: str, stats) -> float:
|
||||
"""Dispatch to the correct formula function by card_type.
|
||||
|
||||
Args:
|
||||
card_type: One of 'batter', 'sp', 'rp'.
|
||||
stats: Object with the attributes required by the formula.
|
||||
|
||||
Raises:
|
||||
ValueError: If card_type is not recognised.
|
||||
"""
|
||||
fn = _FORMULA_DISPATCH.get(card_type)
|
||||
if fn is None:
|
||||
raise ValueError(f"Unknown card_type: {card_type!r}")
|
||||
return fn(stats)
|
||||
|
||||
|
||||
def tier_from_value(value: float, track) -> int:
|
||||
"""Return the evolution tier (0-4) for a computed value against a track.
|
||||
|
||||
Tier boundaries are inclusive on the lower end:
|
||||
T0: value < t1
|
||||
T1: t1 <= value < t2
|
||||
T2: t2 <= value < t3
|
||||
T3: t3 <= value < t4
|
||||
T4: value >= t4
|
||||
|
||||
Args:
|
||||
value: Computed formula value.
|
||||
track: Object (or dict-like) with t1_threshold..t4_threshold attributes/keys.
|
||||
"""
|
||||
# Support both attribute-style (Peewee model) and dict (seed fixture)
|
||||
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
|
||||
558
app/services/season_stats.py
Normal file
558
app/services/season_stats.py
Normal file
@ -0,0 +1,558 @@
|
||||
"""
|
||||
season_stats.py — Incremental BattingSeasonStats and PitchingSeasonStats update logic.
|
||||
|
||||
Called once per completed StratGame to accumulate batting and pitching
|
||||
statistics into the batting_season_stats and pitching_season_stats tables
|
||||
respectively.
|
||||
|
||||
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: read-modify-write inside db.atomic() transaction
|
||||
- PostgreSQL: ON CONFLICT ... DO UPDATE with column-level EXCLUDED increments
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from peewee import EXCLUDED
|
||||
|
||||
from app.db_engine import (
|
||||
db,
|
||||
BattingSeasonStats,
|
||||
Decision,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DATABASE_TYPE = os.environ.get("DATABASE_TYPE", "sqlite").lower()
|
||||
|
||||
|
||||
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, 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
|
||||
matching BattingSeasonStats column names.
|
||||
"""
|
||||
groups = defaultdict(
|
||||
lambda: {
|
||||
"games": 0,
|
||||
"pa": 0,
|
||||
"ab": 0,
|
||||
"hits": 0,
|
||||
"doubles": 0,
|
||||
"triples": 0,
|
||||
"hr": 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
|
||||
}
|
||||
)
|
||||
|
||||
for play in plays:
|
||||
batter_id = play.batter_id
|
||||
batter_team_id = play.batter_team_id
|
||||
|
||||
if batter_id is None:
|
||||
continue
|
||||
|
||||
key = (batter_id, batter_team_id)
|
||||
g = groups[key]
|
||||
|
||||
g["pa"] += play.pa
|
||||
g["ab"] += play.ab
|
||||
g["hits"] += play.hit
|
||||
g["doubles"] += play.double
|
||||
g["triples"] += play.triple
|
||||
g["hr"] += play.homerun
|
||||
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"] = 1
|
||||
g["appeared"] = True
|
||||
|
||||
# Clean up the helper flag before returning
|
||||
for key in groups:
|
||||
del groups[key]["appeared"]
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def _build_pitching_groups(plays):
|
||||
"""
|
||||
Aggregate per-play pitching stats by (pitcher_id, pitcher_team_id).
|
||||
|
||||
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 strikeouts)
|
||||
- hit → hits allowed
|
||||
- bb → walks allowed (batter bb, separate from hbp)
|
||||
- hbp → hit batters
|
||||
- homerun → home runs allowed
|
||||
|
||||
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().
|
||||
|
||||
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": 1, # pitcher appeared in this game by definition
|
||||
"games_started": 0, # populated later via _apply_decisions
|
||||
"outs": 0,
|
||||
"strikeouts": 0,
|
||||
"bb": 0,
|
||||
"hits_allowed": 0,
|
||||
"runs_allowed": 0, # not available from StratPlay
|
||||
"earned_runs": 0, # not available from StratPlay
|
||||
"hr_allowed": 0,
|
||||
"hbp": 0,
|
||||
"wild_pitches": 0, # not available from StratPlay
|
||||
"balks": 0, # not available from StratPlay
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"holds": 0,
|
||||
"saves": 0,
|
||||
"blown_saves": 0,
|
||||
}
|
||||
)
|
||||
|
||||
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["strikeouts"] += play.so
|
||||
g["hits_allowed"] += play.hit
|
||||
g["bb"] += play.bb
|
||||
g["hbp"] += play.hbp
|
||||
g["hr_allowed"] += play.homerun
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def _apply_decisions(pitching_groups, decisions):
|
||||
"""
|
||||
Merge Decision rows into the pitching stat groups.
|
||||
|
||||
Each Decision belongs to exactly one pitcher in the game, containing
|
||||
win/loss/save/hold/blown-save flags and the is_start indicator.
|
||||
"""
|
||||
for decision in decisions:
|
||||
pitcher_id = decision.pitcher_id
|
||||
pitcher_team_id = decision.pitcher_team_id
|
||||
key = (pitcher_id, pitcher_team_id)
|
||||
|
||||
# Pitcher may have a Decision without plays (rare edge case for
|
||||
# games where the Decision was recorded without StratPlay rows).
|
||||
# Initialise a zeroed entry if not already present.
|
||||
if key not in pitching_groups:
|
||||
pitching_groups[key] = {
|
||||
"games": 1,
|
||||
"games_started": 0,
|
||||
"outs": 0,
|
||||
"strikeouts": 0,
|
||||
"bb": 0,
|
||||
"hits_allowed": 0,
|
||||
"runs_allowed": 0,
|
||||
"earned_runs": 0,
|
||||
"hr_allowed": 0,
|
||||
"hbp": 0,
|
||||
"wild_pitches": 0,
|
||||
"balks": 0,
|
||||
"wins": 0,
|
||||
"losses": 0,
|
||||
"holds": 0,
|
||||
"saves": 0,
|
||||
"blown_saves": 0,
|
||||
}
|
||||
|
||||
g = pitching_groups[key]
|
||||
g["wins"] += decision.win
|
||||
g["losses"] += decision.loss
|
||||
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_batting_postgres(player_id, team_id, season, game_id, batting):
|
||||
"""
|
||||
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()
|
||||
|
||||
increment_cols = [
|
||||
"games",
|
||||
"pa",
|
||||
"ab",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"hr",
|
||||
"rbi",
|
||||
"runs",
|
||||
"bb",
|
||||
"strikeouts",
|
||||
"hbp",
|
||||
"sac",
|
||||
"ibb",
|
||||
"gidp",
|
||||
"sb",
|
||||
"cs",
|
||||
]
|
||||
|
||||
conflict_target = [
|
||||
BattingSeasonStats.player,
|
||||
BattingSeasonStats.team,
|
||||
BattingSeasonStats.season,
|
||||
]
|
||||
|
||||
update_dict = {}
|
||||
for col in increment_cols:
|
||||
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"]
|
||||
|
||||
BattingSeasonStats.insert(
|
||||
player=player_id,
|
||||
team=team_id,
|
||||
season=season,
|
||||
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(
|
||||
conflict_target=conflict_target,
|
||||
action="update",
|
||||
update=update_dict,
|
||||
).execute()
|
||||
|
||||
|
||||
def _upsert_pitching_postgres(player_id, team_id, season, game_id, pitching):
|
||||
"""
|
||||
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.
|
||||
This is safe because the entire update_season_stats() call is
|
||||
wrapped in db.atomic().
|
||||
"""
|
||||
now = datetime.now()
|
||||
|
||||
obj, _ = BattingSeasonStats.get_or_create(
|
||||
player_id=player_id,
|
||||
team_id=team_id,
|
||||
season=season,
|
||||
)
|
||||
|
||||
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.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.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.strikeouts += pitching.get("strikeouts", 0)
|
||||
obj.bb += pitching.get("bb", 0)
|
||||
obj.hits_allowed += pitching.get("hits_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.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
|
||||
obj.last_updated_at = now
|
||||
obj.save()
|
||||
|
||||
|
||||
def update_season_stats(game_id: int) -> dict:
|
||||
"""
|
||||
Accumulate per-game batting and pitching stats into BattingSeasonStats
|
||||
and PitchingSeasonStats respectively.
|
||||
|
||||
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. 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 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.
|
||||
|
||||
Returns:
|
||||
Summary dict with keys: game_id, season, batters_updated,
|
||||
pitchers_updated. If the game was already processed, also
|
||||
includes "skipped": True.
|
||||
|
||||
Raises:
|
||||
StratGame.DoesNotExist: If no StratGame row matches game_id.
|
||||
"""
|
||||
logger.info("update_season_stats: starting for game_id=%d", game_id)
|
||||
|
||||
# Step 1 — Fetch the game to get season
|
||||
game = StratGame.get_by_id(game_id)
|
||||
season = game.season
|
||||
|
||||
with db.atomic():
|
||||
# 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,
|
||||
)
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"season": season,
|
||||
"batters_updated": 0,
|
||||
"pitchers_updated": 0,
|
||||
"skipped": True,
|
||||
}
|
||||
|
||||
# Step 3 — Load plays
|
||||
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
||||
logger.debug(
|
||||
"update_season_stats: game_id=%d loaded %d plays", game_id, len(plays)
|
||||
)
|
||||
|
||||
# Steps 4 & 5 — Aggregate batting and pitching groups
|
||||
batting_groups = _build_batting_groups(plays)
|
||||
pitching_groups = _build_pitching_groups(plays)
|
||||
|
||||
# Step 6 — Merge Decision rows into pitching groups
|
||||
decisions = list(Decision.select().where(Decision.game == game_id))
|
||||
_apply_decisions(pitching_groups, decisions)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# 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 — "
|
||||
"batters_updated=%d pitchers_updated=%d",
|
||||
game_id,
|
||||
batters_updated,
|
||||
pitchers_updated,
|
||||
)
|
||||
|
||||
return {
|
||||
"game_id": game_id,
|
||||
"season": season,
|
||||
"batters_updated": batters_updated,
|
||||
"pitchers_updated": pitchers_updated,
|
||||
}
|
||||
203
migrations/2026-03-17_add_evolution_tables.sql
Normal file
203
migrations/2026-03-17_add_evolution_tables.sql
Normal file
@ -0,0 +1,203 @@
|
||||
-- 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,
|
||||
-- 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 evolution_track;
|
||||
-- SELECT count(*) FROM evolution_card_state;
|
||||
-- SELECT count(*) FROM evolution_tier_boost;
|
||||
-- SELECT count(*) FROM evolution_cosmetic;
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_name IN ('card', 'battingcard', 'pitchingcard')
|
||||
-- AND column_name IN ('variant', 'image_url')
|
||||
-- ORDER BY table_name, column_name;
|
||||
--
|
||||
-- Rollback: See DROP/ALTER statements at bottom of file
|
||||
|
||||
-- ============================================
|
||||
-- FORWARD MIGRATION
|
||||
-- ============================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 1: player_season_stats
|
||||
-- Accumulates per-player per-team per-season
|
||||
-- batting and pitching totals for evolution
|
||||
-- formula evaluation.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS player_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,
|
||||
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,
|
||||
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);
|
||||
|
||||
-- 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);
|
||||
|
||||
-- Fast lookup by player across seasons
|
||||
CREATE INDEX IF NOT EXISTS player_season_stats_player_season_idx
|
||||
ON player_season_stats (player_id, season);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 2: evolution_track
|
||||
-- Defines the available evolution tracks
|
||||
-- (e.g. "HR Mastery", "Ace SP"), their
|
||||
-- metric formula, and the four tier thresholds.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_track (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
card_type VARCHAR(50) NOT NULL, -- 'batter', 'sp', or 'rp'
|
||||
formula VARCHAR(255) NOT NULL, -- e.g. 'hr', 'k_per_9', 'ops'
|
||||
t1_threshold INTEGER NOT NULL,
|
||||
t2_threshold INTEGER NOT NULL,
|
||||
t3_threshold INTEGER NOT NULL,
|
||||
t4_threshold INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 3: 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
|
||||
-- + team combination uniquely identifies a
|
||||
-- card in a given season).
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_card_state (
|
||||
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,
|
||||
track_id INTEGER NOT NULL REFERENCES evolution_track(id) ON DELETE CASCADE,
|
||||
current_tier INTEGER NOT NULL DEFAULT 0,
|
||||
current_value DOUBLE PRECISION NOT NULL DEFAULT 0.0,
|
||||
fully_evolved BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_evaluated_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- One evolution state per card (player + team)
|
||||
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
|
||||
-- Defines the stat boosts unlocked at each
|
||||
-- tier within a track. A single tier may
|
||||
-- grant multiple boosts (e.g. +1 HR and
|
||||
-- +1 power rating).
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_tier_boost (
|
||||
id SERIAL PRIMARY KEY,
|
||||
track_id INTEGER NOT NULL REFERENCES evolution_track(id) ON DELETE CASCADE,
|
||||
tier INTEGER NOT NULL, -- 1-4
|
||||
boost_type VARCHAR(50) NOT NULL, -- e.g. 'rating_bump', 'display_only'
|
||||
boost_target VARCHAR(50) NOT NULL, -- e.g. 'hr_rating', 'contact_rating'
|
||||
boost_value DOUBLE PRECISION NOT NULL DEFAULT 0.0
|
||||
);
|
||||
|
||||
-- Prevent duplicate boost definitions for the same track/tier/type/target
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS evolution_tier_boost_track_tier_type_target_uniq
|
||||
ON evolution_tier_boost (track_id, tier, boost_type, boost_target);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Table 5: evolution_cosmetic
|
||||
-- Catalogue of unlockable visual treatments
|
||||
-- (borders, foils, badges, etc.) tied to
|
||||
-- minimum tier requirements.
|
||||
-- --------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS evolution_cosmetic (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) UNIQUE NOT NULL,
|
||||
tier_required INTEGER NOT NULL DEFAULT 0,
|
||||
cosmetic_type VARCHAR(50) NOT NULL, -- e.g. 'border', 'foil', 'badge'
|
||||
css_class VARCHAR(255),
|
||||
asset_url VARCHAR(500)
|
||||
);
|
||||
|
||||
-- --------------------------------------------
|
||||
-- Column extensions for existing tables
|
||||
-- --------------------------------------------
|
||||
|
||||
-- Track which visual variant a card is displaying
|
||||
-- (NULL = base card, 1+ = evolved variants)
|
||||
ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL;
|
||||
|
||||
-- Store pre-rendered or externally-hosted card image URLs
|
||||
ALTER TABLE battingcard ADD COLUMN IF NOT EXISTS image_url VARCHAR(500);
|
||||
ALTER TABLE pitchingcard ADD COLUMN IF NOT EXISTS image_url VARCHAR(500);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ============================================
|
||||
-- VERIFICATION QUERIES
|
||||
-- ============================================
|
||||
-- \d player_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',
|
||||
-- 'evolution_card_state',
|
||||
-- 'evolution_tier_boost'
|
||||
-- )
|
||||
-- ORDER BY tablename, indexname;
|
||||
-- SELECT column_name, data_type FROM information_schema.columns
|
||||
-- WHERE table_name IN ('card', 'battingcard', 'pitchingcard')
|
||||
-- AND column_name IN ('variant', 'image_url')
|
||||
-- ORDER BY table_name, column_name;
|
||||
|
||||
-- ============================================
|
||||
-- ROLLBACK (if needed)
|
||||
-- ============================================
|
||||
-- ALTER TABLE pitchingcard DROP COLUMN IF EXISTS image_url;
|
||||
-- ALTER TABLE battingcard DROP COLUMN IF EXISTS image_url;
|
||||
-- ALTER TABLE card DROP COLUMN IF EXISTS variant;
|
||||
-- DROP TABLE IF EXISTS evolution_cosmetic CASCADE;
|
||||
-- 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;
|
||||
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;
|
||||
5
pyproject.toml
Normal file
5
pyproject.toml
Normal file
@ -0,0 +1,5 @@
|
||||
[tool.ruff]
|
||||
[tool.ruff.lint]
|
||||
# db_engine.py uses `from peewee import *` throughout — a pre-existing
|
||||
# codebase pattern. Suppress wildcard-import warnings for that file only.
|
||||
per-file-ignores = { "app/db_engine.py" = ["F401", "F403", "F405"], "app/main.py" = ["E402", "F541"] }
|
||||
3
ruff.toml
Normal file
3
ruff.toml
Normal file
@ -0,0 +1,3 @@
|
||||
[lint]
|
||||
# db_engine.py uses `from peewee import *` intentionally — suppress star-import warnings
|
||||
ignore = ["F403", "F405"]
|
||||
@ -2,9 +2,6 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'style.html' %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;700&family=Source+Sans+3:wght@400;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="fullCard" style="width: 1200px; height: 600px;">
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,14 +1,175 @@
|
||||
"""Pytest configuration for the paper-dynasty-database test suite.
|
||||
"""
|
||||
Shared test fixtures for the Paper Dynasty database test suite.
|
||||
|
||||
Sets DATABASE_TYPE=postgresql before any app module is imported so that
|
||||
db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the
|
||||
production SQLite file during test collection. Each test module is
|
||||
responsible for binding models to its own in-memory database.
|
||||
Uses in-memory SQLite with foreign_keys pragma enabled. Each test
|
||||
gets a fresh set of tables via the setup_test_db fixture (autouse).
|
||||
|
||||
All models are bound to the in-memory database before table creation
|
||||
so that no connection to the real storage/pd_master.db occurs during
|
||||
tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from peewee import SqliteDatabase
|
||||
|
||||
# Set DATABASE_TYPE=postgresql so that the module-level SKIP_TABLE_CREATION
|
||||
# flag is True. This prevents db_engine.py from calling create_tables()
|
||||
# against the real storage/pd_master.db during import — those calls would
|
||||
# fail if indexes already exist and would also contaminate the dev database.
|
||||
# The PooledPostgresqlDatabase object is created but never actually connects
|
||||
# because our fixture rebinds all models to an in-memory SQLite db before
|
||||
# any query is executed.
|
||||
os.environ["DATABASE_TYPE"] = "postgresql"
|
||||
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
||||
# without raising a configuration error (it will not actually be used).
|
||||
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
||||
|
||||
from app.db_engine import (
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
PackType,
|
||||
Pack,
|
||||
Card,
|
||||
Roster,
|
||||
RosterSlot,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
EvolutionCosmetic,
|
||||
ScoutOpportunity,
|
||||
ScoutClaim,
|
||||
)
|
||||
|
||||
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
||||
|
||||
# All models in dependency order (parents before children) so that
|
||||
# create_tables and drop_tables work without FK violations.
|
||||
_TEST_MODELS = [
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
PackType,
|
||||
Pack,
|
||||
Card,
|
||||
Roster,
|
||||
RosterSlot,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Decision,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
ProcessedGame,
|
||||
ScoutOpportunity,
|
||||
ScoutClaim,
|
||||
EvolutionTrack,
|
||||
EvolutionCardState,
|
||||
EvolutionTierBoost,
|
||||
EvolutionCosmetic,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_db():
|
||||
"""Bind all models to in-memory SQLite and create tables.
|
||||
|
||||
The fixture is autouse so every test automatically gets a fresh,
|
||||
isolated database schema without needing to request it explicitly.
|
||||
Tables are dropped in reverse dependency order after each test to
|
||||
keep the teardown clean and to catch any accidental FK reference
|
||||
direction bugs early.
|
||||
"""
|
||||
_test_db.bind(_TEST_MODELS)
|
||||
_test_db.connect()
|
||||
_test_db.create_tables(_TEST_MODELS)
|
||||
yield _test_db
|
||||
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
||||
_test_db.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Minimal shared fixtures — create just enough data for FK dependencies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rarity():
|
||||
"""A single Common rarity row used as FK seed for Player rows."""
|
||||
return Rarity.create(value=1, name="Common", color="#ffffff")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player(rarity):
|
||||
"""A minimal Player row with all required (non-nullable) columns filled.
|
||||
|
||||
Player.p_name is the real column name (not 'name'). All FK and
|
||||
non-nullable varchar fields are provided so SQLite's NOT NULL
|
||||
constraints are satisfied even with foreign_keys=ON.
|
||||
"""
|
||||
cardset = Cardset.create(
|
||||
name="Test Set",
|
||||
description="Test cardset",
|
||||
total_cards=100,
|
||||
)
|
||||
return Player.create(
|
||||
p_name="Test Player",
|
||||
rarity=rarity,
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
pos_1="1B",
|
||||
image="https://example.com/image.png",
|
||||
mlbclub="TST",
|
||||
franchise="TST",
|
||||
description="A test player",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team():
|
||||
"""A minimal Team row.
|
||||
|
||||
Team uses abbrev/lname/sname/gmid/gmname/gsheet/wallet/team_value/
|
||||
collection_value — not the 'name'/'user_id' shorthand described in
|
||||
the spec, which referred to the real underlying columns by
|
||||
simplified names.
|
||||
"""
|
||||
return Team.create(
|
||||
abbrev="TST",
|
||||
sname="Test",
|
||||
lname="Test Team",
|
||||
gmid=100000001,
|
||||
gmname="testuser",
|
||||
gsheet="https://docs.google.com/spreadsheets/test",
|
||||
wallet=500,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=11,
|
||||
is_ai=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def track():
|
||||
"""A minimal EvolutionTrack for batter cards."""
|
||||
return EvolutionTrack.create(
|
||||
name="Batter Track",
|
||||
card_type="batter",
|
||||
formula="pa + tb * 2",
|
||||
t1_threshold=37,
|
||||
t2_threshold=149,
|
||||
t3_threshold=448,
|
||||
t4_threshold=896,
|
||||
)
|
||||
|
||||
332
tests/test_evolution_models.py
Normal file
332
tests/test_evolution_models.py
Normal file
@ -0,0 +1,332 @@
|
||||
"""
|
||||
Tests for evolution-related models and PlayerSeasonStats.
|
||||
|
||||
Covers WP-01 acceptance criteria:
|
||||
- EvolutionTrack: CRUD and unique-name constraint
|
||||
- EvolutionCardState: CRUD, defaults, unique-(player,team) constraint,
|
||||
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),
|
||||
and in-place stat accumulation
|
||||
|
||||
Each test class is self-contained: fixtures from conftest.py supply the
|
||||
minimal parent rows needed to satisfy FK constraints, and every assertion
|
||||
targets a single, clearly-named behaviour so failures are easy to trace.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from peewee import IntegrityError
|
||||
from playhouse.shortcuts import model_to_dict
|
||||
|
||||
from app.db_engine import (
|
||||
EvolutionCardState,
|
||||
EvolutionCosmetic,
|
||||
EvolutionTierBoost,
|
||||
EvolutionTrack,
|
||||
PlayerSeasonStats,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionTrack
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionTrack:
|
||||
"""Tests for the EvolutionTrack model.
|
||||
|
||||
EvolutionTrack defines a named progression path (formula +
|
||||
tier thresholds) for a card type. The name column carries a
|
||||
UNIQUE constraint so that accidental duplicates are caught at
|
||||
the database level.
|
||||
"""
|
||||
|
||||
def test_create_track(self, track):
|
||||
"""Creating a track persists all fields and they round-trip correctly.
|
||||
|
||||
Reads back via model_to_dict (recurse=False) to verify the raw
|
||||
column values, not Python-object representations, match what was
|
||||
inserted.
|
||||
"""
|
||||
data = model_to_dict(track, recurse=False)
|
||||
assert data["name"] == "Batter Track"
|
||||
assert data["card_type"] == "batter"
|
||||
assert data["formula"] == "pa + tb * 2"
|
||||
assert data["t1_threshold"] == 37
|
||||
assert data["t2_threshold"] == 149
|
||||
assert data["t3_threshold"] == 448
|
||||
assert data["t4_threshold"] == 896
|
||||
|
||||
def test_track_unique_name(self, track):
|
||||
"""Inserting a second track with the same name raises IntegrityError.
|
||||
|
||||
The UNIQUE constraint on EvolutionTrack.name must prevent two
|
||||
tracks from sharing the same identifier, as the name is used as
|
||||
a human-readable key throughout the evolution system.
|
||||
"""
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionTrack.create(
|
||||
name="Batter Track", # duplicate
|
||||
card_type="sp",
|
||||
formula="outs * 3",
|
||||
t1_threshold=10,
|
||||
t2_threshold=40,
|
||||
t3_threshold=120,
|
||||
t4_threshold=240,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionCardState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionCardState:
|
||||
"""Tests for EvolutionCardState, which tracks per-player evolution progress.
|
||||
|
||||
Each row represents one card (player) owned by one team, linked to a
|
||||
specific EvolutionTrack. The model records the current tier (0-4),
|
||||
accumulated progress value, and whether the card is fully evolved.
|
||||
"""
|
||||
|
||||
def test_create_card_state(self, player, team, track):
|
||||
"""Creating a card state stores all fields and defaults are correct.
|
||||
|
||||
Defaults under test:
|
||||
current_tier → 0 (fresh card, no tier unlocked yet)
|
||||
current_value → 0.0 (no formula progress accumulated)
|
||||
fully_evolved → False (evolution is not complete at creation)
|
||||
last_evaluated_at → None (never evaluated yet)
|
||||
"""
|
||||
state = EvolutionCardState.create(player=player, team=team, track=track)
|
||||
|
||||
fetched = EvolutionCardState.get_by_id(state.id)
|
||||
assert fetched.player_id == player.player_id
|
||||
assert fetched.team_id == team.id
|
||||
assert fetched.track_id == track.id
|
||||
assert fetched.current_tier == 0
|
||||
assert fetched.current_value == 0.0
|
||||
assert fetched.fully_evolved is False
|
||||
assert fetched.last_evaluated_at is None
|
||||
|
||||
def test_card_state_unique_player_team(self, player, team, track):
|
||||
"""A second card state for the same (player, team) pair raises IntegrityError.
|
||||
|
||||
The unique index on (player, team) enforces that each player card
|
||||
has at most one evolution state per team roster slot, preventing
|
||||
duplicate evolution progress rows for the same physical card.
|
||||
"""
|
||||
EvolutionCardState.create(player=player, team=team, track=track)
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionCardState.create(player=player, team=team, track=track)
|
||||
|
||||
def test_card_state_fk_track(self, player, team, track):
|
||||
"""Accessing card_state.track returns the original EvolutionTrack instance.
|
||||
|
||||
This confirms the FK is correctly wired and that Peewee resolves
|
||||
the relationship, returning an object with the same primary key and
|
||||
name as the track used during creation.
|
||||
"""
|
||||
state = EvolutionCardState.create(player=player, team=team, track=track)
|
||||
fetched = EvolutionCardState.get_by_id(state.id)
|
||||
resolved_track = fetched.track
|
||||
assert resolved_track.id == track.id
|
||||
assert resolved_track.name == "Batter Track"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionTierBoost
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionTierBoost:
|
||||
"""Tests for EvolutionTierBoost, the per-tier stat/rating bonus table.
|
||||
|
||||
Each row maps a (track, tier) combination to a single boost — the
|
||||
specific stat or rating column to buff and by how much. The four-
|
||||
column unique constraint prevents double-booking the same boost slot.
|
||||
"""
|
||||
|
||||
def test_create_tier_boost(self, track):
|
||||
"""Creating a boost row persists all fields accurately.
|
||||
|
||||
Verifies boost_type, boost_target, and boost_value are stored
|
||||
and retrieved without modification.
|
||||
"""
|
||||
boost = EvolutionTierBoost.create(
|
||||
track=track,
|
||||
tier=1,
|
||||
boost_type="rating",
|
||||
boost_target="contact_vl",
|
||||
boost_value=1.5,
|
||||
)
|
||||
fetched = EvolutionTierBoost.get_by_id(boost.id)
|
||||
assert fetched.track_id == track.id
|
||||
assert fetched.tier == 1
|
||||
assert fetched.boost_type == "rating"
|
||||
assert fetched.boost_target == "contact_vl"
|
||||
assert fetched.boost_value == 1.5
|
||||
|
||||
def test_tier_boost_unique_constraint(self, track):
|
||||
"""Duplicate (track, tier, boost_type, boost_target) raises IntegrityError.
|
||||
|
||||
The four-column unique index ensures that a single boost slot
|
||||
(e.g. Tier-1 contact_vl rating) cannot be defined twice for the
|
||||
same track, which would create ambiguity during evolution evaluation.
|
||||
"""
|
||||
EvolutionTierBoost.create(
|
||||
track=track,
|
||||
tier=2,
|
||||
boost_type="rating",
|
||||
boost_target="power_vr",
|
||||
boost_value=2.0,
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionTierBoost.create(
|
||||
track=track,
|
||||
tier=2,
|
||||
boost_type="rating",
|
||||
boost_target="power_vr",
|
||||
boost_value=3.0, # different value, same identity columns
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EvolutionCosmetic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEvolutionCosmetic:
|
||||
"""Tests for EvolutionCosmetic, decorative unlocks tied to evolution tiers.
|
||||
|
||||
Cosmetics are purely visual rewards (frames, badges, themes) that a
|
||||
card unlocks when it reaches a required tier. The name column is
|
||||
the stable identifier and carries a UNIQUE constraint.
|
||||
"""
|
||||
|
||||
def test_create_cosmetic(self):
|
||||
"""Creating a cosmetic persists all fields correctly.
|
||||
|
||||
Verifies all columns including optional ones (css_class, asset_url)
|
||||
are stored and retrieved.
|
||||
"""
|
||||
cosmetic = EvolutionCosmetic.create(
|
||||
name="Gold Frame",
|
||||
tier_required=2,
|
||||
cosmetic_type="frame",
|
||||
css_class="evo-frame-gold",
|
||||
asset_url="https://cdn.example.com/frames/gold.png",
|
||||
)
|
||||
fetched = EvolutionCosmetic.get_by_id(cosmetic.id)
|
||||
assert fetched.name == "Gold Frame"
|
||||
assert fetched.tier_required == 2
|
||||
assert fetched.cosmetic_type == "frame"
|
||||
assert fetched.css_class == "evo-frame-gold"
|
||||
assert fetched.asset_url == "https://cdn.example.com/frames/gold.png"
|
||||
|
||||
def test_cosmetic_unique_name(self):
|
||||
"""Inserting a second cosmetic with the same name raises IntegrityError.
|
||||
|
||||
The UNIQUE constraint on EvolutionCosmetic.name prevents duplicate
|
||||
cosmetic definitions that could cause ambiguous tier unlock lookups.
|
||||
"""
|
||||
EvolutionCosmetic.create(
|
||||
name="Silver Badge",
|
||||
tier_required=1,
|
||||
cosmetic_type="badge",
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
EvolutionCosmetic.create(
|
||||
name="Silver Badge", # duplicate
|
||||
tier_required=3,
|
||||
cosmetic_type="badge",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlayerSeasonStats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlayerSeasonStats:
|
||||
"""Tests for PlayerSeasonStats, 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
|
||||
prevents double-counting and ensures a single authoritative row for
|
||||
each (player, team, season) combination.
|
||||
"""
|
||||
|
||||
def test_create_season_stats(self, player, team):
|
||||
"""Creating a stats row with explicit values stores everything correctly.
|
||||
|
||||
Also verifies the integer stat defaults (all 0) for columns that
|
||||
are not provided, which is the initial state before any games are
|
||||
processed.
|
||||
"""
|
||||
stats = PlayerSeasonStats.create(
|
||||
player=player,
|
||||
team=team,
|
||||
season=11,
|
||||
games_batting=5,
|
||||
pa=20,
|
||||
ab=18,
|
||||
hits=6,
|
||||
doubles=1,
|
||||
triples=0,
|
||||
hr=2,
|
||||
bb=2,
|
||||
hbp=0,
|
||||
so=4,
|
||||
rbi=5,
|
||||
runs=3,
|
||||
sb=1,
|
||||
cs=0,
|
||||
)
|
||||
fetched = PlayerSeasonStats.get_by_id(stats.id)
|
||||
assert fetched.player_id == player.player_id
|
||||
assert fetched.team_id == team.id
|
||||
assert fetched.season == 11
|
||||
assert fetched.games_batting == 5
|
||||
assert fetched.pa == 20
|
||||
assert fetched.hits == 6
|
||||
assert fetched.hr == 2
|
||||
# Pitching fields were not set — confirm default zero values
|
||||
assert fetched.games_pitching == 0
|
||||
assert fetched.outs == 0
|
||||
assert fetched.wins == 0
|
||||
assert fetched.saves == 0
|
||||
# Nullable meta fields
|
||||
assert fetched.last_game is None
|
||||
assert fetched.last_updated_at is None
|
||||
|
||||
def test_season_stats_unique_constraint(self, player, team):
|
||||
"""A second row for the same (player, team, season) raises IntegrityError.
|
||||
|
||||
The unique index on these three columns guarantees that each
|
||||
player-team-season combination has exactly one accumulation row,
|
||||
preventing duplicate stat aggregation that would inflate totals.
|
||||
"""
|
||||
PlayerSeasonStats.create(player=player, team=team, season=11)
|
||||
with pytest.raises(IntegrityError):
|
||||
PlayerSeasonStats.create(player=player, team=team, season=11)
|
||||
|
||||
def test_season_stats_increment(self, player, team):
|
||||
"""Manually incrementing hits on an existing row persists the change.
|
||||
|
||||
Simulates the common pattern used by the stats accumulator:
|
||||
fetch the row, add the game delta, save. Verifies that save()
|
||||
writes back to the database and that subsequent reads reflect the
|
||||
updated value.
|
||||
"""
|
||||
stats = PlayerSeasonStats.create(
|
||||
player=player,
|
||||
team=team,
|
||||
season=11,
|
||||
hits=10,
|
||||
)
|
||||
stats.hits += 3
|
||||
stats.save()
|
||||
|
||||
refreshed = PlayerSeasonStats.get_by_id(stats.id)
|
||||
assert refreshed.hits == 13
|
||||
159
tests/test_evolution_seed.py
Normal file
159
tests/test_evolution_seed.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""
|
||||
Tests for app/seed/evolution_tracks.py — seed_evolution_tracks().
|
||||
|
||||
What: Verify that the JSON-driven seed function correctly creates, counts,
|
||||
and idempotently updates EvolutionTrack rows in the database.
|
||||
|
||||
Why: The seed is the single source of truth for track configuration. A
|
||||
regression here (duplicates, wrong thresholds, missing formula) would
|
||||
silently corrupt evolution scoring for every card in the system.
|
||||
|
||||
Each test operates on a fresh in-memory SQLite database provided by the
|
||||
autouse `setup_test_db` fixture in conftest.py. The seed reads its data
|
||||
from `app/seed/evolution_tracks.json` on disk, so the tests also serve as
|
||||
a light integration check between the JSON file and the Peewee model.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db_engine import EvolutionTrack
|
||||
from app.seed.evolution_tracks import seed_evolution_tracks
|
||||
|
||||
# Path to the JSON fixture that the seed reads from at runtime
|
||||
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def json_tracks():
|
||||
"""Load the raw JSON definitions so tests can assert against them.
|
||||
|
||||
This avoids hardcoding expected values — if the JSON changes, tests
|
||||
automatically follow without needing manual updates.
|
||||
"""
|
||||
return json.loads(_JSON_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_seed_creates_three_tracks(json_tracks):
|
||||
"""After one seed call, exactly 3 EvolutionTrack rows must exist.
|
||||
|
||||
Why: The JSON currently defines three card-type tracks (batter, sp, rp).
|
||||
If the count is wrong the system would either be missing tracks
|
||||
(evolution disabled for a card type) or have phantom extras.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
assert EvolutionTrack.select().count() == 3
|
||||
|
||||
|
||||
def test_seed_correct_card_types(json_tracks):
|
||||
"""The set of card_type values persisted must match the JSON exactly.
|
||||
|
||||
Why: card_type is used as a discriminator throughout the evolution engine.
|
||||
An unexpected value (e.g. 'pitcher' instead of 'sp') would cause
|
||||
track-lookup misses and silently skip evolution scoring for that role.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
expected_types = {d["card_type"] for d in json_tracks}
|
||||
actual_types = {t.card_type for t in EvolutionTrack.select()}
|
||||
assert actual_types == expected_types
|
||||
|
||||
|
||||
def test_seed_thresholds_ascending():
|
||||
"""For every track, t1 < t2 < t3 < t4.
|
||||
|
||||
Why: The evolution engine uses these thresholds to determine tier
|
||||
boundaries. If they are not strictly ascending, tier comparisons
|
||||
would produce incorrect or undefined results (e.g. a player could
|
||||
simultaneously satisfy tier 3 and not satisfy tier 2).
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
for track in EvolutionTrack.select():
|
||||
assert (
|
||||
track.t1_threshold < track.t2_threshold
|
||||
), f"{track.name}: t1 ({track.t1_threshold}) >= t2 ({track.t2_threshold})"
|
||||
assert (
|
||||
track.t2_threshold < track.t3_threshold
|
||||
), f"{track.name}: t2 ({track.t2_threshold}) >= t3 ({track.t3_threshold})"
|
||||
assert (
|
||||
track.t3_threshold < track.t4_threshold
|
||||
), f"{track.name}: t3 ({track.t3_threshold}) >= t4 ({track.t4_threshold})"
|
||||
|
||||
|
||||
def test_seed_thresholds_positive():
|
||||
"""All tier threshold values must be strictly greater than zero.
|
||||
|
||||
Why: A zero or negative threshold would mean a card starts the game
|
||||
already evolved (tier >= 1 at 0 accumulated stat points), which would
|
||||
bypass the entire progression system.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
for track in EvolutionTrack.select():
|
||||
assert track.t1_threshold > 0, f"{track.name}: t1_threshold is not positive"
|
||||
assert track.t2_threshold > 0, f"{track.name}: t2_threshold is not positive"
|
||||
assert track.t3_threshold > 0, f"{track.name}: t3_threshold is not positive"
|
||||
assert track.t4_threshold > 0, f"{track.name}: t4_threshold is not positive"
|
||||
|
||||
|
||||
def test_seed_formula_present():
|
||||
"""Every persisted track must have a non-empty formula string.
|
||||
|
||||
Why: The formula is evaluated at runtime to compute a player's evolution
|
||||
score. An empty formula would cause either a Python eval error or
|
||||
silently produce 0 for every player, halting all evolution progress.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
for track in EvolutionTrack.select():
|
||||
assert (
|
||||
track.formula and track.formula.strip()
|
||||
), f"{track.name}: formula is empty or whitespace-only"
|
||||
|
||||
|
||||
def test_seed_idempotent():
|
||||
"""Calling seed_evolution_tracks() twice must still yield exactly 3 rows.
|
||||
|
||||
Why: The seed is designed to be safe to re-run (e.g. as part of a
|
||||
migration or CI bootstrap). If it inserts duplicates on a second call,
|
||||
the unique constraint on EvolutionTrack.name would raise an IntegrityError
|
||||
in PostgreSQL, and in SQLite it would silently create phantom rows that
|
||||
corrupt tier-lookup joins.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
seed_evolution_tracks()
|
||||
assert EvolutionTrack.select().count() == 3
|
||||
|
||||
|
||||
def test_seed_updates_on_rerun(json_tracks):
|
||||
"""A second seed call must restore any manually changed threshold to the JSON value.
|
||||
|
||||
What: Seed once, manually mutate a threshold in the DB, then seed again.
|
||||
Assert that the threshold is now back to the JSON-defined value.
|
||||
|
||||
Why: The seed must act as the authoritative source of truth. If
|
||||
re-seeding does not overwrite local changes, configuration drift can
|
||||
build up silently and the production database would diverge from the
|
||||
checked-in JSON without any visible error.
|
||||
"""
|
||||
seed_evolution_tracks()
|
||||
|
||||
# Pick the first track and corrupt its t1_threshold
|
||||
first_def = json_tracks[0]
|
||||
track = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
|
||||
original_t1 = track.t1_threshold
|
||||
corrupted_value = original_t1 + 9999
|
||||
track.t1_threshold = corrupted_value
|
||||
track.save()
|
||||
|
||||
# Confirm the corruption took effect before re-seeding
|
||||
track_check = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
|
||||
assert track_check.t1_threshold == corrupted_value
|
||||
|
||||
# Re-seed — should restore the JSON value
|
||||
seed_evolution_tracks()
|
||||
|
||||
restored = EvolutionTrack.get(EvolutionTrack.name == first_def["name"])
|
||||
assert restored.t1_threshold == first_def["t1_threshold"], (
|
||||
f"Expected t1_threshold={first_def['t1_threshold']} after re-seed, "
|
||||
f"got {restored.t1_threshold}"
|
||||
)
|
||||
132
tests/test_evolution_track_api.py
Normal file
132
tests/test_evolution_track_api.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Integration tests for the evolution track catalog API endpoints (WP-06).
|
||||
|
||||
Tests cover:
|
||||
GET /api/v2/evolution/tracks
|
||||
GET /api/v2/evolution/tracks/{track_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 the test module runs and deleted
|
||||
afterwards so the tests are repeatable. ON CONFLICT keeps the table clean
|
||||
even if a previous run did not complete teardown.
|
||||
"""
|
||||
|
||||
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')}"}
|
||||
|
||||
_SEED_TRACKS = [
|
||||
("Batter", "batter", "pa+tb*2", 37, 149, 448, 896),
|
||||
("Starting Pitcher", "sp", "ip+k", 10, 40, 120, 240),
|
||||
("Relief Pitcher", "rp", "ip+k", 3, 12, 35, 70),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_tracks(pg_conn):
|
||||
"""Insert three canonical evolution tracks; remove them after the module.
|
||||
|
||||
Uses ON CONFLICT DO UPDATE so the fixture is safe to run even if rows
|
||||
already exist from a prior test run that did not clean up. Returns the
|
||||
list of row IDs that were upserted.
|
||||
"""
|
||||
cur = pg_conn.cursor()
|
||||
ids = []
|
||||
for name, card_type, formula, t1, t2, t3, t4 in _SEED_TRACKS:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO evolution_track
|
||||
(name, card_type, formula, t1_threshold, t2_threshold, t3_threshold, t4_threshold)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (card_type) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
formula = EXCLUDED.formula,
|
||||
t1_threshold = EXCLUDED.t1_threshold,
|
||||
t2_threshold = EXCLUDED.t2_threshold,
|
||||
t3_threshold = EXCLUDED.t3_threshold,
|
||||
t4_threshold = EXCLUDED.t4_threshold
|
||||
RETURNING id
|
||||
""",
|
||||
(name, card_type, formula, t1, t2, t3, t4),
|
||||
)
|
||||
ids.append(cur.fetchone()[0])
|
||||
pg_conn.commit()
|
||||
yield ids
|
||||
cur.execute("DELETE FROM evolution_track WHERE id = ANY(%s)", (ids,))
|
||||
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
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_list_tracks_returns_count_3(client, seeded_tracks):
|
||||
"""GET /tracks returns all three tracks with count=3.
|
||||
|
||||
After seeding batter/sp/rp, the table should have exactly those three
|
||||
rows (no other tracks are inserted by other test modules).
|
||||
"""
|
||||
resp = client.get("/api/v2/evolution/tracks", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 3
|
||||
assert len(data["items"]) == 3
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_filter_by_card_type(client, seeded_tracks):
|
||||
"""card_type=sp filter returns exactly 1 track with card_type 'sp'."""
|
||||
resp = client.get("/api/v2/evolution/tracks?card_type=sp", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["count"] == 1
|
||||
assert data["items"][0]["card_type"] == "sp"
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_get_single_track_with_thresholds(client, seeded_tracks):
|
||||
"""GET /tracks/{id} returns a track dict with formula and t1-t4 thresholds."""
|
||||
track_id = seeded_tracks[0] # batter
|
||||
resp = client.get(f"/api/v2/evolution/tracks/{track_id}", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["card_type"] == "batter"
|
||||
assert data["formula"] == "pa+tb*2"
|
||||
for key in ("t1_threshold", "t2_threshold", "t3_threshold", "t4_threshold"):
|
||||
assert key in data, f"Missing field: {key}"
|
||||
assert data["t1_threshold"] == 37
|
||||
assert data["t4_threshold"] == 896
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_404_for_nonexistent_track(client, seeded_tracks):
|
||||
"""GET /tracks/999999 returns 404 when the track does not exist."""
|
||||
resp = client.get("/api/v2/evolution/tracks/999999", headers=AUTH_HEADER)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@_skip_no_pg
|
||||
def test_auth_required(client, seeded_tracks):
|
||||
"""Requests without a Bearer token return 401 for both endpoints."""
|
||||
resp_list = client.get("/api/v2/evolution/tracks")
|
||||
assert resp_list.status_code == 401
|
||||
|
||||
track_id = seeded_tracks[0]
|
||||
resp_single = client.get(f"/api/v2/evolution/tracks/{track_id}")
|
||||
assert resp_single.status_code == 401
|
||||
206
tests/test_formula_engine.py
Normal file
206
tests/test_formula_engine.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Tests for the formula engine (WP-09).
|
||||
|
||||
Unit tests only — no database required. Stats inputs are simple namespace
|
||||
objects whose attributes match what BattingSeasonStats/PitchingSeasonStats expose.
|
||||
|
||||
Tier thresholds used (from evolution_tracks.json seed data):
|
||||
Batter: t1=37, t2=149, t3=448, t4=896
|
||||
SP: t1=10, t2=40, t3=120, t4=240
|
||||
RP: t1=3, t2=12, t3=35, t4=70
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.formula_engine import (
|
||||
compute_batter_value,
|
||||
compute_rp_value,
|
||||
compute_sp_value,
|
||||
compute_value_for_track,
|
||||
tier_from_value,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def batter_stats(**kwargs):
|
||||
"""Build a minimal batter stats object with all fields defaulting to 0."""
|
||||
defaults = {"pa": 0, "hits": 0, "doubles": 0, "triples": 0, "hr": 0}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def pitcher_stats(**kwargs):
|
||||
"""Build a minimal pitcher stats object with all fields defaulting to 0."""
|
||||
defaults = {"outs": 0, "strikeouts": 0}
|
||||
defaults.update(kwargs)
|
||||
return SimpleNamespace(**defaults)
|
||||
|
||||
|
||||
def track_dict(card_type: str) -> dict:
|
||||
"""Return the locked threshold dict for a given card_type."""
|
||||
return {
|
||||
"batter": {
|
||||
"card_type": "batter",
|
||||
"t1_threshold": 37,
|
||||
"t2_threshold": 149,
|
||||
"t3_threshold": 448,
|
||||
"t4_threshold": 896,
|
||||
},
|
||||
"sp": {
|
||||
"card_type": "sp",
|
||||
"t1_threshold": 10,
|
||||
"t2_threshold": 40,
|
||||
"t3_threshold": 120,
|
||||
"t4_threshold": 240,
|
||||
},
|
||||
"rp": {
|
||||
"card_type": "rp",
|
||||
"t1_threshold": 3,
|
||||
"t2_threshold": 12,
|
||||
"t3_threshold": 35,
|
||||
"t4_threshold": 70,
|
||||
},
|
||||
}[card_type]
|
||||
|
||||
|
||||
def track_ns(card_type: str):
|
||||
"""Return a namespace (attribute-style) track for a given card_type."""
|
||||
return SimpleNamespace(**track_dict(card_type))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_batter_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batter_formula_single_and_double():
|
||||
"""4 PA, 1 single, 1 double: PA=4, TB=1+2=3, value = 4 + 3×2 = 10."""
|
||||
stats = batter_stats(pa=4, hits=2, doubles=1)
|
||||
assert compute_batter_value(stats) == 10.0
|
||||
|
||||
|
||||
def test_batter_formula_no_hits():
|
||||
"""4 PA, 0 hits: TB=0, value = 4 + 0 = 4."""
|
||||
stats = batter_stats(pa=4)
|
||||
assert compute_batter_value(stats) == 4.0
|
||||
|
||||
|
||||
def test_batter_formula_hr_heavy():
|
||||
"""4 PA, 2 HR: TB = 0 singles + 4×2 = 8, value = 4 + 8×2 = 20."""
|
||||
stats = batter_stats(pa=4, hits=2, hr=2)
|
||||
assert compute_batter_value(stats) == 20.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_sp_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sp_formula_standard():
|
||||
"""18 outs + 5 K: IP = 18/3 = 6.0, value = 6.0 + 5 = 11.0."""
|
||||
stats = pitcher_stats(outs=18, strikeouts=5)
|
||||
assert compute_sp_value(stats) == 11.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_rp_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rp_formula_standard():
|
||||
"""3 outs + 2 K: IP = 3/3 = 1.0, value = 1.0 + 2 = 3.0."""
|
||||
stats = pitcher_stats(outs=3, strikeouts=2)
|
||||
assert compute_rp_value(stats) == 3.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zero stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batter_zero_stats_returns_zero():
|
||||
"""All-zero batter stats must return 0.0."""
|
||||
assert compute_batter_value(batter_stats()) == 0.0
|
||||
|
||||
|
||||
def test_sp_zero_stats_returns_zero():
|
||||
"""All-zero SP stats must return 0.0."""
|
||||
assert compute_sp_value(pitcher_stats()) == 0.0
|
||||
|
||||
|
||||
def test_rp_zero_stats_returns_zero():
|
||||
"""All-zero RP stats must return 0.0."""
|
||||
assert compute_rp_value(pitcher_stats()) == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formula dispatch by track name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dispatch_batter():
|
||||
"""compute_value_for_track('batter', ...) delegates to compute_batter_value."""
|
||||
stats = batter_stats(pa=4, hits=2, doubles=1)
|
||||
assert compute_value_for_track("batter", stats) == compute_batter_value(stats)
|
||||
|
||||
|
||||
def test_dispatch_sp():
|
||||
"""compute_value_for_track('sp', ...) delegates to compute_sp_value."""
|
||||
stats = pitcher_stats(outs=18, strikeouts=5)
|
||||
assert compute_value_for_track("sp", stats) == compute_sp_value(stats)
|
||||
|
||||
|
||||
def test_dispatch_rp():
|
||||
"""compute_value_for_track('rp', ...) delegates to compute_rp_value."""
|
||||
stats = pitcher_stats(outs=3, strikeouts=2)
|
||||
assert compute_value_for_track("rp", stats) == compute_rp_value(stats)
|
||||
|
||||
|
||||
def test_dispatch_unknown_raises():
|
||||
"""An unrecognised card_type must raise ValueError."""
|
||||
with pytest.raises(ValueError, match="Unknown card_type"):
|
||||
compute_value_for_track("dh", batter_stats())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# tier_from_value — batter thresholds (t1=37, t2=149, t3=448, t4=896)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tier_exact_t1_boundary():
|
||||
"""value=37 is exactly t1 for batter → T1."""
|
||||
assert tier_from_value(37, track_dict("batter")) == 1
|
||||
|
||||
|
||||
def test_tier_just_below_t1():
|
||||
"""value=36 is just below t1=37 for batter → T0."""
|
||||
assert tier_from_value(36, track_dict("batter")) == 0
|
||||
|
||||
|
||||
def test_tier_t4_boundary():
|
||||
"""value=896 is exactly t4 for batter → T4."""
|
||||
assert tier_from_value(896, track_dict("batter")) == 4
|
||||
|
||||
|
||||
def test_tier_above_t4():
|
||||
"""value above t4 still returns T4 (fully evolved)."""
|
||||
assert tier_from_value(1000, track_dict("batter")) == 4
|
||||
|
||||
|
||||
def test_tier_t2_boundary():
|
||||
"""value=149 is exactly t2 for batter → T2."""
|
||||
assert tier_from_value(149, track_dict("batter")) == 2
|
||||
|
||||
|
||||
def test_tier_t3_boundary():
|
||||
"""value=448 is exactly t3 for batter → T3."""
|
||||
assert tier_from_value(448, track_dict("batter")) == 3
|
||||
|
||||
|
||||
def test_tier_accepts_namespace_track():
|
||||
"""tier_from_value must work with attribute-style track objects (Peewee models)."""
|
||||
assert tier_from_value(37, track_ns("batter")) == 1
|
||||
451
tests/test_season_stats_model.py
Normal file
451
tests/test_season_stats_model.py
Normal file
@ -0,0 +1,451 @@
|
||||
"""Tests for BattingSeasonStats and PitchingSeasonStats Peewee models.
|
||||
|
||||
Unit tests verify model structure and defaults on unsaved instances without
|
||||
touching a database. Integration tests use an in-memory SQLite database to
|
||||
verify table creation, unique constraints, indexes, and the delta-update
|
||||
(increment) pattern.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from peewee import SqliteDatabase, IntegrityError
|
||||
|
||||
from app.models.season_stats import BattingSeasonStats, PitchingSeasonStats
|
||||
from app.db_engine import Rarity, Event, Cardset, MlbPlayer, Player, Team, StratGame
|
||||
|
||||
# Dependency order matters for FK resolution.
|
||||
_TEST_MODELS = [
|
||||
Rarity,
|
||||
Event,
|
||||
Cardset,
|
||||
MlbPlayer,
|
||||
Player,
|
||||
Team,
|
||||
StratGame,
|
||||
BattingSeasonStats,
|
||||
PitchingSeasonStats,
|
||||
]
|
||||
|
||||
_test_db = SqliteDatabase(":memory:", pragmas={"foreign_keys": 1})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_db():
|
||||
"""Bind all models to an in-memory SQLite database, create tables, and
|
||||
tear them down after each test so each test starts from a clean state."""
|
||||
_test_db.bind(_TEST_MODELS)
|
||||
_test_db.create_tables(_TEST_MODELS)
|
||||
yield _test_db
|
||||
_test_db.drop_tables(list(reversed(_TEST_MODELS)), safe=True)
|
||||
|
||||
|
||||
# ── Fixture helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_rarity():
|
||||
return Rarity.create(value=1, name="Common", color="#ffffff")
|
||||
|
||||
|
||||
def make_cardset():
|
||||
return Cardset.create(name="2025", description="2025 Season", total_cards=100)
|
||||
|
||||
|
||||
def make_player(cardset, rarity, player_id=1):
|
||||
return Player.create(
|
||||
player_id=player_id,
|
||||
p_name="Test Player",
|
||||
cost=100,
|
||||
image="test.png",
|
||||
mlbclub="BOS",
|
||||
franchise="Boston",
|
||||
cardset=cardset,
|
||||
set_num=1,
|
||||
rarity=rarity,
|
||||
pos_1="OF",
|
||||
description="Test",
|
||||
)
|
||||
|
||||
|
||||
def make_team(abbrev="TEST", gmid=123456789):
|
||||
return Team.create(
|
||||
abbrev=abbrev,
|
||||
sname=abbrev,
|
||||
lname=f"Team {abbrev}",
|
||||
gmid=gmid,
|
||||
gmname="testuser",
|
||||
gsheet="https://example.com",
|
||||
wallet=1000,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=1,
|
||||
)
|
||||
|
||||
|
||||
def make_game(home_team, away_team, season=10):
|
||||
return StratGame.create(
|
||||
season=season,
|
||||
game_type="ranked",
|
||||
away_team=away_team,
|
||||
home_team=home_team,
|
||||
)
|
||||
|
||||
|
||||
def make_batting_stats(player, team, season=10, **kwargs):
|
||||
return BattingSeasonStats.create(player=player, team=team, season=season, **kwargs)
|
||||
|
||||
|
||||
def make_pitching_stats(player, team, season=10, **kwargs):
|
||||
return PitchingSeasonStats.create(player=player, team=team, season=season, **kwargs)
|
||||
|
||||
|
||||
# ── Shared column-list constants ─────────────────────────────────────────────
|
||||
|
||||
_BATTING_STAT_COLS = [
|
||||
"games",
|
||||
"pa",
|
||||
"ab",
|
||||
"hits",
|
||||
"doubles",
|
||||
"triples",
|
||||
"hr",
|
||||
"rbi",
|
||||
"runs",
|
||||
"bb",
|
||||
"strikeouts",
|
||||
"hbp",
|
||||
"sac",
|
||||
"ibb",
|
||||
"gidp",
|
||||
"sb",
|
||||
"cs",
|
||||
]
|
||||
|
||||
_PITCHING_STAT_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",
|
||||
]
|
||||
|
||||
_KEY_COLS = ["player", "team", "season"]
|
||||
_META_COLS = ["last_game", "last_updated_at"]
|
||||
|
||||
|
||||
# ── Shared index helper ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_index_columns(db_conn, table: str) -> set:
|
||||
"""Return a set of frozensets, each being the column set of one index."""
|
||||
indexes = db_conn.execute_sql(f"PRAGMA index_list({table})").fetchall()
|
||||
result = set()
|
||||
for idx in indexes:
|
||||
idx_name = idx[1]
|
||||
cols = db_conn.execute_sql(f"PRAGMA index_info({idx_name})").fetchall()
|
||||
result.add(frozenset(col[2] for col in cols))
|
||||
return result
|
||||
|
||||
|
||||
# ── Unit: column completeness ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingColumnCompleteness:
|
||||
"""All required columns are present in BattingSeasonStats."""
|
||||
|
||||
EXPECTED_COLS = _BATTING_STAT_COLS
|
||||
KEY_COLS = _KEY_COLS
|
||||
META_COLS = _META_COLS
|
||||
|
||||
def test_stat_columns_present(self):
|
||||
"""All batting aggregate columns are present."""
|
||||
fields = BattingSeasonStats._meta.fields
|
||||
for col in self.EXPECTED_COLS:
|
||||
assert col in fields, f"Missing batting column: {col}"
|
||||
|
||||
def test_key_columns_present(self):
|
||||
"""player, team, and season columns are present."""
|
||||
fields = BattingSeasonStats._meta.fields
|
||||
for col in self.KEY_COLS:
|
||||
assert col in fields, f"Missing key column: {col}"
|
||||
|
||||
def test_meta_columns_present(self):
|
||||
"""Meta columns last_game and last_updated_at are present."""
|
||||
fields = BattingSeasonStats._meta.fields
|
||||
for col in self.META_COLS:
|
||||
assert col in fields, f"Missing meta column: {col}"
|
||||
|
||||
|
||||
class TestPitchingColumnCompleteness:
|
||||
"""All required columns are present in PitchingSeasonStats."""
|
||||
|
||||
EXPECTED_COLS = _PITCHING_STAT_COLS
|
||||
KEY_COLS = _KEY_COLS
|
||||
META_COLS = _META_COLS
|
||||
|
||||
def test_stat_columns_present(self):
|
||||
"""All pitching aggregate columns are present."""
|
||||
fields = PitchingSeasonStats._meta.fields
|
||||
for col in self.EXPECTED_COLS:
|
||||
assert col in fields, f"Missing pitching column: {col}"
|
||||
|
||||
def test_key_columns_present(self):
|
||||
"""player, team, and season columns are present."""
|
||||
fields = PitchingSeasonStats._meta.fields
|
||||
for col in self.KEY_COLS:
|
||||
assert col in fields, f"Missing key column: {col}"
|
||||
|
||||
def test_meta_columns_present(self):
|
||||
"""Meta columns last_game and last_updated_at are present."""
|
||||
fields = PitchingSeasonStats._meta.fields
|
||||
for col in self.META_COLS:
|
||||
assert col in fields, f"Missing meta column: {col}"
|
||||
|
||||
|
||||
# ── Unit: default values ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingDefaultValues:
|
||||
"""All integer stat columns default to 0; nullable meta fields default to None."""
|
||||
|
||||
INT_STAT_COLS = _BATTING_STAT_COLS
|
||||
|
||||
def test_all_int_columns_default_to_zero(self):
|
||||
"""Every integer stat column defaults to 0 on an unsaved instance."""
|
||||
row = BattingSeasonStats()
|
||||
for col in self.INT_STAT_COLS:
|
||||
val = getattr(row, col)
|
||||
assert val == 0, f"Column {col!r} default is {val!r}, expected 0"
|
||||
|
||||
def test_last_game_defaults_to_none(self):
|
||||
"""last_game FK is nullable and defaults to None."""
|
||||
row = BattingSeasonStats()
|
||||
assert row.last_game_id is None
|
||||
|
||||
def test_last_updated_at_defaults_to_none(self):
|
||||
"""last_updated_at defaults to None."""
|
||||
row = BattingSeasonStats()
|
||||
assert row.last_updated_at is None
|
||||
|
||||
|
||||
class TestPitchingDefaultValues:
|
||||
"""All integer stat columns default to 0; nullable meta fields default to None."""
|
||||
|
||||
INT_STAT_COLS = _PITCHING_STAT_COLS
|
||||
|
||||
def test_all_int_columns_default_to_zero(self):
|
||||
"""Every integer stat column defaults to 0 on an unsaved instance."""
|
||||
row = PitchingSeasonStats()
|
||||
for col in self.INT_STAT_COLS:
|
||||
val = getattr(row, col)
|
||||
assert val == 0, f"Column {col!r} default is {val!r}, expected 0"
|
||||
|
||||
def test_last_game_defaults_to_none(self):
|
||||
"""last_game FK is nullable and defaults to None."""
|
||||
row = PitchingSeasonStats()
|
||||
assert row.last_game_id is None
|
||||
|
||||
def test_last_updated_at_defaults_to_none(self):
|
||||
"""last_updated_at defaults to None."""
|
||||
row = PitchingSeasonStats()
|
||||
assert row.last_updated_at is None
|
||||
|
||||
|
||||
# ── Integration: unique constraint ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingUniqueConstraint:
|
||||
"""UNIQUE on (player_id, team_id, season) is enforced at the DB level."""
|
||||
|
||||
def test_duplicate_raises(self):
|
||||
"""Inserting a second row for the same (player, team, season) raises IntegrityError."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_batting_stats(player, team, season=10)
|
||||
with pytest.raises(IntegrityError):
|
||||
make_batting_stats(player, team, season=10)
|
||||
|
||||
def test_different_season_allowed(self):
|
||||
"""Same (player, team) in a different season creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_batting_stats(player, team, season=10)
|
||||
row2 = make_batting_stats(player, team, season=11)
|
||||
assert row2.id is not None
|
||||
|
||||
def test_different_team_allowed(self):
|
||||
"""Same (player, season) on a different team creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team1 = make_team("TM1", gmid=111)
|
||||
team2 = make_team("TM2", gmid=222)
|
||||
make_batting_stats(player, team1, season=10)
|
||||
row2 = make_batting_stats(player, team2, season=10)
|
||||
assert row2.id is not None
|
||||
|
||||
|
||||
class TestPitchingUniqueConstraint:
|
||||
"""UNIQUE on (player_id, team_id, season) is enforced at the DB level."""
|
||||
|
||||
def test_duplicate_raises(self):
|
||||
"""Inserting a second row for the same (player, team, season) raises IntegrityError."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_pitching_stats(player, team, season=10)
|
||||
with pytest.raises(IntegrityError):
|
||||
make_pitching_stats(player, team, season=10)
|
||||
|
||||
def test_different_season_allowed(self):
|
||||
"""Same (player, team) in a different season creates a separate row."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
make_pitching_stats(player, team, season=10)
|
||||
row2 = make_pitching_stats(player, team, season=11)
|
||||
assert row2.id is not None
|
||||
|
||||
|
||||
# ── Integration: delta update pattern ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingDeltaUpdate:
|
||||
"""Batting stats can be incremented (delta update) without replacing existing values."""
|
||||
|
||||
def test_increment_batting_stats(self):
|
||||
"""Updating pa and hits increments correctly."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_batting_stats(player, team, season=10, pa=5, hits=2)
|
||||
|
||||
BattingSeasonStats.update(
|
||||
pa=BattingSeasonStats.pa + 3,
|
||||
hits=BattingSeasonStats.hits + 1,
|
||||
).where(
|
||||
(BattingSeasonStats.player == player)
|
||||
& (BattingSeasonStats.team == team)
|
||||
& (BattingSeasonStats.season == 10)
|
||||
).execute()
|
||||
|
||||
updated = BattingSeasonStats.get_by_id(row.id)
|
||||
assert updated.pa == 8
|
||||
assert updated.hits == 3
|
||||
|
||||
def test_last_game_fk_is_nullable(self):
|
||||
"""last_game FK can be set to a StratGame instance or left NULL."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_batting_stats(player, team, season=10)
|
||||
assert row.last_game_id is None
|
||||
|
||||
game = make_game(home_team=team, away_team=team)
|
||||
BattingSeasonStats.update(last_game=game).where(
|
||||
BattingSeasonStats.id == row.id
|
||||
).execute()
|
||||
|
||||
updated = BattingSeasonStats.get_by_id(row.id)
|
||||
assert updated.last_game_id == game.id
|
||||
|
||||
|
||||
class TestPitchingDeltaUpdate:
|
||||
"""Pitching stats can be incremented (delta update) without replacing existing values."""
|
||||
|
||||
def test_increment_pitching_stats(self):
|
||||
"""Updating outs and strikeouts increments correctly."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_pitching_stats(player, team, season=10, outs=9, strikeouts=3)
|
||||
|
||||
PitchingSeasonStats.update(
|
||||
outs=PitchingSeasonStats.outs + 6,
|
||||
strikeouts=PitchingSeasonStats.strikeouts + 2,
|
||||
).where(
|
||||
(PitchingSeasonStats.player == player)
|
||||
& (PitchingSeasonStats.team == team)
|
||||
& (PitchingSeasonStats.season == 10)
|
||||
).execute()
|
||||
|
||||
updated = PitchingSeasonStats.get_by_id(row.id)
|
||||
assert updated.outs == 15
|
||||
assert updated.strikeouts == 5
|
||||
|
||||
def test_last_game_fk_is_nullable(self):
|
||||
"""last_game FK can be set to a StratGame instance or left NULL."""
|
||||
rarity = make_rarity()
|
||||
cardset = make_cardset()
|
||||
player = make_player(cardset, rarity)
|
||||
team = make_team()
|
||||
row = make_pitching_stats(player, team, season=10)
|
||||
assert row.last_game_id is None
|
||||
|
||||
game = make_game(home_team=team, away_team=team)
|
||||
PitchingSeasonStats.update(last_game=game).where(
|
||||
PitchingSeasonStats.id == row.id
|
||||
).execute()
|
||||
|
||||
updated = PitchingSeasonStats.get_by_id(row.id)
|
||||
assert updated.last_game_id == game.id
|
||||
|
||||
|
||||
# ── Integration: index existence ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBattingIndexExistence:
|
||||
"""Required indexes exist on batting_season_stats."""
|
||||
|
||||
def test_unique_index_on_player_team_season(self, setup_test_db):
|
||||
"""A unique index covering (player_id, team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "batting_season_stats")
|
||||
assert frozenset({"player_id", "team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_team_season(self, setup_test_db):
|
||||
"""An index covering (team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "batting_season_stats")
|
||||
assert frozenset({"team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_player_season(self, setup_test_db):
|
||||
"""An index covering (player_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "batting_season_stats")
|
||||
assert frozenset({"player_id", "season"}) in index_sets
|
||||
|
||||
|
||||
class TestPitchingIndexExistence:
|
||||
"""Required indexes exist on pitching_season_stats."""
|
||||
|
||||
def test_unique_index_on_player_team_season(self, setup_test_db):
|
||||
"""A unique index covering (player_id, team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "pitching_season_stats")
|
||||
assert frozenset({"player_id", "team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_team_season(self, setup_test_db):
|
||||
"""An index covering (team_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "pitching_season_stats")
|
||||
assert frozenset({"team_id", "season"}) in index_sets
|
||||
|
||||
def test_index_on_player_season(self, setup_test_db):
|
||||
"""An index covering (player_id, season) exists."""
|
||||
index_sets = _get_index_columns(setup_test_db, "pitching_season_stats")
|
||||
assert frozenset({"player_id", "season"}) in index_sets
|
||||
661
tests/test_season_stats_update.py
Normal file
661
tests/test_season_stats_update.py
Normal file
@ -0,0 +1,661 @@
|
||||
"""
|
||||
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 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
|
||||
would silently produce wrong stats that would then corrupt every
|
||||
evolution tier calculation downstream.
|
||||
|
||||
Test data is created using real Peewee models (no mocking) against the
|
||||
in-memory SQLite database provided by the autouse setup_test_db fixture
|
||||
in conftest.py. All Player and Team creation uses the actual required
|
||||
column set discovered from the model definition in db_engine.py.
|
||||
"""
|
||||
|
||||
import app.services.season_stats as _season_stats_module
|
||||
import pytest
|
||||
|
||||
from app.db_engine import (
|
||||
BattingSeasonStats,
|
||||
Cardset,
|
||||
Decision,
|
||||
PitchingSeasonStats,
|
||||
Player,
|
||||
Rarity,
|
||||
StratGame,
|
||||
StratPlay,
|
||||
Team,
|
||||
)
|
||||
from app.services.season_stats import update_season_stats
|
||||
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
|
||||
# `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
|
||||
# update_season_stats() operates on the same in-memory connection that the
|
||||
# test fixtures write to.
|
||||
_season_stats_module.db = _test_db
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_cardset():
|
||||
"""Return a reusable Cardset row (or fetch the existing one by name)."""
|
||||
cs, _ = Cardset.get_or_create(
|
||||
name="Test Set",
|
||||
defaults={"description": "Test cardset", "total_cards": 100},
|
||||
)
|
||||
return cs
|
||||
|
||||
|
||||
def _make_rarity():
|
||||
"""Return the Common rarity singleton."""
|
||||
r, _ = Rarity.get_or_create(value=1, name="Common", defaults={"color": "#ffffff"})
|
||||
return r
|
||||
|
||||
|
||||
def _make_player(name: str, pos: str = "1B") -> Player:
|
||||
"""Create a Player row with all required (non-nullable) columns satisfied.
|
||||
|
||||
Why we need this helper: Player has many non-nullable varchar columns
|
||||
(image, mlbclub, franchise, description) and a required FK to Cardset.
|
||||
A single helper keeps test fixtures concise and consistent.
|
||||
"""
|
||||
return Player.create(
|
||||
p_name=name,
|
||||
rarity=_make_rarity(),
|
||||
cardset=_make_cardset(),
|
||||
set_num=1,
|
||||
pos_1=pos,
|
||||
image="https://example.com/image.png",
|
||||
mlbclub="TST",
|
||||
franchise="TST",
|
||||
description=f"Test player: {name}",
|
||||
)
|
||||
|
||||
|
||||
def _make_team(abbrev: str, gmid: int, season: int = 11) -> Team:
|
||||
"""Create a Team row with all required (non-nullable) columns satisfied."""
|
||||
return Team.create(
|
||||
abbrev=abbrev,
|
||||
sname=abbrev,
|
||||
lname=f"Team {abbrev}",
|
||||
gmid=gmid,
|
||||
gmname=f"gm_{abbrev.lower()}",
|
||||
gsheet="https://docs.google.com/spreadsheets/test",
|
||||
wallet=500,
|
||||
team_value=1000,
|
||||
collection_value=1000,
|
||||
season=season,
|
||||
is_ai=False,
|
||||
)
|
||||
|
||||
|
||||
def make_play(game, play_num, batter, batter_team, pitcher, pitcher_team, **stats):
|
||||
"""Create a StratPlay row with sensible defaults for all required fields.
|
||||
|
||||
Why we provide defaults for every stat column: StratPlay has many
|
||||
IntegerField columns with default=0 at the model level, but supplying
|
||||
them explicitly makes it clear what the baseline state of each play is
|
||||
and keeps the helper signature stable if defaults change.
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_a():
|
||||
return _make_team("TMA", gmid=1001)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_b():
|
||||
return _make_team("TMB", gmid=1002)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_batter():
|
||||
"""A batter-type player for team A."""
|
||||
return _make_player("Batter One", pos="CF")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def player_pitcher():
|
||||
"""A pitcher-type player for team B."""
|
||||
return _make_player("Pitcher One", pos="SP")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(team_a, team_b):
|
||||
return StratGame.create(
|
||||
season=11,
|
||||
game_type="ranked",
|
||||
away_team=team_a,
|
||||
home_team=team_b,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_game_batting_stats(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""Batting stat totals from StratPlay rows are correctly accumulated.
|
||||
|
||||
What: Create three plate appearances (2 hits, 1 strikeout, a walk, and a
|
||||
home run) for one batter. After update_season_stats(), the
|
||||
PlayerSeasonStats row should reflect the exact sum of all play fields.
|
||||
|
||||
Why: The core of the batting aggregation pipeline. If any field mapping
|
||||
is wrong (e.g. 'hit' mapped to 'doubles' instead of 'hits'), evolution
|
||||
scoring and leaderboards would silently report incorrect stats.
|
||||
"""
|
||||
# PA 1: single (hit=1, ab=1, pa=1)
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
)
|
||||
# PA 2: home run (hit=1, homerun=1, ab=1, pa=1, rbi=1, run=1)
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
homerun=1,
|
||||
rbi=1,
|
||||
run=1,
|
||||
outs=0,
|
||||
)
|
||||
# PA 3: strikeout (ab=1, pa=1, so=1, outs=1)
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
)
|
||||
# PA 4: walk (pa=1, bb=1)
|
||||
make_play(
|
||||
game,
|
||||
4,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
bb=1,
|
||||
outs=0,
|
||||
)
|
||||
|
||||
result = update_season_stats(game.id)
|
||||
|
||||
assert result["batters_updated"] >= 1
|
||||
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.strikeouts == 1
|
||||
assert stats.bb == 1
|
||||
assert stats.rbi == 1
|
||||
assert stats.runs == 1
|
||||
assert stats.games == 1
|
||||
|
||||
|
||||
def test_single_game_pitching_stats(
|
||||
team_a, team_b, player_batter, player_pitcher, game
|
||||
):
|
||||
"""Pitching stat totals (outs, k, hits_allowed, bb_allowed) are correct.
|
||||
|
||||
What: The same plays that create batting stats for the batter are also
|
||||
the source for the pitcher's opposing stats. This test checks that
|
||||
_build_pitching_groups() correctly inverts batter-perspective fields.
|
||||
|
||||
Why: The batter's 'so' becomes the pitcher's 'k', the batter's 'hit'
|
||||
becomes 'hits_allowed', etc. Any transposition in this mapping would
|
||||
corrupt pitcher stats silently.
|
||||
"""
|
||||
# Play 1: strikeout — batter so=1, outs=1
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
)
|
||||
# Play 2: single — batter hit=1
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
)
|
||||
# Play 3: walk — batter bb=1
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
bb=1,
|
||||
outs=0,
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
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.strikeouts == 1 # batter's so → pitcher's strikeouts
|
||||
assert stats.hits_allowed == 1 # batter's hit → pitcher hits_allowed
|
||||
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):
|
||||
"""Decision.win=1 for a pitcher results in wins=1 in PlayerSeasonStats.
|
||||
|
||||
What: Add a single StratPlay to establish the pitcher in pitching_groups,
|
||||
then create a Decision row recording a win. Call update_season_stats()
|
||||
and verify the wins column is 1.
|
||||
|
||||
Why: Decisions are stored in a separate table from StratPlay. If
|
||||
_apply_decisions() fails to merge them (wrong FK lookup, key mismatch),
|
||||
pitchers would always show 0 wins/losses/saves regardless of actual game
|
||||
outcomes, breaking standings and evolution criteria.
|
||||
"""
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
Decision.create(
|
||||
season=11,
|
||||
game=game,
|
||||
pitcher=player_pitcher,
|
||||
pitcher_team=team_b,
|
||||
win=1,
|
||||
loss=0,
|
||||
is_save=0,
|
||||
hold=0,
|
||||
b_save=0,
|
||||
is_start=True,
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
stats = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == player_pitcher,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
PitchingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.wins == 1
|
||||
assert stats.losses == 0
|
||||
|
||||
|
||||
def test_double_count_prevention(team_a, team_b, player_batter, player_pitcher, game):
|
||||
"""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 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 ProcessedGame ledger
|
||||
provides full idempotency for all replay scenarios.
|
||||
"""
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
first_result = update_season_stats(game.id)
|
||||
assert "skipped" not in first_result
|
||||
|
||||
second_result = update_season_stats(game.id)
|
||||
assert second_result.get("skipped") is True
|
||||
assert second_result["batters_updated"] == 0
|
||||
assert second_result["pitchers_updated"] == 0
|
||||
|
||||
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 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.
|
||||
|
||||
Why: PlayerSeasonStats is a season-long accumulator, not a per-game
|
||||
snapshot. If the upsert logic overwrites instead of increments, a player's
|
||||
stats would always reflect only their most recent game.
|
||||
"""
|
||||
game1 = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
game2 = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
|
||||
# Game 1: 2 plate appearances
|
||||
for i in range(2):
|
||||
make_play(
|
||||
game1,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
# Game 2: 3 plate appearances
|
||||
for i in range(3):
|
||||
make_play(
|
||||
game2,
|
||||
i + 1,
|
||||
player_batter,
|
||||
team_a,
|
||||
player_pitcher,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
outs=1,
|
||||
)
|
||||
|
||||
update_season_stats(game1.id)
|
||||
update_season_stats(game2.id)
|
||||
|
||||
stats = BattingSeasonStats.get(
|
||||
BattingSeasonStats.player == player_batter,
|
||||
BattingSeasonStats.team == team_a,
|
||||
BattingSeasonStats.season == 11,
|
||||
)
|
||||
assert stats.pa == 5
|
||||
assert stats.games == 2
|
||||
|
||||
|
||||
def test_two_team_game(team_a, team_b):
|
||||
"""Players from both teams in a game each get their own stats row.
|
||||
|
||||
What: Create a batter+pitcher pair for team A and another pair for team B.
|
||||
In the same game, team A bats against team B's pitcher and vice versa.
|
||||
After update_season_stats(), both batters and both pitchers must have
|
||||
correct, isolated stats rows.
|
||||
|
||||
Why: A key correctness guarantee is that stats are attributed to the
|
||||
correct (player, team) combination. If team attribution is wrong,
|
||||
a player's stats could appear under the wrong franchise or be merged
|
||||
with an opponent's row.
|
||||
"""
|
||||
batter_a = _make_player("Batter A", pos="CF")
|
||||
pitcher_a = _make_player("Pitcher A", pos="SP")
|
||||
batter_b = _make_player("Batter B", pos="CF")
|
||||
pitcher_b = _make_player("Pitcher B", pos="SP")
|
||||
|
||||
game = StratGame.create(
|
||||
season=11, game_type="ranked", away_team=team_a, home_team=team_b
|
||||
)
|
||||
|
||||
# Team A bats against team B's pitcher (away half)
|
||||
make_play(
|
||||
game,
|
||||
1,
|
||||
batter_a,
|
||||
team_a,
|
||||
pitcher_b,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
hit=1,
|
||||
outs=0,
|
||||
inning_half="top",
|
||||
)
|
||||
make_play(
|
||||
game,
|
||||
2,
|
||||
batter_a,
|
||||
team_a,
|
||||
pitcher_b,
|
||||
team_b,
|
||||
pa=1,
|
||||
ab=1,
|
||||
so=1,
|
||||
outs=1,
|
||||
inning_half="top",
|
||||
)
|
||||
|
||||
# Team B bats against team A's pitcher (home half)
|
||||
make_play(
|
||||
game,
|
||||
3,
|
||||
batter_b,
|
||||
team_b,
|
||||
pitcher_a,
|
||||
team_a,
|
||||
pa=1,
|
||||
ab=1,
|
||||
bb=1,
|
||||
outs=0,
|
||||
inning_half="bottom",
|
||||
)
|
||||
|
||||
update_season_stats(game.id)
|
||||
|
||||
# Team A's batter: 2 PA, 1 hit, 1 SO
|
||||
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.strikeouts == 1
|
||||
|
||||
# Team B's batter: 1 PA, 1 BB
|
||||
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 strikeout
|
||||
stats_pb = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == pitcher_b,
|
||||
PitchingSeasonStats.team == team_b,
|
||||
)
|
||||
assert stats_pb.hits_allowed == 1
|
||||
assert stats_pb.strikeouts == 1
|
||||
|
||||
# Team A's pitcher (faced team B's batter): 1 BB allowed
|
||||
stats_pa = PitchingSeasonStats.get(
|
||||
PitchingSeasonStats.player == pitcher_a,
|
||||
PitchingSeasonStats.team == team_a,
|
||||
)
|
||||
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