Merge pull request 'Card Evolution Phase 1a: Schema & Data Foundation' (#104) from feature/card-evolution-phase1a into card-evolution
Reviewed-on: #104 Reviewed-by: Claude <cal.corum+openclaw@gmail.com>
This commit is contained in:
commit
f12aa858c1
149
app/db_engine.py
149
app/db_engine.py
@ -1194,6 +1194,155 @@ if not SKIP_TABLE_CREATION:
|
|||||||
db.create_tables([ScoutOpportunity, ScoutClaim], safe=True)
|
db.create_tables([ScoutOpportunity, ScoutClaim], safe=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerSeasonStats(BaseModel):
|
||||||
|
player = ForeignKeyField(Player)
|
||||||
|
team = ForeignKeyField(Team)
|
||||||
|
season = IntegerField()
|
||||||
|
|
||||||
|
# Batting stats
|
||||||
|
games_batting = IntegerField(default=0)
|
||||||
|
pa = IntegerField(default=0)
|
||||||
|
ab = IntegerField(default=0)
|
||||||
|
hits = IntegerField(default=0)
|
||||||
|
doubles = IntegerField(default=0)
|
||||||
|
triples = IntegerField(default=0)
|
||||||
|
hr = IntegerField(default=0)
|
||||||
|
bb = IntegerField(default=0)
|
||||||
|
hbp = IntegerField(default=0)
|
||||||
|
so = IntegerField(default=0)
|
||||||
|
rbi = IntegerField(default=0)
|
||||||
|
runs = IntegerField(default=0)
|
||||||
|
sb = IntegerField(default=0)
|
||||||
|
cs = IntegerField(default=0)
|
||||||
|
|
||||||
|
# Pitching stats
|
||||||
|
games_pitching = IntegerField(default=0)
|
||||||
|
outs = IntegerField(default=0)
|
||||||
|
k = IntegerField(default=0)
|
||||||
|
bb_allowed = IntegerField(default=0)
|
||||||
|
hits_allowed = IntegerField(default=0)
|
||||||
|
hr_allowed = IntegerField(default=0)
|
||||||
|
wins = IntegerField(default=0)
|
||||||
|
losses = IntegerField(default=0)
|
||||||
|
saves = IntegerField(default=0)
|
||||||
|
holds = IntegerField(default=0)
|
||||||
|
blown_saves = IntegerField(default=0)
|
||||||
|
|
||||||
|
# Meta
|
||||||
|
last_game = ForeignKeyField(StratGame, null=True)
|
||||||
|
last_updated_at = DateTimeField(null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
database = db
|
||||||
|
table_name = "player_season_stats"
|
||||||
|
|
||||||
|
|
||||||
|
player_season_stats_unique_index = ModelIndex(
|
||||||
|
PlayerSeasonStats,
|
||||||
|
(PlayerSeasonStats.player, PlayerSeasonStats.team, PlayerSeasonStats.season),
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.add_index(player_season_stats_unique_index)
|
||||||
|
|
||||||
|
player_season_stats_team_season_index = ModelIndex(
|
||||||
|
PlayerSeasonStats,
|
||||||
|
(PlayerSeasonStats.team, PlayerSeasonStats.season),
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.add_index(player_season_stats_team_season_index)
|
||||||
|
|
||||||
|
player_season_stats_player_season_index = ModelIndex(
|
||||||
|
PlayerSeasonStats,
|
||||||
|
(PlayerSeasonStats.player, PlayerSeasonStats.season),
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
PlayerSeasonStats.add_index(player_season_stats_player_season_index)
|
||||||
|
|
||||||
|
|
||||||
|
if not SKIP_TABLE_CREATION:
|
||||||
|
db.create_tables([PlayerSeasonStats], safe=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EvolutionTrack(BaseModel):
|
||||||
|
name = CharField(unique=True)
|
||||||
|
card_type = CharField() # 'batter', 'sp', 'rp'
|
||||||
|
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()
|
db.close()
|
||||||
|
|
||||||
# scout_db = SqliteDatabase(
|
# scout_db = SqliteDatabase(
|
||||||
|
|||||||
@ -1,5 +1,29 @@
|
|||||||
[
|
[
|
||||||
{"name": "Batter", "card_type": "batter", "formula": "pa+tb*2", "t1": 37, "t2": 149, "t3": 448, "t4": 896},
|
{
|
||||||
{"name": "Starting Pitcher", "card_type": "sp", "formula": "ip+k", "t1": 10, "t2": 40, "t3": 120, "t4": 240},
|
"name": "Batter Track",
|
||||||
{"name": "Relief Pitcher", "card_type": "rp", "formula": "ip+k", "t1": 3, "t2": 12, "t3": 35, "t4": 70}
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,41 +1,66 @@
|
|||||||
"""Seed data fixture for EvolutionTrack.
|
"""Seed script for EvolutionTrack records.
|
||||||
|
|
||||||
Inserts the three universal evolution tracks (Batter, Starting Pitcher,
|
Loads track definitions from evolution_tracks.json and upserts them into the
|
||||||
Relief Pitcher) if they do not already exist. Safe to call multiple times
|
database using get_or_create keyed on name. Existing tracks have their
|
||||||
thanks to get_or_create — depends on WP-01 (EvolutionTrack model) to run.
|
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 json
|
||||||
import os
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_JSON_PATH = os.path.join(os.path.dirname(__file__), "evolution_tracks.json")
|
from app.db_engine import EvolutionTrack
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_JSON_PATH = Path(__file__).parent / "evolution_tracks.json"
|
||||||
|
|
||||||
|
|
||||||
def load_tracks():
|
def seed_evolution_tracks() -> list[EvolutionTrack]:
|
||||||
"""Return the locked list of evolution track dicts from the JSON fixture."""
|
"""Upsert evolution tracks from JSON seed data.
|
||||||
with open(_JSON_PATH) as fh:
|
|
||||||
return json.load(fh)
|
|
||||||
|
|
||||||
|
Returns a list of EvolutionTrack instances that were created or updated.
|
||||||
def seed(model_class=None):
|
|
||||||
"""Insert evolution tracks that are not yet in the database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_class: Peewee model with get_or_create support. Defaults to
|
|
||||||
``app.db_engine.EvolutionTrack`` (imported lazily so this module
|
|
||||||
can be imported before WP-01 lands).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of (instance, created) tuples from get_or_create.
|
|
||||||
"""
|
"""
|
||||||
if model_class is None:
|
raw = _JSON_PATH.read_text(encoding="utf-8")
|
||||||
from app.db_engine import EvolutionTrack as model_class # noqa: PLC0415
|
track_defs = json.loads(raw)
|
||||||
|
|
||||||
results = []
|
results: list[EvolutionTrack] = []
|
||||||
for track in load_tracks():
|
|
||||||
instance, created = model_class.get_or_create(
|
for defn in track_defs:
|
||||||
card_type=track["card_type"],
|
track, created = EvolutionTrack.get_or_create(
|
||||||
defaults=track,
|
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"],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
results.append((instance, created))
|
|
||||||
|
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
|
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))
|
||||||
|
|||||||
484
app/services/season_stats.py
Normal file
484
app/services/season_stats.py
Normal file
@ -0,0 +1,484 @@
|
|||||||
|
"""
|
||||||
|
season_stats.py — Incremental PlayerSeasonStats update logic.
|
||||||
|
|
||||||
|
Called once per completed StratGame to accumulate batting and pitching
|
||||||
|
statistics into the player_season_stats table.
|
||||||
|
|
||||||
|
Idempotency limitation: re-delivery of a game is detected by checking
|
||||||
|
whether any PlayerSeasonStats row still carries that game_id as last_game.
|
||||||
|
This guard only works if no later game has been processed for the same
|
||||||
|
players — if game G+1 is processed first, a re-delivery of game G will
|
||||||
|
bypass the guard and double-count stats. A persistent processed-game
|
||||||
|
ledger is needed for full idempotency across out-of-order re-delivery
|
||||||
|
(see issue #105).
|
||||||
|
|
||||||
|
Peewee upsert strategy:
|
||||||
|
- SQLite: on_conflict_replace() — simplest path, deletes + re-inserts
|
||||||
|
- PostgreSQL: on_conflict() with EXCLUDED — true atomic increment via SQL
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from peewee import EXCLUDED
|
||||||
|
|
||||||
|
from app.db_engine import (
|
||||||
|
db,
|
||||||
|
Decision,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
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_batting, 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.
|
||||||
|
"""
|
||||||
|
groups = defaultdict(
|
||||||
|
lambda: {
|
||||||
|
"games_batting": 0,
|
||||||
|
"pa": 0,
|
||||||
|
"ab": 0,
|
||||||
|
"hits": 0,
|
||||||
|
"doubles": 0,
|
||||||
|
"triples": 0,
|
||||||
|
"hr": 0,
|
||||||
|
"bb": 0,
|
||||||
|
"hbp": 0,
|
||||||
|
"so": 0,
|
||||||
|
"rbi": 0,
|
||||||
|
"runs": 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["bb"] += play.bb
|
||||||
|
g["hbp"] += play.hbp
|
||||||
|
g["so"] += play.so
|
||||||
|
g["rbi"] += play.rbi
|
||||||
|
g["runs"] += play.run
|
||||||
|
g["sb"] += play.sb
|
||||||
|
g["cs"] += play.cs
|
||||||
|
|
||||||
|
if play.pa > 0 and not g["appeared"]:
|
||||||
|
g["games_batting"] = 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 k)
|
||||||
|
- hit → hits allowed
|
||||||
|
- bb+hbp → base-on-balls allowed
|
||||||
|
- homerun → home runs allowed
|
||||||
|
|
||||||
|
games_pitching counts unique pitchers who appeared (at least one
|
||||||
|
play as pitcher), capped at 1 per game since this function processes
|
||||||
|
a single game.
|
||||||
|
|
||||||
|
Returns a dict keyed by (pitcher_id, pitcher_team_id) with stat dicts.
|
||||||
|
"""
|
||||||
|
groups = defaultdict(
|
||||||
|
lambda: {
|
||||||
|
"games_pitching": 1, # pitcher appeared in this game by definition
|
||||||
|
"outs": 0,
|
||||||
|
"k": 0,
|
||||||
|
"hits_allowed": 0,
|
||||||
|
"bb_allowed": 0,
|
||||||
|
"hr_allowed": 0,
|
||||||
|
# Decision stats added later
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"saves": 0,
|
||||||
|
"holds": 0,
|
||||||
|
"blown_saves": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for play in plays:
|
||||||
|
pitcher_id = play.pitcher_id
|
||||||
|
pitcher_team_id = play.pitcher_team_id
|
||||||
|
key = (pitcher_id, pitcher_team_id)
|
||||||
|
g = groups[key]
|
||||||
|
|
||||||
|
g["outs"] += play.outs
|
||||||
|
g["k"] += play.so
|
||||||
|
g["hits_allowed"] += play.hit
|
||||||
|
g["bb_allowed"] += play.bb + 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_pitching": 1,
|
||||||
|
"outs": 0,
|
||||||
|
"k": 0,
|
||||||
|
"hits_allowed": 0,
|
||||||
|
"bb_allowed": 0,
|
||||||
|
"hr_allowed": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"saves": 0,
|
||||||
|
"holds": 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
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_postgres(player_id, team_id, season, game_id, batting, pitching):
|
||||||
|
"""
|
||||||
|
PostgreSQL upsert using ON CONFLICT ... DO UPDATE with column-level
|
||||||
|
increments. Each stat column is incremented by the value from the
|
||||||
|
EXCLUDED (incoming) row, ensuring concurrent games don't overwrite
|
||||||
|
each other.
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"player_id": player_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"season": season,
|
||||||
|
"games_batting": batting.get("games_batting", 0),
|
||||||
|
"pa": batting.get("pa", 0),
|
||||||
|
"ab": batting.get("ab", 0),
|
||||||
|
"hits": batting.get("hits", 0),
|
||||||
|
"doubles": batting.get("doubles", 0),
|
||||||
|
"triples": batting.get("triples", 0),
|
||||||
|
"hr": batting.get("hr", 0),
|
||||||
|
"bb": batting.get("bb", 0),
|
||||||
|
"hbp": batting.get("hbp", 0),
|
||||||
|
"so": batting.get("so", 0),
|
||||||
|
"rbi": batting.get("rbi", 0),
|
||||||
|
"runs": batting.get("runs", 0),
|
||||||
|
"sb": batting.get("sb", 0),
|
||||||
|
"cs": batting.get("cs", 0),
|
||||||
|
"games_pitching": pitching.get("games_pitching", 0),
|
||||||
|
"outs": pitching.get("outs", 0),
|
||||||
|
"k": pitching.get("k", 0),
|
||||||
|
"hits_allowed": pitching.get("hits_allowed", 0),
|
||||||
|
"bb_allowed": pitching.get("bb_allowed", 0),
|
||||||
|
"hr_allowed": pitching.get("hr_allowed", 0),
|
||||||
|
"wins": pitching.get("wins", 0),
|
||||||
|
"losses": pitching.get("losses", 0),
|
||||||
|
"saves": pitching.get("saves", 0),
|
||||||
|
"holds": pitching.get("holds", 0),
|
||||||
|
"blown_saves": pitching.get("blown_saves", 0),
|
||||||
|
"last_game_id": game_id,
|
||||||
|
"last_updated_at": now,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Incrementable stat columns (all batting + pitching accumulators)
|
||||||
|
increment_cols = [
|
||||||
|
"games_batting",
|
||||||
|
"pa",
|
||||||
|
"ab",
|
||||||
|
"hits",
|
||||||
|
"doubles",
|
||||||
|
"triples",
|
||||||
|
"hr",
|
||||||
|
"bb",
|
||||||
|
"hbp",
|
||||||
|
"so",
|
||||||
|
"rbi",
|
||||||
|
"runs",
|
||||||
|
"sb",
|
||||||
|
"cs",
|
||||||
|
"games_pitching",
|
||||||
|
"outs",
|
||||||
|
"k",
|
||||||
|
"hits_allowed",
|
||||||
|
"bb_allowed",
|
||||||
|
"hr_allowed",
|
||||||
|
"wins",
|
||||||
|
"losses",
|
||||||
|
"saves",
|
||||||
|
"holds",
|
||||||
|
"blown_saves",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build the conflict-target field objects
|
||||||
|
conflict_target = [
|
||||||
|
PlayerSeasonStats.player,
|
||||||
|
PlayerSeasonStats.team,
|
||||||
|
PlayerSeasonStats.season,
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build the update dict: increment accumulators, overwrite metadata
|
||||||
|
update_dict = {}
|
||||||
|
for col in increment_cols:
|
||||||
|
field_obj = getattr(PlayerSeasonStats, col)
|
||||||
|
update_dict[field_obj] = field_obj + EXCLUDED[col]
|
||||||
|
|
||||||
|
update_dict[PlayerSeasonStats.last_game] = EXCLUDED["last_game_id"]
|
||||||
|
update_dict[PlayerSeasonStats.last_updated_at] = EXCLUDED["last_updated_at"]
|
||||||
|
|
||||||
|
PlayerSeasonStats.insert(
|
||||||
|
player=player_id,
|
||||||
|
team=team_id,
|
||||||
|
season=season,
|
||||||
|
games_batting=row["games_batting"],
|
||||||
|
pa=row["pa"],
|
||||||
|
ab=row["ab"],
|
||||||
|
hits=row["hits"],
|
||||||
|
doubles=row["doubles"],
|
||||||
|
triples=row["triples"],
|
||||||
|
hr=row["hr"],
|
||||||
|
bb=row["bb"],
|
||||||
|
hbp=row["hbp"],
|
||||||
|
so=row["so"],
|
||||||
|
rbi=row["rbi"],
|
||||||
|
runs=row["runs"],
|
||||||
|
sb=row["sb"],
|
||||||
|
cs=row["cs"],
|
||||||
|
games_pitching=row["games_pitching"],
|
||||||
|
outs=row["outs"],
|
||||||
|
k=row["k"],
|
||||||
|
hits_allowed=row["hits_allowed"],
|
||||||
|
bb_allowed=row["bb_allowed"],
|
||||||
|
hr_allowed=row["hr_allowed"],
|
||||||
|
wins=row["wins"],
|
||||||
|
losses=row["losses"],
|
||||||
|
saves=row["saves"],
|
||||||
|
holds=row["holds"],
|
||||||
|
blown_saves=row["blown_saves"],
|
||||||
|
last_game=game_id,
|
||||||
|
last_updated_at=now,
|
||||||
|
).on_conflict(
|
||||||
|
conflict_target=conflict_target,
|
||||||
|
action="update",
|
||||||
|
update=update_dict,
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_sqlite(player_id, team_id, season, game_id, batting, pitching):
|
||||||
|
"""
|
||||||
|
SQLite upsert: 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, _ = PlayerSeasonStats.get_or_create(
|
||||||
|
player_id=player_id,
|
||||||
|
team_id=team_id,
|
||||||
|
season=season,
|
||||||
|
)
|
||||||
|
|
||||||
|
obj.games_batting += batting.get("games_batting", 0)
|
||||||
|
obj.pa += batting.get("pa", 0)
|
||||||
|
obj.ab += batting.get("ab", 0)
|
||||||
|
obj.hits += batting.get("hits", 0)
|
||||||
|
obj.doubles += batting.get("doubles", 0)
|
||||||
|
obj.triples += batting.get("triples", 0)
|
||||||
|
obj.hr += batting.get("hr", 0)
|
||||||
|
obj.bb += batting.get("bb", 0)
|
||||||
|
obj.hbp += batting.get("hbp", 0)
|
||||||
|
obj.so += batting.get("so", 0)
|
||||||
|
obj.rbi += batting.get("rbi", 0)
|
||||||
|
obj.runs += batting.get("runs", 0)
|
||||||
|
obj.sb += batting.get("sb", 0)
|
||||||
|
obj.cs += batting.get("cs", 0)
|
||||||
|
|
||||||
|
obj.games_pitching += pitching.get("games_pitching", 0)
|
||||||
|
obj.outs += pitching.get("outs", 0)
|
||||||
|
obj.k += pitching.get("k", 0)
|
||||||
|
obj.hits_allowed += pitching.get("hits_allowed", 0)
|
||||||
|
obj.bb_allowed += pitching.get("bb_allowed", 0)
|
||||||
|
obj.hr_allowed += pitching.get("hr_allowed", 0)
|
||||||
|
obj.wins += pitching.get("wins", 0)
|
||||||
|
obj.losses += pitching.get("losses", 0)
|
||||||
|
obj.saves += pitching.get("saves", 0)
|
||||||
|
obj.holds += pitching.get("holds", 0)
|
||||||
|
obj.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 PlayerSeasonStats.
|
||||||
|
|
||||||
|
This function is safe to call exactly once per game. If called again
|
||||||
|
for the same game_id while it is still the most-recently-processed
|
||||||
|
game for at least one affected player (detected by checking last_game
|
||||||
|
FK), it returns early without modifying any data.
|
||||||
|
|
||||||
|
Limitation: the guard only detects re-delivery if no later game has
|
||||||
|
been processed for the same players. Out-of-order re-delivery (e.g.
|
||||||
|
game G re-delivered after game G+1 was already processed) will not be
|
||||||
|
caught and will silently double-count stats. See issue #105 for the
|
||||||
|
planned ProcessedGame ledger fix.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Fetch StratGame to get the season.
|
||||||
|
2. Guard against re-processing via last_game_id check.
|
||||||
|
3. Collect all StratPlay rows for the game.
|
||||||
|
4. Group batting stats by (batter_id, batter_team_id).
|
||||||
|
5. Group pitching stats by (pitcher_id, pitcher_team_id).
|
||||||
|
6. Merge Decision rows into pitching groups.
|
||||||
|
7. Upsert each player's contribution using either:
|
||||||
|
- PostgreSQL: atomic SQL increment via ON CONFLICT DO UPDATE
|
||||||
|
- SQLite: read-modify-write inside a transaction
|
||||||
|
|
||||||
|
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 — Double-count prevention: check if any row still
|
||||||
|
# carries this game_id as last_game. Note: only detects replay
|
||||||
|
# of the most-recently-processed game; out-of-order re-delivery
|
||||||
|
# bypasses this guard (see issue #105).
|
||||||
|
already_processed = (
|
||||||
|
PlayerSeasonStats.select()
|
||||||
|
.where(PlayerSeasonStats.last_game == game_id)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if already_processed:
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Collect all unique player keys across both perspectives.
|
||||||
|
# A two-way player (batter who also pitched, or vice-versa) gets
|
||||||
|
# a single combined row in PlayerSeasonStats.
|
||||||
|
all_keys = set(batting_groups.keys()) | set(pitching_groups.keys())
|
||||||
|
|
||||||
|
batters_updated = 0
|
||||||
|
pitchers_updated = 0
|
||||||
|
|
||||||
|
upsert_fn = (
|
||||||
|
_upsert_postgres if DATABASE_TYPE == "postgresql" else _upsert_sqlite
|
||||||
|
)
|
||||||
|
|
||||||
|
for player_id, team_id in all_keys:
|
||||||
|
batting = batting_groups.get((player_id, team_id), {})
|
||||||
|
pitching = pitching_groups.get((player_id, team_id), {})
|
||||||
|
|
||||||
|
upsert_fn(player_id, team_id, season, game_id, batting, pitching)
|
||||||
|
|
||||||
|
if batting:
|
||||||
|
batters_updated += 1
|
||||||
|
if 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;
|
||||||
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"]
|
||||||
@ -1,14 +1,171 @@
|
|||||||
"""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
|
Uses in-memory SQLite with foreign_keys pragma enabled. Each test
|
||||||
db_engine.py sets SKIP_TABLE_CREATION=True and does not try to mutate the
|
gets a fresh set of tables via the setup_test_db fixture (autouse).
|
||||||
production SQLite file during test collection. Each test module is
|
|
||||||
responsible for binding models to its own in-memory database.
|
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 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"
|
os.environ["DATABASE_TYPE"] = "postgresql"
|
||||||
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
# Provide dummy credentials so PooledPostgresqlDatabase can be instantiated
|
||||||
# without raising a configuration error (it will not actually be used).
|
# without raising a configuration error (it will not actually be used).
|
||||||
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
|
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,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
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,
|
||||||
|
ScoutOpportunity,
|
||||||
|
ScoutClaim,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
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
|
||||||
@ -1,119 +1,159 @@
|
|||||||
"""Tests for the evolution track seed data fixture (WP-03).
|
"""
|
||||||
|
Tests for app/seed/evolution_tracks.py — seed_evolution_tracks().
|
||||||
|
|
||||||
Unit tests verify the JSON fixture is correctly formed without touching any
|
What: Verify that the JSON-driven seed function correctly creates, counts,
|
||||||
database. The integration test binds a minimal in-memory EvolutionTrack
|
and idempotently updates EvolutionTrack rows in the database.
|
||||||
model (mirroring the schema WP-01 will add to db_engine) to an in-memory
|
|
||||||
SQLite database, calls seed(), and verifies idempotency.
|
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
|
import pytest
|
||||||
from peewee import CharField, IntegerField, Model, SqliteDatabase
|
|
||||||
|
|
||||||
from app.seed.evolution_tracks import load_tracks, seed
|
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
|
||||||
# Fixtures
|
_JSON_PATH = Path(__file__).parent.parent / "app" / "seed" / "evolution_tracks.json"
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_test_db = SqliteDatabase(":memory:")
|
|
||||||
|
|
||||||
|
|
||||||
class EvolutionTrackStub(Model):
|
@pytest.fixture
|
||||||
"""Minimal EvolutionTrack model for integration tests.
|
def json_tracks():
|
||||||
|
"""Load the raw JSON definitions so tests can assert against them.
|
||||||
|
|
||||||
Mirrors the schema that WP-01 will add to db_engine so the integration
|
This avoids hardcoding expected values — if the JSON changes, tests
|
||||||
test can run without WP-01 being merged.
|
automatically follow without needing manual updates.
|
||||||
"""
|
"""
|
||||||
|
return json.loads(_JSON_PATH.read_text(encoding="utf-8"))
|
||||||
name = CharField()
|
|
||||||
card_type = CharField(unique=True)
|
|
||||||
formula = CharField()
|
|
||||||
t1 = IntegerField()
|
|
||||||
t2 = IntegerField()
|
|
||||||
t3 = IntegerField()
|
|
||||||
t4 = IntegerField()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
database = _test_db
|
|
||||||
table_name = "evolution_track"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
def test_seed_creates_three_tracks(json_tracks):
|
||||||
def _db():
|
"""After one seed call, exactly 3 EvolutionTrack rows must exist.
|
||||||
"""Bind and create the stub table; drop it after each test."""
|
|
||||||
_test_db.connect(reuse_if_open=True)
|
|
||||||
_test_db.create_tables([EvolutionTrackStub])
|
|
||||||
yield
|
|
||||||
_test_db.drop_tables([EvolutionTrackStub])
|
|
||||||
|
|
||||||
|
Why: The JSON currently defines three card-type tracks (batter, sp, rp).
|
||||||
# ---------------------------------------------------------------------------
|
If the count is wrong the system would either be missing tracks
|
||||||
# Unit tests — JSON fixture only, no database
|
(evolution disabled for a card type) or have phantom extras.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_three_tracks_in_seed_data():
|
|
||||||
"""load_tracks() must return exactly 3 evolution tracks."""
|
|
||||||
assert len(load_tracks()) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_card_types_are_exactly_batter_sp_rp():
|
|
||||||
"""The set of card_type values must be exactly {'batter', 'sp', 'rp'}."""
|
|
||||||
types = {t["card_type"] for t in load_tracks()}
|
|
||||||
assert types == {"batter", "sp", "rp"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_thresholds_positive_and_ascending():
|
|
||||||
"""Each track must have t1 < t2 < t3 < t4, all positive."""
|
|
||||||
for track in load_tracks():
|
|
||||||
assert track["t1"] > 0
|
|
||||||
assert track["t1"] < track["t2"] < track["t3"] < track["t4"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_tracks_have_non_empty_formula():
|
|
||||||
"""Every track must have a non-empty formula string."""
|
|
||||||
for track in load_tracks():
|
|
||||||
assert isinstance(track["formula"], str) and track["formula"].strip()
|
|
||||||
|
|
||||||
|
|
||||||
def test_tier_thresholds_match_locked_values():
|
|
||||||
"""Threshold values must exactly match the locked design spec."""
|
|
||||||
tracks = {t["card_type"]: t for t in load_tracks()}
|
|
||||||
|
|
||||||
assert tracks["batter"]["t1"] == 37
|
|
||||||
assert tracks["batter"]["t2"] == 149
|
|
||||||
assert tracks["batter"]["t3"] == 448
|
|
||||||
assert tracks["batter"]["t4"] == 896
|
|
||||||
|
|
||||||
assert tracks["sp"]["t1"] == 10
|
|
||||||
assert tracks["sp"]["t2"] == 40
|
|
||||||
assert tracks["sp"]["t3"] == 120
|
|
||||||
assert tracks["sp"]["t4"] == 240
|
|
||||||
|
|
||||||
assert tracks["rp"]["t1"] == 3
|
|
||||||
assert tracks["rp"]["t2"] == 12
|
|
||||||
assert tracks["rp"]["t3"] == 35
|
|
||||||
assert tracks["rp"]["t4"] == 70
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Integration test — uses the stub model + in-memory SQLite
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_is_idempotent():
|
|
||||||
"""Calling seed() twice must not create duplicate rows (get_or_create).
|
|
||||||
|
|
||||||
First call: all three tracks created (created=True for each).
|
|
||||||
Second call: all three already exist (created=False for each).
|
|
||||||
Both calls succeed without error.
|
|
||||||
"""
|
"""
|
||||||
results_first = seed(model_class=EvolutionTrackStub)
|
seed_evolution_tracks()
|
||||||
assert len(results_first) == 3
|
assert EvolutionTrack.select().count() == 3
|
||||||
assert all(created for _, created in results_first)
|
|
||||||
|
|
||||||
results_second = seed(model_class=EvolutionTrackStub)
|
|
||||||
assert len(results_second) == 3
|
|
||||||
assert not any(created for _, created in results_second)
|
|
||||||
|
|
||||||
assert EvolutionTrackStub.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}"
|
||||||
|
)
|
||||||
|
|||||||
594
tests/test_season_stats_update.py
Normal file
594
tests/test_season_stats_update.py
Normal file
@ -0,0 +1,594 @@
|
|||||||
|
"""
|
||||||
|
Tests for app/services/season_stats.py — update_season_stats().
|
||||||
|
|
||||||
|
What: Verify that the incremental stat accumulation function correctly
|
||||||
|
aggregates StratPlay and Decision rows into PlayerSeasonStats, handles
|
||||||
|
duplicate calls idempotently, and accumulates stats across multiple games.
|
||||||
|
|
||||||
|
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 (
|
||||||
|
Cardset,
|
||||||
|
Decision,
|
||||||
|
Player,
|
||||||
|
PlayerSeasonStats,
|
||||||
|
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 = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == player_batter,
|
||||||
|
PlayerSeasonStats.team == team_a,
|
||||||
|
PlayerSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.pa == 4
|
||||||
|
assert stats.ab == 3
|
||||||
|
assert stats.hits == 2
|
||||||
|
assert stats.hr == 1
|
||||||
|
assert stats.so == 1
|
||||||
|
assert stats.bb == 1
|
||||||
|
assert stats.rbi == 1
|
||||||
|
assert stats.runs == 1
|
||||||
|
assert stats.games_batting == 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 = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == player_pitcher,
|
||||||
|
PlayerSeasonStats.team == team_b,
|
||||||
|
PlayerSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.outs == 1 # one strikeout = one out recorded
|
||||||
|
assert stats.k == 1 # batter's so → pitcher's k
|
||||||
|
assert stats.hits_allowed == 1 # batter's hit → pitcher hits_allowed
|
||||||
|
assert stats.bb_allowed == 1 # batter's bb → pitcher bb_allowed
|
||||||
|
assert stats.games_pitching == 1
|
||||||
|
|
||||||
|
|
||||||
|
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 = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == player_pitcher,
|
||||||
|
PlayerSeasonStats.team == team_b,
|
||||||
|
PlayerSeasonStats.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 detects via the
|
||||||
|
PlayerSeasonStats.last_game FK check that this game is still the
|
||||||
|
most-recently-processed game and returns early with 'skipped'=True.
|
||||||
|
The resulting pa should still be 3, not 6.
|
||||||
|
|
||||||
|
Why: The bot infrastructure may deliver game-complete events more than
|
||||||
|
once (network retries, message replays). The guard prevents
|
||||||
|
double-counting when the replayed game is still the last game
|
||||||
|
processed for those players. Note: this test only covers same-game
|
||||||
|
immediate replay — out-of-order re-delivery (game G after G+1) is a
|
||||||
|
known limitation tracked in issue #105.
|
||||||
|
"""
|
||||||
|
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 = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == player_batter,
|
||||||
|
PlayerSeasonStats.team == team_a,
|
||||||
|
PlayerSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
# Must still be 3, not 6
|
||||||
|
assert stats.pa == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_games_accumulate(team_a, team_b, player_batter, player_pitcher):
|
||||||
|
"""Stats from two separate games are summed in a single PlayerSeasonStats row.
|
||||||
|
|
||||||
|
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 = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == player_batter,
|
||||||
|
PlayerSeasonStats.team == team_a,
|
||||||
|
PlayerSeasonStats.season == 11,
|
||||||
|
)
|
||||||
|
assert stats.pa == 5
|
||||||
|
assert stats.games_batting == 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 = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == batter_a,
|
||||||
|
PlayerSeasonStats.team == team_a,
|
||||||
|
)
|
||||||
|
assert stats_ba.pa == 2
|
||||||
|
assert stats_ba.hits == 1
|
||||||
|
assert stats_ba.so == 1
|
||||||
|
|
||||||
|
# Team B's batter: 1 PA, 1 BB
|
||||||
|
stats_bb = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == batter_b,
|
||||||
|
PlayerSeasonStats.team == team_b,
|
||||||
|
)
|
||||||
|
assert stats_bb.pa == 1
|
||||||
|
assert stats_bb.bb == 1
|
||||||
|
|
||||||
|
# Team B's pitcher (faced team A's batter): 1 hit allowed, 1 K
|
||||||
|
stats_pb = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == pitcher_b,
|
||||||
|
PlayerSeasonStats.team == team_b,
|
||||||
|
)
|
||||||
|
assert stats_pb.hits_allowed == 1
|
||||||
|
assert stats_pb.k == 1
|
||||||
|
|
||||||
|
# Team A's pitcher (faced team B's batter): 1 BB allowed
|
||||||
|
stats_pa = PlayerSeasonStats.get(
|
||||||
|
PlayerSeasonStats.player == pitcher_a,
|
||||||
|
PlayerSeasonStats.team == team_a,
|
||||||
|
)
|
||||||
|
assert stats_pa.bb_allowed == 1
|
||||||
Loading…
Reference in New Issue
Block a user