Compare commits

..

No commits in common. "main" and "issue/143-feat-add-limit-pagination-to-paperdex-endpoint" have entirely different histories.

32 changed files with 218 additions and 6415 deletions

View File

@ -1,31 +0,0 @@
#!/bin/bash
#
# Install git hooks for this repository
#
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "Error: Not in a git repository"
exit 1
fi
HOOKS_DIR="$REPO_ROOT/.githooks"
GIT_HOOKS_DIR="$REPO_ROOT/.git/hooks"
echo "Installing git hooks..."
if [ -f "$HOOKS_DIR/pre-commit" ]; then
cp "$HOOKS_DIR/pre-commit" "$GIT_HOOKS_DIR/pre-commit"
chmod +x "$GIT_HOOKS_DIR/pre-commit"
echo "Installed pre-commit hook"
else
echo "pre-commit hook not found in $HOOKS_DIR"
fi
echo ""
echo "The pre-commit hook will:"
echo " - Auto-fix ruff lint violations (unused imports, formatting, etc.)"
echo " - Block commits only on truly unfixable issues"
echo ""
echo "To bypass in emergency: git commit --no-verify"

View File

@ -1,53 +0,0 @@
#!/bin/bash
#
# Pre-commit hook: ruff lint check on staged Python files.
# Catches syntax errors, unused imports, and basic issues before commit.
# To bypass in emergency: git commit --no-verify
#
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"
STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM -z -- '*.py')
if [ -z "$STAGED_PY" ]; then
exit 0
fi
echo "ruff check on staged files..."
# Stash unstaged changes so ruff only operates on staged content.
# Without this, ruff --fix runs on the full working tree file (staged +
# unstaged), and the subsequent git add would silently include unstaged
# changes in the commit — breaking git add -p workflows.
STASHED=0
if git stash --keep-index -q 2>/dev/null; then
STASHED=1
fi
# Auto-fix what we can, then re-stage the fixed files
printf '%s' "$STAGED_PY" | xargs -0 ruff check --fix --exit-zero
printf '%s' "$STAGED_PY" | xargs -0 git add
# Restore unstaged changes
if [ $STASHED -eq 1 ]; then
git stash pop -q
fi
# Now check for remaining unfixable issues
printf '%s' "$STAGED_PY" | xargs -0 ruff check
RUFF_EXIT=$?
if [ $RUFF_EXIT -ne 0 ]; then
echo ""
echo -e "${RED}Pre-commit checks failed (unfixable issues). Commit blocked.${NC}"
echo -e "${YELLOW}To bypass (not recommended): git commit --no-verify${NC}"
exit 1
fi
echo -e "${GREEN}All checks passed.${NC}"
exit 0

2
.gitignore vendored
View File

@ -59,8 +59,6 @@ pyenv.cfg
pyvenv.cfg
docker-compose.override.yml
docker-compose.*.yml
.run-local.pid
.env.local
*.db
venv
.claude/

View File

@ -31,7 +31,7 @@ docker build -t paper-dynasty-db . # Build image
| **URL** | pddev.manticorum.com | pd.manticorum.com |
| **Host** | `ssh pd-database` | `ssh akamai``/root/container-data/paper-dynasty` |
| **API container** | `dev_pd_database` | `pd_api` |
| **PostgreSQL** | `sba_postgres` / `paperdynasty_dev` / `sba_admin` | `pd_postgres` / `pd_master` |
| **PostgreSQL** | `pd_postgres` (port 5432) | `pd_postgres` |
| **Adminer** | port 8081 | — |
| **API port** | 816 | 815 |
| **Image** | `manticorum67/paper-dynasty-database` | `manticorum67/paper-dynasty-database` |

View File

@ -44,10 +44,6 @@ else:
pragmas={"journal_mode": "wal", "cache_size": -1 * 64000, "synchronous": 0},
)
# Refractor stat accumulation starts at this season — stats from earlier seasons
# are excluded from evaluation queries. Override via REFRACTOR_START_SEASON env var.
REFRACTOR_START_SEASON = int(os.environ.get("REFRACTOR_START_SEASON", "11"))
# 2025, 2005
ranked_cardsets = [24, 25, 26, 27, 28, 29]
LIVE_CARDSET_ID = 27
@ -478,7 +474,6 @@ class Card(BaseModel):
team = ForeignKeyField(Team, null=True)
pack = ForeignKeyField(Pack, null=True)
value = IntegerField(default=0)
variant = IntegerField(null=True, default=None)
def __str__(self):
if self.player:
@ -760,7 +755,6 @@ class BattingCard(BaseModel):
running = IntegerField()
offense_col = IntegerField()
hand = CharField(default="R")
image_url = CharField(null=True, max_length=500)
class Meta:
database = db
@ -830,7 +824,6 @@ class PitchingCard(BaseModel):
batting = CharField(null=True)
offense_col = IntegerField()
hand = CharField(default="R")
image_url = CharField(null=True, max_length=500)
class Meta:
database = db
@ -1239,7 +1232,6 @@ class RefractorCardState(BaseModel):
current_value = FloatField(default=0.0)
fully_evolved = BooleanField(default=False)
last_evaluated_at = DateTimeField(null=True)
variant = IntegerField(null=True)
class Meta:
database = db
@ -1261,29 +1253,46 @@ refractor_card_state_team_index = ModelIndex(
RefractorCardState.add_index(refractor_card_state_team_index)
class RefractorBoostAudit(BaseModel):
card_state = ForeignKeyField(RefractorCardState, on_delete="CASCADE")
class RefractorTierBoost(BaseModel):
track = ForeignKeyField(RefractorTrack)
tier = IntegerField() # 1-4
battingcard = ForeignKeyField(BattingCard, null=True)
pitchingcard = ForeignKeyField(PitchingCard, null=True)
variant_created = IntegerField()
boost_delta_json = (
TextField()
) # JSONB in PostgreSQL; TextField for SQLite test compat
applied_at = DateTimeField(default=datetime.now)
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 = "refractor_boost_audit"
table_name = "refractor_tier_boost"
refractor_tier_boost_index = ModelIndex(
RefractorTierBoost,
(
RefractorTierBoost.track,
RefractorTierBoost.tier,
RefractorTierBoost.boost_type,
RefractorTierBoost.boost_target,
),
unique=True,
)
RefractorTierBoost.add_index(refractor_tier_boost_index)
class RefractorCosmetic(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 = "refractor_cosmetic"
if not SKIP_TABLE_CREATION:
db.create_tables(
[
RefractorTrack,
RefractorCardState,
RefractorBoostAudit,
],
[RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic],
safe=True,
)

View File

@ -51,7 +51,6 @@ async def get_card_positions(
cardset_id: list = Query(default=None),
short_output: Optional[bool] = False,
sort: Optional[str] = "innings-desc",
limit: int = 100,
):
all_pos = (
CardPosition.select()
@ -87,9 +86,6 @@ async def get_card_positions(
elif sort == "range-asc":
all_pos = all_pos.order_by(CardPosition.range, CardPosition.id)
limit = max(0, min(limit, 500))
all_pos = all_pos.limit(limit)
return_val = {
"count": all_pos.count(),
"positions": [model_to_dict(x, recurse=not short_output) for x in all_pos],

View File

@ -79,8 +79,8 @@ async def get_cards(
all_cards = all_cards.where(Card.pack == this_pack)
if value is not None:
all_cards = all_cards.where(Card.value == value)
if variant is not None:
all_cards = all_cards.where(Card.variant == variant)
# if variant is not None:
# all_cards = all_cards.where(Card.variant == variant)
if min_value is not None:
all_cards = all_cards.where(Card.value >= min_value)
if max_value is not None:
@ -114,8 +114,8 @@ async def get_cards(
if csv:
data_list = [
["id", "player", "cardset", "rarity", "team", "pack", "value", "variant"]
]
["id", "player", "cardset", "rarity", "team", "pack", "value"]
] # , 'variant']]
for line in all_cards:
data_list.append(
[
@ -125,8 +125,7 @@ async def get_cards(
line.player.rarity,
line.team.abbrev,
line.pack,
line.value,
line.variant,
line.value, # line.variant
]
)
return_val = DataFrame(data_list).to_csv(header=False, index=False)

View File

@ -2,15 +2,7 @@ import datetime
import os.path
import pandas as pd
from fastapi import (
APIRouter,
BackgroundTasks,
Depends,
HTTPException,
Request,
Response,
Query,
)
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Query
from fastapi.responses import FileResponse
from fastapi.templating import Jinja2Templates
from typing import Optional, List, Literal
@ -40,9 +32,6 @@ from ..db_engine import (
)
from ..db_helpers import upsert_players
from ..dependencies import oauth2_scheme, valid_token
from ..services.card_storage import backfill_variant_image_url, upload_variant_apng
from ..services.refractor_boost import compute_variant_hash
from ..services.apng_generator import apng_cache_path, generate_animated_card
# ---------------------------------------------------------------------------
# Persistent browser instance (WP-02)
@ -143,19 +132,6 @@ def normalize_franchise(franchise: str) -> str:
return FRANCHISE_NORMALIZE.get(titled, titled)
def resolve_refractor_tier(player_id: int, variant: int) -> int:
"""Determine the refractor tier (0-4) from a player's variant hash.
Pure math no DB query needed. Returns 0 for base cards or unknown variants.
"""
if variant == 0:
return 0
for tier in range(1, 5):
if compute_variant_hash(player_id, tier) == variant:
return tier
return 0
router = APIRouter(prefix="/api/v2/players", tags=["players"])
@ -737,157 +713,16 @@ async def get_one_player(player_id: int, csv: Optional[bool] = False):
return return_val
@router.get("/{player_id}/{card_type}card/{d}/{variant}/animated")
async def get_animated_card(
request: Request,
background_tasks: BackgroundTasks,
player_id: int,
card_type: Literal["batting", "pitching"],
variant: int,
d: str,
tier: Optional[int] = Query(
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
),
):
try:
this_player = Player.get_by_id(player_id)
except DoesNotExist:
raise HTTPException(
status_code=404, detail=f"No player found with id {player_id}"
)
refractor_tier = (
tier if tier is not None else resolve_refractor_tier(player_id, variant)
)
if refractor_tier < 3:
raise HTTPException(
status_code=404,
detail=f"No animation for tier {refractor_tier}; animated cards require T3 or T4",
)
cache_path = apng_cache_path(
this_player.cardset.id, card_type, player_id, d, variant
)
headers = {"Cache-Control": "public, max-age=86400"}
if os.path.isfile(cache_path) and tier is None:
return FileResponse(path=cache_path, media_type="image/apng", headers=headers)
all_pos = (
CardPosition.select()
.where(CardPosition.player == this_player)
.order_by(CardPosition.innings.desc())
)
if card_type == "batting":
this_bc = BattingCard.get_or_none(
BattingCard.player == this_player, BattingCard.variant == variant
)
if this_bc is None:
raise HTTPException(
status_code=404,
detail=f"Batting card not found for id {player_id}, variant {variant}",
)
rating_vl = BattingCardRatings.get_or_none(
BattingCardRatings.battingcard == this_bc, BattingCardRatings.vs_hand == "L"
)
rating_vr = BattingCardRatings.get_or_none(
BattingCardRatings.battingcard == this_bc, BattingCardRatings.vs_hand == "R"
)
if None in [rating_vr, rating_vl]:
raise HTTPException(
status_code=404,
detail=f"Ratings not found for batting card {this_bc.id}",
)
card_data = get_batter_card_data(
this_player, this_bc, rating_vl, rating_vr, all_pos
)
if (
this_player.description in this_player.cardset.name
and this_player.cardset.id not in [23]
):
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = refractor_tier
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
else:
this_pc = PitchingCard.get_or_none(
PitchingCard.player == this_player, PitchingCard.variant == variant
)
if this_pc is None:
raise HTTPException(
status_code=404,
detail=f"Pitching card not found for id {player_id}, variant {variant}",
)
rating_vl = PitchingCardRatings.get_or_none(
PitchingCardRatings.pitchingcard == this_pc,
PitchingCardRatings.vs_hand == "L",
)
rating_vr = PitchingCardRatings.get_or_none(
PitchingCardRatings.pitchingcard == this_pc,
PitchingCardRatings.vs_hand == "R",
)
if None in [rating_vr, rating_vl]:
raise HTTPException(
status_code=404,
detail=f"Ratings not found for pitching card {this_pc.id}",
)
card_data = get_pitcher_card_data(
this_player, this_pc, rating_vl, rating_vr, all_pos
)
if (
this_player.description in this_player.cardset.name
and this_player.cardset.id not in [23]
):
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = refractor_tier
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
browser = await get_browser()
page = await browser.new_page(viewport={"width": 1280, "height": 720})
try:
await generate_animated_card(
page,
html_response.body.decode("UTF-8"),
cache_path,
refractor_tier,
)
finally:
await page.close()
if tier is None:
background_tasks.add_task(
upload_variant_apng,
player_id=player_id,
variant=variant,
card_type=card_type,
cardset_id=this_player.cardset.id,
apng_path=cache_path,
)
return FileResponse(path=cache_path, media_type="image/apng", headers=headers)
@router.get("/{player_id}/{card_type}card")
@router.get("/{player_id}/{card_type}card/{d}")
@router.get("/{player_id}/{card_type}card/{d}/{variant}")
async def get_batter_card(
request: Request,
background_tasks: BackgroundTasks,
player_id: int,
card_type: Literal["batting", "pitching"],
variant: int = 0,
d: str = None,
html: Optional[bool] = False,
tier: Optional[int] = Query(
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
),
):
try:
this_player = Player.get_by_id(player_id)
@ -905,7 +740,6 @@ async def get_batter_card(
f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png"
)
and html is False
and tier is None
):
return FileResponse(
path=f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png",
@ -952,9 +786,6 @@ async def get_batter_card(
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = (
tier if tier is not None else resolve_refractor_tier(player_id, variant)
)
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
@ -992,9 +823,6 @@ async def get_batter_card(
card_data["cardset_name"] = this_player.cardset.name
else:
card_data["cardset_name"] = this_player.description
card_data["refractor_tier"] = (
tier if tier is not None else resolve_refractor_tier(player_id, variant)
)
card_data["request"] = request
html_response = templates.TemplateResponse("player_card.html", card_data)
@ -1054,27 +882,6 @@ async def get_batter_card(
# save_as=f'{player_id}-{d}-v{variant}.png'
# )
# Schedule S3 upload for variant cards that don't have an image_url yet.
# Skip when tier is overridden (?tier= dev preview) — those renders don't
# correspond to real variant card rows.
if variant > 0 and tier is None:
CardModel = BattingCard if card_type == "batting" else PitchingCard
try:
card_row = CardModel.get(
(CardModel.player_id == player_id) & (CardModel.variant == variant)
)
if card_row.image_url is None:
background_tasks.add_task(
backfill_variant_image_url,
player_id=player_id,
variant=variant,
card_type=card_type,
cardset_id=this_player.cardset.id,
png_path=file_path,
)
except CardModel.DoesNotExist:
pass
return FileResponse(path=file_path, media_type="image/png", headers=headers)

View File

@ -1,13 +1,9 @@
import os
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
import logging
from typing import Optional
from ..db_engine import model_to_dict, BattingCard, PitchingCard
from ..db_engine import model_to_dict
from ..dependencies import oauth2_scheme, valid_token
from ..services.refractor_init import initialize_card_refractor, _determine_card_type
logger = logging.getLogger(__name__)
@ -24,12 +20,8 @@ _NEXT_THRESHOLD_ATTR = {
4: None,
}
# Sentinel used by _build_card_state_response to distinguish "caller did not
# pass image_url" (do the DB lookup) from "caller passed None" (use None).
_UNSET = object()
def _build_card_state_response(state, player_name=None, image_url=_UNSET) -> dict:
def _build_card_state_response(state, player_name=None) -> dict:
"""Serialise a RefractorCardState into the standard API response shape.
Produces a flat dict with player_id and team_id as plain integers,
@ -60,9 +52,7 @@ def _build_card_state_response(state, player_name=None, image_url=_UNSET) -> dic
"current_value": state.current_value,
"fully_evolved": state.fully_evolved,
"last_evaluated_at": (
state.last_evaluated_at.isoformat()
if hasattr(state.last_evaluated_at, "isoformat")
else state.last_evaluated_at or None
state.last_evaluated_at.isoformat() if state.last_evaluated_at else None
),
"track": track_dict,
"next_threshold": next_threshold,
@ -72,29 +62,6 @@ def _build_card_state_response(state, player_name=None, image_url=_UNSET) -> dic
if player_name is not None:
result["player_name"] = player_name
# Resolve image_url from the variant card row.
# When image_url is pre-fetched by the caller (batch list path), it is
# passed directly and the per-row DB query is skipped entirely.
if image_url is _UNSET:
image_url = None
if state.variant and state.variant > 0:
card_type = (
state.track.card_type
if hasattr(state, "track") and state.track
else None
)
if card_type:
CardModel = BattingCard if card_type == "batter" else PitchingCard
try:
variant_card = CardModel.get(
(CardModel.player_id == state.player_id)
& (CardModel.variant == state.variant)
)
image_url = variant_card.image_url
except CardModel.DoesNotExist:
pass
result["image_url"] = image_url
return result
@ -140,7 +107,6 @@ async def list_card_states(
tier: Optional[int] = Query(default=None, ge=0, le=4),
season: Optional[int] = Query(default=None),
progress: Optional[str] = Query(default=None),
evaluated_only: bool = Query(default=True),
limit: int = Query(default=10, ge=1, le=100),
offset: int = Query(default=0, ge=0),
token: str = Depends(oauth2_scheme),
@ -148,18 +114,15 @@ async def list_card_states(
"""List RefractorCardState rows for a team, with optional filters and pagination.
Required:
team_id -- filter to this team's cards; returns empty list if team has no states
team_id -- filter to this team's cards; returns empty list if team has no states
Optional filters:
card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type
tier -- filter by current_tier (0-4)
season -- filter to players who have batting or pitching season stats in that
season (EXISTS subquery against batting/pitching_season_stats)
progress -- 'close' = only cards within 80% of their next tier threshold;
fully evolved cards are always excluded from this filter
evaluated_only -- default True; when True, excludes cards where last_evaluated_at
is NULL (cards created but never run through the evaluator).
Set to False to include all rows, including zero-value placeholders.
card_type -- one of 'batter', 'sp', 'rp'; filters by RefractorTrack.card_type
tier -- filter by current_tier (0-4)
season -- filter to players who have batting or pitching season stats in that
season (EXISTS subquery against batting/pitching_season_stats)
progress -- 'close' = only cards within 80% of their next tier threshold;
fully evolved cards are always excluded from this filter
Pagination:
limit -- page size (1-100, default 10)
@ -236,47 +199,15 @@ async def list_card_states(
& (RefractorCardState.current_value >= next_threshold_expr * 0.8)
)
if evaluated_only:
query = query.where(RefractorCardState.last_evaluated_at.is_null(False))
total = query.count() or 0
states_page = list(query.offset(offset).limit(limit))
# Pre-fetch image_urls in at most 2 bulk queries (one per card table) so
# that _build_card_state_response never issues a per-row CardModel.get().
batter_pids: set[int] = set()
pitcher_pids: set[int] = set()
for state in states_page:
if state.variant and state.variant > 0:
card_type = state.track.card_type if state.track else None
if card_type == "batter":
batter_pids.add(state.player_id)
elif card_type in ("sp", "rp"):
pitcher_pids.add(state.player_id)
image_url_map: dict[tuple[int, int], str | None] = {}
if batter_pids:
for card in BattingCard.select().where(BattingCard.player_id.in_(batter_pids)):
image_url_map[(card.player_id, card.variant)] = card.image_url
if pitcher_pids:
for card in PitchingCard.select().where(
PitchingCard.player_id.in_(pitcher_pids)
):
image_url_map[(card.player_id, card.variant)] = card.image_url
total = query.count()
items = []
for state in states_page:
for state in query.offset(offset).limit(limit):
player_name = None
try:
player_name = state.player.p_name
except Exception:
pass
img_url = image_url_map.get((state.player_id, state.variant))
items.append(
_build_card_state_response(
state, player_name=player_name, image_url=img_url
)
)
items.append(_build_card_state_response(state, player_name=player_name))
return {"count": total, "items": items}
@ -360,16 +291,14 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
Finds all unique (player_id, team_id) pairs from the game's StratPlay rows,
then for each pair that has a RefractorCardState, re-computes the refractor
tier. Pairs without a state row are auto-initialized on-the-fly via
initialize_card_refractor (idempotent). Per-player errors are logged but
do not abort the batch.
tier. Pairs without a state row are silently skipped. Per-player errors are
logged but do not abort the batch.
"""
if not valid_token(token):
logging.warning("Bad Token: [REDACTED]")
raise HTTPException(status_code=401, detail="Unauthorized")
from ..db_engine import RefractorCardState, Player, StratPlay
from ..services.refractor_boost import apply_tier_boost
from ..services.refractor_evaluator import evaluate_card
plays = list(StratPlay.select().where(StratPlay.game == game_id))
@ -384,8 +313,6 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
evaluated = 0
tier_ups = []
boost_enabled = os.environ.get("REFRACTOR_BOOST_ENABLED", "true").lower() != "false"
for player_id, team_id in pairs:
try:
state = RefractorCardState.get_or_none(
@ -393,29 +320,14 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
& (RefractorCardState.team_id == team_id)
)
if state is None:
try:
player = Player.get_by_id(player_id)
card_type = _determine_card_type(player)
state = initialize_card_refractor(player_id, team_id, card_type)
except Exception:
logger.warning(
f"Refractor auto-init failed for player={player_id} "
f"team={team_id} — skipping"
)
if state is None:
continue
continue
old_tier = state.current_tier
# Use dry_run=True so that current_tier is NOT written here.
# apply_tier_boost() writes current_tier + variant atomically on
# tier-up. If no tier-up occurs, apply_tier_boost is not called
# and the tier stays at old_tier (correct behaviour).
result = evaluate_card(player_id, team_id, dry_run=True)
result = evaluate_card(player_id, team_id)
evaluated += 1
# Use computed_tier (what the formula says) to detect tier-ups.
computed_tier = result.get("computed_tier", old_tier)
if computed_tier > old_tier:
new_tier = result.get("current_tier", old_tier)
if new_tier > old_tier:
player_name = "Unknown"
try:
p = Player.get_by_id(player_id)
@ -423,72 +335,17 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
except Exception:
pass
# Phase 2: Apply rating boosts for each tier gained.
# apply_tier_boost() writes current_tier + variant atomically.
# If it fails, current_tier stays at old_tier — automatic retry next game.
boost_result = None
if not boost_enabled:
# Boost disabled via REFRACTOR_BOOST_ENABLED=false.
# Skip notification — current_tier was not written (dry_run),
# so reporting a tier-up would be a false notification.
continue
card_type = state.track.card_type if state.track else None
if card_type:
last_successful_tier = old_tier
failing_tier = old_tier + 1
try:
for tier in range(old_tier + 1, computed_tier + 1):
failing_tier = tier
boost_result = apply_tier_boost(
player_id, team_id, tier, card_type
)
last_successful_tier = tier
except Exception as boost_exc:
logger.warning(
f"Refractor boost failed for player={player_id} "
f"team={team_id} tier={failing_tier}: {boost_exc}"
)
# Report only the tiers that actually succeeded.
# If none succeeded, skip the tier_up notification entirely.
if last_successful_tier == old_tier:
continue
# At least one intermediate tier was committed; report that.
computed_tier = last_successful_tier
else:
# No card_type means no track — skip boost and skip notification.
# A false tier-up notification must not be sent when the boost
# was never applied (current_tier was never written to DB).
logger.warning(
f"Refractor boost skipped for player={player_id} "
f"team={team_id}: no card_type on track"
)
continue
tier_up_entry = {
"player_id": player_id,
"team_id": team_id,
"player_name": player_name,
"old_tier": old_tier,
"new_tier": computed_tier,
"current_value": result.get("current_value", 0),
"track_name": state.track.name if state.track else "Unknown",
}
# Non-breaking addition: include boost info when available.
if boost_result:
variant_num = boost_result.get("variant_created")
tier_up_entry["variant_created"] = variant_num
if computed_tier >= 3 and variant_num and card_type:
d = date.today().strftime("%Y-%m-%d")
api_base = os.environ.get("API_BASE_URL", "").rstrip("/")
tier_up_entry["animated_url"] = (
f"{api_base}/api/v2/players/{player_id}/{card_type}card"
f"/{d}/{variant_num}/animated"
)
tier_ups.append(tier_up_entry)
tier_ups.append(
{
"player_id": player_id,
"team_id": team_id,
"player_name": player_name,
"old_tier": old_tier,
"new_tier": new_tier,
"current_value": result.get("current_value", 0),
"track_name": state.track.name if state.track else "Unknown",
}
)
except Exception as exc:
logger.warning(
f"Refractor eval failed for player={player_id} team={team_id}: {exc}"

View File

@ -1,125 +0,0 @@
"""
APNG animated card generation for T3 and T4 refractor tiers.
Captures animation frames by scrubbing CSS animations via Playwright each
frame is rendered with a negative animation-delay that freezes the render at a
specific point in the animation cycle. The captured PNGs are then assembled
into a looping APNG using the apng library.
Cache / S3 path convention:
Local: storage/cards/cardset-{id}/{card_type}/{player_id}-{date}-v{variant}.apng
S3: cards/cardset-{id}/{card_type}/{player_id}-{date}-v{variant}.apng
"""
import os
import tempfile
from apng import APNG
from playwright.async_api import Page
# ---------------------------------------------------------------------------
# Animation specs per tier
# Each entry: list of (css_selector, animation_duration_seconds) pairs that
# need to be scrubbed, plus the frame count and per-frame display time.
# ---------------------------------------------------------------------------
_T3_SPEC = {
"selectors_and_durations": [("#header::after", 2.5)],
"num_frames": 12,
"frame_delay_ms": 200,
}
_T4_SPEC = {
"selectors_and_durations": [
("#header::after", 6.0),
(".tier-diamond.diamond-glow", 2.0),
],
"num_frames": 24,
"frame_delay_ms": 250,
}
ANIM_SPECS = {3: _T3_SPEC, 4: _T4_SPEC}
def apng_cache_path(
cardset_id: int, card_type: str, player_id: int, d: str, variant: int
) -> str:
"""Return the local filesystem cache path for an animated card APNG."""
return f"storage/cards/cardset-{cardset_id}/{card_type}/{player_id}-{d}-v{variant}.apng"
async def generate_animated_card(
page: Page,
html_content: str,
output_path: str,
tier: int,
) -> None:
"""Generate an animated APNG for a T3 or T4 refractor card.
Scrubs each CSS animation by injecting an override <style> tag that sets
animation-play-state: running and a negative animation-delay, freezing the
render at evenly-spaced intervals across one animation cycle. The captured
frames are assembled into a looping APNG at output_path.
Args:
page: An open Playwright page (caller is responsible for lifecycle).
html_content: Rendered card HTML string (from TemplateResponse.body).
output_path: Destination path for the .apng file.
tier: Refractor tier must be 3 or 4.
Raises:
ValueError: If tier is not 3 or 4.
"""
spec = ANIM_SPECS.get(tier)
if spec is None:
raise ValueError(
f"No animation spec for tier {tier}; animated cards are T3 and T4 only"
)
num_frames = spec["num_frames"]
frame_delay_ms = spec["frame_delay_ms"]
selectors_and_durations = spec["selectors_and_durations"]
frame_paths: list[str] = []
try:
for i in range(num_frames):
progress = i / num_frames # 0.0 .. (N-1)/N, seamless loop
await page.set_content(html_content)
# Inject override CSS: unpauses animation and seeks to frame offset
css_parts = []
for selector, duration in selectors_and_durations:
delay_s = -progress * duration
css_parts.append(
f"{selector} {{"
f" animation-play-state: running !important;"
f" animation-delay: {delay_s:.4f}s !important;"
f" }}"
)
await page.add_style_tag(content="\n".join(css_parts))
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.close()
await page.screenshot(
path=tmp.name,
type="png",
clip={"x": 0.0, "y": 0, "width": 1200, "height": 600},
)
frame_paths.append(tmp.name)
dir_path = os.path.dirname(output_path)
if dir_path:
os.makedirs(dir_path, exist_ok=True)
apng_obj = APNG()
for frame_path in frame_paths:
# delay/delay_den is the frame display time in seconds as a fraction
apng_obj.append_file(frame_path, delay=frame_delay_ms, delay_den=1000)
apng_obj.save(output_path)
finally:
for path in frame_paths:
try:
os.unlink(path)
except OSError:
pass

View File

@ -1,310 +0,0 @@
"""
card_storage.py S3 upload utility for variant card images.
Public API
----------
get_s3_client()
Create and return a boto3 S3 client using ambient AWS credentials
(environment variables or instance profile).
build_s3_key(cardset_id, player_id, variant, card_type)
Construct the S3 object key for a variant card PNG image.
build_apng_s3_key(cardset_id, player_id, variant, card_type)
Construct the S3 object key for a variant animated card APNG.
build_s3_url(s3_key, render_date)
Return the full HTTPS S3 URL with a cache-busting date query param.
upload_card_to_s3(s3_client, png_bytes, s3_key)
Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers.
upload_apng_to_s3(s3_client, apng_bytes, s3_key)
Upload raw APNG bytes to S3 with correct ContentType and CacheControl headers.
backfill_variant_image_url(player_id, variant, card_type, cardset_id, png_path)
End-to-end: read PNG from disk, upload to S3, update BattingCard or
PitchingCard.image_url in the database. All exceptions are caught and
logged; this function never raises (safe to call as a background task).
upload_variant_apng(player_id, variant, card_type, cardset_id, apng_path)
End-to-end: read APNG from disk and upload to S3. No DB update (no
animated_url column exists yet). All exceptions are caught and logged;
this function never raises (safe to call as a background task).
Design notes
------------
- S3 credentials are resolved from the environment by boto3 at call time;
no credentials are hard-coded here.
- The cache-bust ?d= param matches the card-creation pipeline convention so
that clients can compare URLs across pipelines.
"""
import logging
import os
from datetime import date
import boto3
from app.db_engine import BattingCard, PitchingCard
logger = logging.getLogger(__name__)
S3_BUCKET = os.environ.get("S3_BUCKET", "paper-dynasty")
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
def get_s3_client():
"""Create and return a boto3 S3 client for the configured region.
Credentials are resolved by boto3 from the standard chain:
environment variables ~/.aws/credentials instance profile.
Returns:
A boto3 S3 client instance.
"""
return boto3.client("s3", region_name=S3_REGION)
def build_s3_key(cardset_id: int, player_id: int, variant: int, card_type: str) -> str:
"""Construct the S3 object key for a variant card image.
Key format:
cards/cardset-{csid:03d}/player-{pid}/v{variant}/{card_type}card.png
Args:
cardset_id: Numeric cardset ID (zero-padded to 3 digits).
player_id: Player ID.
variant: Variant number (0 = base, 1-4 = refractor tiers).
card_type: Either "batting" or "pitching".
Returns:
The S3 object key string.
"""
return (
f"cards/cardset-{cardset_id:03d}/player-{player_id}"
f"/v{variant}/{card_type}card.png"
)
def build_s3_url(s3_key: str, render_date: date) -> str:
"""Return the full HTTPS S3 URL for a card image with a cache-bust param.
URL format:
https://{bucket}.s3.{region}.amazonaws.com/{key}?d={date}
The ?d= query param matches the card-creation pipeline convention so that
clients invalidate their cache after each re-render.
Args:
s3_key: S3 object key (from build_s3_key).
render_date: The date the card was rendered, used for cache-busting.
Returns:
Full HTTPS URL string.
"""
base_url = f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com"
date_str = render_date.strftime("%Y-%m-%d")
return f"{base_url}/{s3_key}?d={date_str}"
def build_apng_s3_key(
cardset_id: int, player_id: int, variant: int, card_type: str
) -> str:
"""Construct the S3 object key for a variant animated card APNG.
Key format:
cards/cardset-{csid:03d}/player-{pid}/v{variant}/{card_type}card.apng
Args:
cardset_id: Numeric cardset ID (zero-padded to 3 digits).
player_id: Player ID.
variant: Variant number (1-4 = refractor tiers).
card_type: Either "batting" or "pitching".
Returns:
The S3 object key string.
"""
return (
f"cards/cardset-{cardset_id:03d}/player-{player_id}"
f"/v{variant}/{card_type}card.apng"
)
def upload_card_to_s3(s3_client, png_bytes: bytes, s3_key: str) -> None:
"""Upload raw PNG bytes to S3 with the standard card image headers.
Sets ContentType=image/png and CacheControl=public, max-age=300 (5 min)
so that CDN and browser caches are refreshed within a short window after
a re-render.
Args:
s3_client: A boto3 S3 client (from get_s3_client).
png_bytes: Raw PNG image bytes.
s3_key: S3 object key (from build_s3_key).
Returns:
None
"""
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=png_bytes,
ContentType="image/png",
CacheControl="public, max-age=300",
)
def backfill_variant_image_url(
player_id: int,
variant: int,
card_type: str,
cardset_id: int,
png_path: str,
) -> None:
"""Read a rendered PNG from disk, upload it to S3, and update the DB row.
Determines the correct card model (BattingCard or PitchingCard) from
card_type, then:
1. Reads PNG bytes from png_path.
2. Uploads to S3 via upload_card_to_s3.
3. Fetches the card row by (player_id, variant).
4. Sets image_url to the new S3 URL and calls save().
All exceptions are caught and logged this function is intended to be
called as a background task and must never propagate exceptions.
Args:
player_id: Player ID used to locate the card row.
variant: Variant number (matches the card row's variant field).
card_type: "batting" or "pitching" selects the model.
cardset_id: Cardset ID used for the S3 key.
png_path: Absolute path to the rendered PNG file on disk.
Returns:
None
"""
try:
# 1. Read PNG from disk
with open(png_path, "rb") as f:
png_bytes = f.read()
# 2. Build key and upload
s3_key = build_s3_key(
cardset_id=cardset_id,
player_id=player_id,
variant=variant,
card_type=card_type,
)
s3_client = get_s3_client()
upload_card_to_s3(s3_client, png_bytes, s3_key)
# 3. Build URL with today's date for cache-busting
image_url = build_s3_url(s3_key, render_date=date.today())
# 4. Locate the card row and update image_url
if card_type == "batting":
card = BattingCard.get(
BattingCard.player_id == player_id, BattingCard.variant == variant
)
else:
card = PitchingCard.get(
PitchingCard.player_id == player_id, PitchingCard.variant == variant
)
card.image_url = image_url
card.save()
logger.info(
"backfill_variant_image_url: updated %s card player=%s variant=%s url=%s",
card_type,
player_id,
variant,
image_url,
)
except Exception:
logger.exception(
"backfill_variant_image_url: failed for player=%s variant=%s card_type=%s",
player_id,
variant,
card_type,
)
def upload_apng_to_s3(s3_client, apng_bytes: bytes, s3_key: str) -> None:
"""Upload raw APNG bytes to S3 with the standard animated card headers.
Sets ContentType=image/apng and CacheControl=public, max-age=86400 (1 day)
matching the animated endpoint's own Cache-Control header.
Args:
s3_client: A boto3 S3 client (from get_s3_client).
apng_bytes: Raw APNG image bytes.
s3_key: S3 object key (from build_apng_s3_key).
Returns:
None
"""
s3_client.put_object(
Bucket=S3_BUCKET,
Key=s3_key,
Body=apng_bytes,
ContentType="image/apng",
CacheControl="public, max-age=86400",
)
def upload_variant_apng(
player_id: int,
variant: int,
card_type: str,
cardset_id: int,
apng_path: str,
) -> None:
"""Read a rendered APNG from disk and upload it to S3.
Intended to be called as a background task after a new animated card is
rendered. No DB update is performed (no animated_url column exists yet).
All exceptions are caught and logged this function is intended to be
called as a background task and must never propagate exceptions.
Args:
player_id: Player ID used for the S3 key.
variant: Variant number (matches the refractor tier variant).
card_type: "batting" or "pitching" selects the S3 key.
cardset_id: Cardset ID used for the S3 key.
apng_path: Absolute path to the rendered APNG file on disk.
Returns:
None
"""
try:
with open(apng_path, "rb") as f:
apng_bytes = f.read()
s3_key = build_apng_s3_key(
cardset_id=cardset_id,
player_id=player_id,
variant=variant,
card_type=card_type,
)
s3_client = get_s3_client()
upload_apng_to_s3(s3_client, apng_bytes, s3_key)
logger.info(
"upload_variant_apng: uploaded %s animated card player=%s variant=%s key=%s",
card_type,
player_id,
variant,
s3_key,
)
except Exception:
logger.exception(
"upload_variant_apng: failed for player=%s variant=%s card_type=%s",
player_id,
variant,
card_type,
)

View File

@ -1,698 +0,0 @@
"""Refractor rating boost service (Phase 2).
Pure functions for computing boosted card ratings when a player
reaches a new Refractor tier. The module-level 'db' variable is used by
apply_tier_boost() for atomic writes; tests patch this reference to redirect
writes to a shared-memory SQLite database.
Batter boost: fixed +0.5 to four offensive columns per tier.
Pitcher boost: 1.5 TB-budget priority algorithm per tier.
"""
from decimal import Decimal, ROUND_HALF_UP
import hashlib
import json
import logging
# Module-level db reference imported lazily so that this module can be
# imported before app.db_engine is fully initialised (e.g. in tests that
# patch DATABASE_TYPE before importing db_engine).
# Tests that need to redirect DB writes should patch this attribute at module
# level: `import app.services.refractor_boost as m; m.db = test_db`.
db = None
def _get_db():
"""Return the module-level db, importing lazily on first use."""
global db
if db is None:
from app.db_engine import db as _db # noqa: PLC0415
db = _db
return db
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Batter constants
# ---------------------------------------------------------------------------
BATTER_POSITIVE_DELTAS: dict[str, Decimal] = {
"homerun": Decimal("0.50"),
"double_pull": Decimal("0.50"),
"single_one": Decimal("0.50"),
"walk": Decimal("0.50"),
}
BATTER_NEGATIVE_DELTAS: dict[str, Decimal] = {
"strikeout": Decimal("-1.50"),
"groundout_a": Decimal("-0.50"),
}
# All 22 outcome columns that must sum to 108.
BATTER_OUTCOME_COLUMNS: list[str] = [
"homerun",
"bp_homerun",
"triple",
"double_three",
"double_two",
"double_pull",
"single_two",
"single_one",
"single_center",
"bp_single",
"hbp",
"walk",
"strikeout",
"lineout",
"popout",
"flyout_a",
"flyout_bq",
"flyout_lf_b",
"flyout_rf_b",
"groundout_a",
"groundout_b",
"groundout_c",
]
# ---------------------------------------------------------------------------
# Pitcher constants
# ---------------------------------------------------------------------------
# (column, tb_cost) pairs in priority order.
PITCHER_PRIORITY: list[tuple[str, int]] = [
("double_cf", 2),
("double_three", 2),
("double_two", 2),
("single_center", 1),
("single_two", 1),
("single_one", 1),
("bp_single", 1),
("walk", 1),
("homerun", 4),
("bp_homerun", 4),
("triple", 3),
("hbp", 1),
]
# All 18 variable outcome columns (sum to 79; x-checks add 29 for 108 total).
PITCHER_OUTCOME_COLUMNS: list[str] = [
"homerun",
"bp_homerun",
"triple",
"double_three",
"double_two",
"double_cf",
"single_two",
"single_one",
"single_center",
"bp_single",
"hbp",
"walk",
"strikeout",
"flyout_lf_b",
"flyout_cf_b",
"flyout_rf_b",
"groundout_a",
"groundout_b",
]
# Cross-check columns that are NEVER modified by the boost algorithm.
PITCHER_XCHECK_COLUMNS: list[str] = [
"xcheck_p",
"xcheck_c",
"xcheck_1b",
"xcheck_2b",
"xcheck_3b",
"xcheck_ss",
"xcheck_lf",
"xcheck_cf",
"xcheck_rf",
]
PITCHER_TB_BUDGET = Decimal("1.5")
# ---------------------------------------------------------------------------
# Batter boost
# ---------------------------------------------------------------------------
def apply_batter_boost(ratings_dict: dict) -> dict:
"""Apply one Refractor tier boost to a batter's outcome ratings.
Adds fixed positive deltas to four offensive columns (homerun, double_pull,
single_one, walk) while funding that increase by reducing strikeout and
groundout_a. A 0-floor is enforced on negative columns: if the full
reduction cannot be taken, positive deltas are scaled proportionally so that
the invariant (22 columns sum to 108.0) is always preserved.
Args:
ratings_dict: Dict containing at minimum all 22 BATTER_OUTCOME_COLUMNS
as numeric (int or float) values.
Returns:
New dict with the same keys as ratings_dict, with boosted outcome column
values as floats. All other keys are passed through unchanged.
Raises:
KeyError: If any BATTER_OUTCOME_COLUMNS key is missing from ratings_dict.
"""
result = dict(ratings_dict)
# Step 1 — convert the 22 outcome columns to Decimal for precise arithmetic.
ratings: dict[str, Decimal] = {
col: Decimal(str(result[col])) for col in BATTER_OUTCOME_COLUMNS
}
# Step 2 — apply negative deltas with 0-floor, tracking how much was
# actually removed versus how much was requested.
total_requested_reduction = Decimal("0")
total_actually_reduced = Decimal("0")
for col, delta in BATTER_NEGATIVE_DELTAS.items():
requested = abs(delta)
total_requested_reduction += requested
actual = min(requested, ratings[col])
ratings[col] -= actual
total_actually_reduced += actual
# Step 3 — check whether any truncation occurred.
total_truncated = total_requested_reduction - total_actually_reduced
# Step 4 — scale positive deltas if we couldn't take the full reduction.
if total_truncated > Decimal("0"):
# Positive additions must equal what was actually reduced so the
# 108-sum is preserved.
total_requested_addition = sum(BATTER_POSITIVE_DELTAS.values())
if total_requested_addition > Decimal("0"):
scale = total_actually_reduced / total_requested_addition
else:
scale = Decimal("0")
logger.warning(
"refractor_boost: batter truncation occurred — "
"requested_reduction=%.4f actually_reduced=%.4f scale=%.6f",
float(total_requested_reduction),
float(total_actually_reduced),
float(scale),
)
# Quantize the first N-1 deltas independently, then assign the last
# delta as the remainder so the total addition equals
# total_actually_reduced exactly (no quantize drift across 4 ops).
pos_cols = list(BATTER_POSITIVE_DELTAS.keys())
positive_deltas = {}
running_sum = Decimal("0")
for col in pos_cols[:-1]:
scaled = (BATTER_POSITIVE_DELTAS[col] * scale).quantize(
Decimal("0.000001"), rounding=ROUND_HALF_UP
)
positive_deltas[col] = scaled
running_sum += scaled
last_delta = total_actually_reduced - running_sum
positive_deltas[pos_cols[-1]] = max(last_delta, Decimal("0"))
else:
positive_deltas = BATTER_POSITIVE_DELTAS
# Step 5 — apply (possibly scaled) positive deltas.
for col, delta in positive_deltas.items():
ratings[col] += delta
# Write boosted values back as floats.
for col in BATTER_OUTCOME_COLUMNS:
result[col] = float(ratings[col])
return result
# ---------------------------------------------------------------------------
# Pitcher boost
# ---------------------------------------------------------------------------
def apply_pitcher_boost(ratings_dict: dict, tb_budget: float = 1.5) -> dict:
"""Apply one Refractor tier boost to a pitcher's outcome ratings.
Iterates through PITCHER_PRIORITY in order, converting as many outcome
chances as the TB budget allows into strikeouts. The TB cost per chance
varies by outcome type (e.g. a double costs 2 TB budget units, a single
costs 1). The strikeout column absorbs all converted chances.
X-check columns (xcheck_p through xcheck_rf) are never touched.
Args:
ratings_dict: Dict containing at minimum all 18 PITCHER_OUTCOME_COLUMNS
as numeric (int or float) values.
tb_budget: Total base budget available for this boost tier. Defaults
to 1.5 (PITCHER_TB_BUDGET).
Returns:
New dict with the same keys as ratings_dict, with boosted outcome column
values as floats. All other keys are passed through unchanged.
Raises:
KeyError: If any PITCHER_OUTCOME_COLUMNS key is missing from ratings_dict.
"""
result = dict(ratings_dict)
# Step 1 — convert outcome columns to Decimal, set remaining budget.
ratings: dict[str, Decimal] = {
col: Decimal(str(result[col])) for col in PITCHER_OUTCOME_COLUMNS
}
remaining = Decimal(str(tb_budget))
# Step 2 — iterate priority list, draining budget.
for col, tb_cost in PITCHER_PRIORITY:
if ratings[col] <= Decimal("0"):
continue
tb_cost_d = Decimal(str(tb_cost))
max_chances = remaining / tb_cost_d
chances_to_take = min(ratings[col], max_chances)
ratings[col] -= chances_to_take
ratings["strikeout"] += chances_to_take
remaining -= chances_to_take * tb_cost_d
if remaining <= Decimal("0"):
break
# Step 3 — warn if budget was not fully spent (rare, indicates all priority
# columns were already at zero).
if remaining > Decimal("0"):
logger.warning(
"refractor_boost: pitcher TB budget not fully spent — "
"remaining=%.4f of tb_budget=%.4f",
float(remaining),
tb_budget,
)
# Write boosted values back as floats.
for col in PITCHER_OUTCOME_COLUMNS:
result[col] = float(ratings[col])
return result
# ---------------------------------------------------------------------------
# Variant hash
# ---------------------------------------------------------------------------
def compute_variant_hash(
player_id: int,
refractor_tier: int,
cosmetics: list[str] | None = None,
) -> int:
"""Compute a stable, deterministic variant identifier for a boosted card.
Hashes the combination of player_id, refractor_tier, and an optional sorted
list of cosmetic identifiers to produce a compact integer suitable for use
as a database variant key. The result is derived from the first 8 hex
characters of a SHA-256 digest, so collisions are extremely unlikely in
practice.
variant=0 is reserved and will never be returned; any hash that resolves to
0 is remapped to 1.
Args:
player_id: Player primary key.
refractor_tier: Refractor tier (04) the card has reached.
cosmetics: Optional list of cosmetic tag strings (e.g. special art
identifiers). Order is normalised callers need not sort.
Returns:
A positive integer in the range [1, 2^32 - 1].
"""
inputs = {
"player_id": player_id,
"refractor_tier": refractor_tier,
"cosmetics": sorted(cosmetics or []),
}
raw = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
result = int(raw[:8], 16)
return result if result != 0 else 1 # variant=0 is reserved
# ---------------------------------------------------------------------------
# Display stat helpers
# ---------------------------------------------------------------------------
def compute_batter_display_stats(ratings: dict) -> dict:
"""Compute avg/obp/slg from batter outcome columns.
Uses the same formulas as the BattingCardRatingsModel Pydantic validator
so that variant card display stats are always consistent with the boosted
chance values. All denominators are 108 (the full card chance total).
Args:
ratings: Dict containing at minimum all BATTER_OUTCOME_COLUMNS as
numeric (int or float) values.
Returns:
Dict with keys 'avg', 'obp', 'slg' as floats.
"""
avg = (
ratings["homerun"]
+ ratings["bp_homerun"] / 2
+ ratings["triple"]
+ ratings["double_three"]
+ ratings["double_two"]
+ ratings["double_pull"]
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
obp = (ratings["hbp"] + ratings["walk"]) / 108 + avg
slg = (
ratings["homerun"] * 4
+ ratings["bp_homerun"] * 2
+ ratings["triple"] * 3
+ ratings["double_three"] * 2
+ ratings["double_two"] * 2
+ ratings["double_pull"] * 2
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
return {"avg": avg, "obp": obp, "slg": slg}
def compute_pitcher_display_stats(ratings: dict) -> dict:
"""Compute avg/obp/slg from pitcher outcome columns.
Uses the same formulas as the PitchingCardRatingsModel Pydantic validator
so that variant card display stats are always consistent with the boosted
chance values. All denominators are 108 (the full card chance total).
Args:
ratings: Dict containing at minimum all PITCHER_OUTCOME_COLUMNS as
numeric (int or float) values.
Returns:
Dict with keys 'avg', 'obp', 'slg' as floats.
"""
avg = (
ratings["homerun"]
+ ratings["bp_homerun"] / 2
+ ratings["triple"]
+ ratings["double_three"]
+ ratings["double_two"]
+ ratings["double_cf"]
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
obp = (ratings["hbp"] + ratings["walk"]) / 108 + avg
slg = (
ratings["homerun"] * 4
+ ratings["bp_homerun"] * 2
+ ratings["triple"] * 3
+ ratings["double_three"] * 2
+ ratings["double_two"] * 2
+ ratings["double_cf"] * 2
+ ratings["single_two"]
+ ratings["single_one"]
+ ratings["single_center"]
+ ratings["bp_single"] / 2
) / 108
return {"avg": avg, "obp": obp, "slg": slg}
# ---------------------------------------------------------------------------
# Orchestration: apply_tier_boost
# ---------------------------------------------------------------------------
def apply_tier_boost(
player_id: int,
team_id: int,
new_tier: int,
card_type: str,
_batting_card_model=None,
_batting_ratings_model=None,
_pitching_card_model=None,
_pitching_ratings_model=None,
_card_model=None,
_state_model=None,
_audit_model=None,
) -> dict:
"""Create a boosted variant card for a tier-up.
IMPORTANT: This function is the SOLE writer of current_tier on
RefractorCardState when a tier-up occurs. The evaluator computes
the new tier but does NOT write it this function writes tier +
variant + audit atomically inside a single db.atomic() block.
If this function fails, the tier stays at its old value and will
be retried on the next game evaluation.
Orchestrates the full flow (card creation outside atomic; state
mutations inside db.atomic()):
1. Determine source variant (variant=0 for T1, previous tier's hash for T2+)
2. Fetch source card and ratings rows
3. Apply boost formula (batter or pitcher) per vs_hand split
4. Assert 108-sum after boost for both batters and pitchers
5. Compute new variant hash
6. Create new card row with new variant (idempotency: skip if exists)
7. Create new ratings rows for both vs_hand splits (idempotency: skip if exists)
8. Inside db.atomic():
a. Write RefractorBoostAudit record
b. Update RefractorCardState: current_tier, variant, fully_evolved
c. Propagate variant to all Card rows for (player_id, team_id)
Args:
player_id: Player primary key.
team_id: Team primary key.
new_tier: The tier being reached (1-4).
card_type: One of 'batter', 'sp', 'rp'.
_batting_card_model: Injectable stub for BattingCard (used in tests).
_batting_ratings_model: Injectable stub for BattingCardRatings.
_pitching_card_model: Injectable stub for PitchingCard.
_pitching_ratings_model: Injectable stub for PitchingCardRatings.
_card_model: Injectable stub for Card.
_state_model: Injectable stub for RefractorCardState.
_audit_model: Injectable stub for RefractorBoostAudit.
Returns:
Dict with 'variant_created' (int) and 'boost_deltas' (per-split dict).
Raises:
ValueError: If the source card or ratings are missing, or if
RefractorCardState is not found for (player_id, team_id).
"""
# Lazy model imports — same pattern as refractor_evaluator.py.
if _batting_card_model is None:
from app.db_engine import BattingCard as _batting_card_model # noqa: PLC0415
if _batting_ratings_model is None:
from app.db_engine import BattingCardRatings as _batting_ratings_model # noqa: PLC0415
if _pitching_card_model is None:
from app.db_engine import PitchingCard as _pitching_card_model # noqa: PLC0415
if _pitching_ratings_model is None:
from app.db_engine import PitchingCardRatings as _pitching_ratings_model # noqa: PLC0415
if _card_model is None:
from app.db_engine import Card as _card_model # noqa: PLC0415
if _state_model is None:
from app.db_engine import RefractorCardState as _state_model # noqa: PLC0415
if _audit_model is None:
from app.db_engine import RefractorBoostAudit as _audit_model # noqa: PLC0415
_db = _get_db()
if card_type not in ("batter", "sp", "rp"):
raise ValueError(
f"Invalid card_type={card_type!r}; expected one of 'batter', 'sp', 'rp'"
)
is_batter = card_type == "batter"
CardModel = _batting_card_model if is_batter else _pitching_card_model
RatingsModel = _batting_ratings_model if is_batter else _pitching_ratings_model
fk_field = "battingcard" if is_batter else "pitchingcard"
# 1. Determine source variant.
if new_tier == 1:
source_variant = 0
else:
source_variant = compute_variant_hash(player_id, new_tier - 1)
# 2. Fetch source card and ratings rows.
source_card = CardModel.get_or_none(
(CardModel.player == player_id) & (CardModel.variant == source_variant)
)
if source_card is None:
raise ValueError(
f"No {'batting' if is_batter else 'pitching'}card for "
f"player={player_id} variant={source_variant}"
)
ratings_rows = list(
RatingsModel.select().where(getattr(RatingsModel, fk_field) == source_card.id)
)
if not ratings_rows:
raise ValueError(f"No ratings rows for card_id={source_card.id}")
# 3. Apply boost to each vs_hand split.
boost_fn = apply_batter_boost if is_batter else apply_pitcher_boost
outcome_cols = BATTER_OUTCOME_COLUMNS if is_batter else PITCHER_OUTCOME_COLUMNS
boosted_splits: dict[str, dict] = {}
for row in ratings_rows:
# Build the ratings dict: outcome columns + (pitcher) x-check columns.
ratings_dict: dict = {col: getattr(row, col) for col in outcome_cols}
if not is_batter:
for col in PITCHER_XCHECK_COLUMNS:
ratings_dict[col] = getattr(row, col)
boosted = boost_fn(ratings_dict)
# 4. Assert 108-sum invariant after boost (Peewee bypasses Pydantic validators).
if is_batter:
boosted_sum = sum(boosted[col] for col in BATTER_OUTCOME_COLUMNS)
else:
boosted_sum = sum(boosted[col] for col in PITCHER_OUTCOME_COLUMNS) + sum(
boosted[col] for col in PITCHER_XCHECK_COLUMNS
)
if abs(boosted_sum - 108.0) >= 0.01:
raise ValueError(
f"108-sum invariant violated after boost for player={player_id} "
f"vs_hand={row.vs_hand}: sum={boosted_sum:.6f}"
)
boosted_splits[row.vs_hand] = boosted
# 5. Compute new variant hash.
new_variant = compute_variant_hash(player_id, new_tier)
# 6. Create new card row (idempotency: skip if exists).
existing_card = CardModel.get_or_none(
(CardModel.player == player_id) & (CardModel.variant == new_variant)
)
if existing_card is not None:
new_card = existing_card
else:
if is_batter:
clone_fields = [
"steal_low",
"steal_high",
"steal_auto",
"steal_jump",
"bunting",
"hit_and_run",
"running",
"offense_col",
"hand",
]
else:
clone_fields = [
"balk",
"wild_pitch",
"hold",
"starter_rating",
"relief_rating",
"closer_rating",
"batting",
"offense_col",
"hand",
]
card_data: dict = {
"player": player_id,
"variant": new_variant,
"image_url": None, # No rendered image for variant cards yet.
}
for fname in clone_fields:
card_data[fname] = getattr(source_card, fname)
new_card = CardModel.create(**card_data)
# 7. Create new ratings rows for each split (idempotency: skip if exists).
display_stats_fn = (
compute_batter_display_stats if is_batter else compute_pitcher_display_stats
)
for vs_hand, boosted_ratings in boosted_splits.items():
existing_ratings = RatingsModel.get_or_none(
(getattr(RatingsModel, fk_field) == new_card.id)
& (RatingsModel.vs_hand == vs_hand)
)
if existing_ratings is not None:
continue # Idempotency: already written.
ratings_data: dict = {
fk_field: new_card.id,
"vs_hand": vs_hand,
}
# Outcome columns (boosted values).
ratings_data.update({col: boosted_ratings[col] for col in outcome_cols})
# X-check columns for pitchers (unchanged by boost, copy from boosted dict).
if not is_batter:
for col in PITCHER_XCHECK_COLUMNS:
ratings_data[col] = boosted_ratings[col]
# Direction rates for batters: copy from source row.
if is_batter:
source_row = next(r for r in ratings_rows if r.vs_hand == vs_hand)
for rate_col in ("pull_rate", "center_rate", "slap_rate"):
ratings_data[rate_col] = getattr(source_row, rate_col)
# Compute fresh display stats from boosted chance columns.
display_stats = display_stats_fn(boosted_ratings)
ratings_data.update(display_stats)
RatingsModel.create(**ratings_data)
# 8. Load card state — needed for atomic state mutations.
card_state = _state_model.get_or_none(
(_state_model.player == player_id) & (_state_model.team == team_id)
)
if card_state is None:
raise ValueError(
f"No refractor_card_state for player={player_id} team={team_id}"
)
# All state mutations in a single atomic block.
with _db.atomic():
# 8a. Write audit record.
# boost_delta_json stores per-split boosted values including x-check columns
# for pitchers so the full card can be reconstructed from the audit.
audit_data: dict = {
"card_state": card_state.id,
"tier": new_tier,
"variant_created": new_variant,
"boost_delta_json": json.dumps(boosted_splits, default=str),
}
if is_batter:
audit_data["battingcard"] = new_card.id
else:
audit_data["pitchingcard"] = new_card.id
existing_audit = _audit_model.get_or_none(
(_audit_model.card_state == card_state.id) & (_audit_model.tier == new_tier)
)
if existing_audit is None:
_audit_model.create(**audit_data)
# 8b. Update RefractorCardState — this is the SOLE tier write on tier-up.
card_state.current_tier = new_tier
card_state.fully_evolved = new_tier >= 4
card_state.variant = new_variant
card_state.save()
# 8c. Propagate variant to all Card rows for (player_id, team_id).
_card_model.update(variant=new_variant).where(
(_card_model.player == player_id) & (_card_model.team == team_id)
).execute()
logger.debug(
"refractor_boost: applied T%s boost for player=%s team=%s variant=%s",
new_tier,
player_id,
team_id,
new_variant,
)
return {
"variant_created": new_variant,
"boost_deltas": dict(boosted_splits),
}

View File

@ -9,20 +9,9 @@ evaluate_card() is the main entry point:
4. Compare value to track thresholds to determine new_tier
5. Update card_state.current_value = computed value
6. Update card_state.current_tier = max(current_tier, new_tier) no regression
(SKIPPED when dry_run=True)
7. Update card_state.fully_evolved = (current_tier >= 4)
(SKIPPED when dry_run=True)
7. Update card_state.fully_evolved = (new_tier >= 4)
8. Update card_state.last_evaluated_at = NOW()
When dry_run=True, only steps 5 and 8 are written (current_value and
last_evaluated_at). Steps 67 (current_tier and fully_evolved) are intentionally
skipped so that the evaluate-game endpoint can detect a pending tier-up and
delegate the tier write to apply_tier_boost(), which writes tier + variant
atomically. The return dict always includes both "computed_tier" (what the
formula says the tier should be) and "computed_fully_evolved" (whether the
computed tier implies full evolution) so callers can make decisions without
reading the database again.
Idempotent: calling multiple times with the same data produces the same result.
Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
@ -58,7 +47,6 @@ class _CareerTotals:
def evaluate_card(
player_id: int,
team_id: int,
dry_run: bool = False,
_stats_model=None,
_state_model=None,
_compute_value_fn=None,
@ -68,26 +56,15 @@ def evaluate_card(
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
card_type) for (player_id, team_id) across all seasons, then delegates
formula computation and tier classification to the formula engine. The
result is written back to refractor_card_state and returned as a dict.
formula computation and tier classification to the formula engine. The result is written back to refractor_card_state and
returned as a dict.
current_tier never decreases (no regression):
card_state.current_tier = max(card_state.current_tier, new_tier)
When dry_run=True, only current_value and last_evaluated_at are written
current_tier and fully_evolved are NOT updated. This allows the caller
(evaluate-game endpoint) to detect a tier-up and delegate the tier write
to apply_tier_boost(), which writes tier + variant atomically. The return
dict always includes "computed_tier" (what the formula says the tier should
be) in addition to "current_tier" (what is actually stored in the DB).
Args:
player_id: Player primary key.
team_id: Team primary key.
dry_run: When True, skip writing current_tier and fully_evolved so
that apply_tier_boost() can write them atomically with variant
creation. Defaults to False (existing behaviour for the manual
/evaluate endpoint).
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats
(used in tests to inject a stub model with all stat fields).
_state_model: Override for RefractorCardState (used in tests to avoid
@ -98,10 +75,8 @@ def evaluate_card(
(used in tests).
Returns:
Dict with current_tier, computed_tier, current_value, fully_evolved,
last_evaluated_at (ISO-8601 string). "computed_tier" reflects what
the formula computed; "current_tier" reflects what is stored in the DB
(which may differ when dry_run=True and a tier-up is pending).
Dict with updated current_tier, current_value, fully_evolved,
last_evaluated_at (ISO-8601 string).
Raises:
ValueError: If no refractor_card_state row exists for (player_id, team_id).
@ -148,11 +123,10 @@ def evaluate_card(
strikeouts=sum(r.strikeouts for r in rows),
)
else:
from app.db_engine import ( # noqa: PLC0415
from app.db_engine import (
BattingSeasonStats,
PitchingSeasonStats,
REFRACTOR_START_SEASON,
)
) # noqa: PLC0415
card_type = card_state.track.card_type
if card_type == "batter":
@ -160,7 +134,6 @@ def evaluate_card(
BattingSeasonStats.select().where(
(BattingSeasonStats.player == player_id)
& (BattingSeasonStats.team == team_id)
& (BattingSeasonStats.season >= REFRACTOR_START_SEASON)
)
)
totals = _CareerTotals(
@ -177,7 +150,6 @@ def evaluate_card(
PitchingSeasonStats.select().where(
(PitchingSeasonStats.player == player_id)
& (PitchingSeasonStats.team == team_id)
& (PitchingSeasonStats.season >= REFRACTOR_START_SEASON)
)
)
totals = _CareerTotals(
@ -197,30 +169,21 @@ def evaluate_card(
value = _compute_value_fn(track.card_type, totals)
new_tier = _tier_from_value_fn(value, track)
# 58. Update card state.
# 58. Update card state (no tier regression)
now = datetime.now()
computed_tier = new_tier
computed_fully_evolved = computed_tier >= 4
# Always update value and timestamp; current_tier and fully_evolved are
# skipped when dry_run=True so that apply_tier_boost() can write them
# atomically with variant creation on tier-up.
card_state.current_value = value
card_state.current_tier = max(card_state.current_tier, new_tier)
card_state.fully_evolved = card_state.current_tier >= 4
card_state.last_evaluated_at = now
if not dry_run:
card_state.current_tier = max(card_state.current_tier, new_tier)
card_state.fully_evolved = card_state.current_tier >= 4
card_state.save()
logging.debug(
"refractor_eval: player=%s team=%s value=%.2f computed_tier=%s "
"stored_tier=%s dry_run=%s",
"refractor_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s",
player_id,
team_id,
value,
computed_tier,
card_state.current_tier,
dry_run,
card_state.fully_evolved,
)
return {
@ -228,8 +191,6 @@ def evaluate_card(
"team_id": team_id,
"current_value": card_state.current_value,
"current_tier": card_state.current_tier,
"computed_tier": computed_tier,
"computed_fully_evolved": computed_fully_evolved,
"fully_evolved": card_state.fully_evolved,
"last_evaluated_at": card_state.last_evaluated_at.isoformat(),
}

View File

@ -1,47 +0,0 @@
-- Migration: Refractor Phase 2 — rating boost support
-- Date: 2026-03-28
-- Purpose: Extends the Refractor system to track and audit rating boosts
-- applied at each tier-up. Adds a variant column to
-- refractor_card_state (mirrors card.variant for promoted copies)
-- and creates the refractor_boost_audit table to record the
-- boost delta, source card, and variant assigned at each tier.
--
-- Tables affected:
-- refractor_card_state — new column: variant INTEGER
-- refractor_boost_audit — new table
--
-- Run on dev first, verify with:
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name = 'refractor_card_state'
-- AND column_name = 'variant';
-- SELECT count(*) FROM refractor_boost_audit;
--
-- Rollback: See DROP/ALTER statements at bottom of file
BEGIN;
-- Verify card.variant column exists (should be from Phase 1 migration).
-- If not present, uncomment:
-- ALTER TABLE card ADD COLUMN IF NOT EXISTS variant INTEGER DEFAULT NULL;
-- New columns on refractor_card_state (additive, no data migration needed)
ALTER TABLE refractor_card_state ADD COLUMN IF NOT EXISTS variant INTEGER;
-- Boost audit table: records what was applied at each tier-up
CREATE TABLE IF NOT EXISTS refractor_boost_audit (
id SERIAL PRIMARY KEY,
card_state_id INTEGER NOT NULL REFERENCES refractor_card_state(id) ON DELETE CASCADE,
tier SMALLINT NOT NULL,
battingcard_id INTEGER REFERENCES battingcard(id),
pitchingcard_id INTEGER REFERENCES pitchingcard(id),
variant_created INTEGER NOT NULL,
boost_delta_json JSONB NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(card_state_id, tier) -- Prevent duplicate audit records on retry
);
COMMIT;
-- Rollback:
-- DROP TABLE IF EXISTS refractor_boost_audit;
-- ALTER TABLE refractor_card_state DROP COLUMN IF EXISTS variant;

View File

@ -1,7 +0,0 @@
-- Drop orphaned RefractorTierBoost and RefractorCosmetic tables.
-- These were speculative schema from the initial Refractor design that were
-- never used — boosts are hardcoded in refractor_boost.py and tier visuals
-- are embedded in CSS templates. Both tables have zero rows on dev and prod.
DROP TABLE IF EXISTS refractor_tier_boost;
DROP TABLE IF EXISTS refractor_cosmetic;

View File

@ -12,5 +12,3 @@ requests==2.32.3
html2image==2.0.6
jinja2==3.1.4
playwright==1.45.1
apng==0.3.4
boto3==1.42.65

View File

@ -1,132 +0,0 @@
#!/usr/bin/env bash
# run-local.sh — Spin up the Paper Dynasty Database API locally for testing.
#
# Connects to the dev PostgreSQL on the homelab (10.10.0.42) so you get real
# card data for rendering. Playwright Chromium must be installed locally
# (it already is on this workstation).
#
# Usage:
# ./run-local.sh # start on default port 8000
# ./run-local.sh 8001 # start on custom port
# ./run-local.sh --stop # kill a running instance
#
# Card rendering test URLs (after startup):
# HTML preview: http://localhost:8000/api/v2/players/{id}/battingcard/{date}/{variant}?html=True
# PNG render: http://localhost:8000/api/v2/players/{id}/battingcard/{date}/{variant}
# API docs: http://localhost:8000/api/docs
set -euo pipefail
cd "$(dirname "$0")"
PORT="${1:-8000}"
PIDFILE=".run-local.pid"
LOGFILE="logs/database/run-local.log"
# ── Stop mode ────────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--stop" ]]; then
if [[ -f "$PIDFILE" ]]; then
pid=$(cat "$PIDFILE")
if kill -0 "$pid" 2>/dev/null; then
kill "$pid"
echo "Stopped local API (PID $pid)"
else
echo "PID $pid not running (stale pidfile)"
fi
rm -f "$PIDFILE"
else
echo "No pidfile found — nothing to stop"
fi
exit 0
fi
# ── Pre-flight checks ───────────────────────────────────────────────────────
if [[ -f "$PIDFILE" ]] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo "Already running (PID $(cat "$PIDFILE")). Use './run-local.sh --stop' first."
exit 1
fi
# Check Python deps are importable
python -c "import fastapi, peewee, playwright" 2>/dev/null || {
echo "Missing Python dependencies. Install with: pip install -r requirements.txt"
exit 1
}
# Check Playwright Chromium is available
python -c "
from playwright.sync_api import sync_playwright
p = sync_playwright().start()
print(p.chromium.executable_path)
p.stop()
" >/dev/null 2>&1 || {
echo "Playwright Chromium not installed. Run: playwright install chromium"
exit 1
}
# Check dev DB is reachable
DB_HOST="${POSTGRES_HOST_LOCAL:-10.10.0.42}"
python -c "
import socket, sys
s = socket.create_connection((sys.argv[1], 5432), timeout=3)
s.close()
" "$DB_HOST" 2>/dev/null || {
echo "Cannot reach dev PostgreSQL at ${DB_HOST}:5432 — is the homelab up?"
exit 1
}
# ── Ensure directories exist ────────────────────────────────────────────────
mkdir -p logs/database
mkdir -p storage/cards
# ── Launch ───────────────────────────────────────────────────────────────────
echo "Starting Paper Dynasty Database API on http://localhost:${PORT}"
echo " DB: paperdynasty_dev @ 10.10.0.42"
echo " Logs: ${LOGFILE}"
echo ""
# Load .env, then .env.local overrides (for passwords not in version control)
set -a
# shellcheck source=/dev/null
[[ -f .env ]] && source .env
[[ -f .env.local ]] && source .env.local
set +a
# Override DB host to point at the dev server's IP (not Docker network name)
export DATABASE_TYPE=postgresql
export POSTGRES_HOST="$DB_HOST"
export POSTGRES_PORT="${POSTGRES_PORT:-5432}"
export POSTGRES_DB="${POSTGRES_DB:-paperdynasty_dev}"
export POSTGRES_USER="${POSTGRES_USER:-sba_admin}"
export LOG_LEVEL=INFO
export TESTING=True
if [[ -z "${POSTGRES_PASSWORD:-}" || "$POSTGRES_PASSWORD" == "your_production_password" ]]; then
echo "ERROR: POSTGRES_PASSWORD not set or is the placeholder value."
echo "Create .env.local with: POSTGRES_PASSWORD=<actual password>"
exit 1
fi
uvicorn app.main:app \
--host 0.0.0.0 \
--port "$PORT" \
--reload \
--reload-dir app \
--reload-dir storage/templates \
2>&1 | tee "$LOGFILE" &
echo $! >"$PIDFILE"
sleep 2
if kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
echo ""
echo "API running (PID $(cat "$PIDFILE"))."
echo ""
echo "Quick test URLs:"
echo " API docs: http://localhost:${PORT}/api/docs"
echo " Health: curl -s http://localhost:${PORT}/api/v2/players/1/battingcard?html=True"
echo ""
echo "Stop with: ./run-local.sh --stop"
else
echo "Failed to start — check ${LOGFILE}"
rm -f "$PIDFILE"
exit 1
fi

View File

@ -1,55 +0,0 @@
# Scripts
Operational scripts for the Paper Dynasty Database API.
## deploy.sh
Deploy the API by tagging a commit and triggering CI/CD.
```bash
./scripts/deploy.sh dev # Tag HEAD as 'dev', CI builds :dev image
./scripts/deploy.sh prod # Create CalVer tag + 'latest' + 'production'
./scripts/deploy.sh dev abc1234 # Tag a specific commit
./scripts/deploy.sh dev --sync-templates # Deploy + push changed templates to server
```
**Template drift check** runs automatically on every deploy. Compares local `storage/templates/*.html` against the target server via md5sum and warns if any files differ. Templates are volume-mounted (not baked into the Docker image), so code deploys alone won't update them.
**Cached image report** also runs automatically, showing PNG and APNG counts on the target server.
| Environment | SSH Host | Template Path |
|---|---|---|
| dev | `pd-database` | `/home/cal/container-data/dev-pd-database/storage/templates` |
| prod | `akamai` | `/root/container-data/paper-dynasty/storage/templates` |
## clear-card-cache.sh
Inspect or clear cached rendered card images inside the API container.
```bash
./scripts/clear-card-cache.sh dev # Report cache size (dry run)
./scripts/clear-card-cache.sh dev --apng-only # Delete animated card cache only
./scripts/clear-card-cache.sh dev --all # Delete all cached card images
```
Cached images regenerate on demand when next requested. APNG files (T3/T4 animated cards) are the most likely to go stale after template CSS changes. Both destructive modes prompt for confirmation before deleting.
| Environment | SSH Host | Container | Cache Path |
|---|---|---|---|
| dev | `pd-database` | `dev_pd_database` | `/app/storage/cards/` |
| prod | `akamai` | `pd_api` | `/app/storage/cards/` |
## Migration Scripts
| Script | Purpose |
|---|---|
| `migrate_to_postgres.py` | One-time SQLite to PostgreSQL migration |
| `migrate_missing_data.py` | Backfill missing data after migration |
| `db_migrations.py` (in repo root) | Schema migrations |
## Utility Scripts
| Script | Purpose |
|---|---|
| `wipe_gauntlet_team.py` | Reset a gauntlet team's state |
| `audit_sqlite.py` | Audit legacy SQLite database |

View File

@ -1,89 +0,0 @@
#!/bin/bash
# Clear cached card images from the API container
# Usage: ./scripts/clear-card-cache.sh <dev|prod> [--apng-only|--all]
#
# With no flags: reports cache size only (dry run)
# --apng-only: delete only .apng files (animated cards)
# --all: delete all cached card images (.png + .apng)
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
declare -A DEPLOY_HOST=([dev]="pd-database" [prod]="akamai")
declare -A CONTAINER=([dev]="dev_pd_database" [prod]="pd_api")
usage() {
echo "Usage: $0 <dev|prod> [--apng-only|--all]"
echo ""
echo " No flag Report cache size (dry run)"
echo " --apng-only Delete only .apng files (animated cards)"
echo " --all Delete all cached card images"
exit 1
}
[[ $# -lt 1 ]] && usage
ENV="$1"
ACTION="${2:-report}"
if [[ "$ENV" != "dev" && "$ENV" != "prod" ]]; then
usage
fi
HOST="${DEPLOY_HOST[$ENV]}"
CTR="${CONTAINER[$ENV]}"
CACHE_PATH="/app/storage/cards"
report() {
echo -e "${CYAN}Card image cache on ${HOST} (${CTR}):${NC}"
ssh "$HOST" "
png_count=\$(docker exec $CTR find $CACHE_PATH -name '*.png' 2>/dev/null | wc -l)
apng_count=\$(docker exec $CTR find $CACHE_PATH -name '*.apng' 2>/dev/null | wc -l)
echo \" PNG: \${png_count} files\"
echo \" APNG: \${apng_count} files\"
echo \" Total: \$((\${png_count} + \${apng_count})) files\"
" 2>/dev/null || {
echo -e "${RED}Could not reach ${HOST}.${NC}"
exit 1
}
}
report
case "$ACTION" in
report)
echo -e "${GREEN}Dry run — no files deleted. Pass --apng-only or --all to clear.${NC}"
;;
--apng-only)
echo -e "${YELLOW}Deleting all .apng files from ${CTR}...${NC}"
read -rp "Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
deleted=$(ssh "$HOST" "docker exec $CTR find $CACHE_PATH -name '*.apng' -delete -print 2>/dev/null | wc -l")
echo -e "${GREEN}Deleted ${deleted} .apng files.${NC}"
;;
--all)
echo -e "${RED}Deleting ALL cached card images from ${CTR}...${NC}"
read -rp "This will clear PNG and APNG caches. Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
deleted=$(ssh "$HOST" "docker exec $CTR find $CACHE_PATH -type f \( -name '*.png' -o -name '*.apng' \) -delete -print 2>/dev/null | wc -l")
echo -e "${GREEN}Deleted ${deleted} cached card images.${NC}"
;;
*)
usage
;;
esac

View File

@ -1,203 +0,0 @@
#!/bin/bash
# Deploy Paper Dynasty Database API
# Usage: ./scripts/deploy.sh <dev|prod> [--sync-templates] [commit]
#
# Dev: Force-updates the "dev" git tag → CI builds :dev Docker image
# Prod: Creates CalVer tag + force-updates "latest" and "production" git tags
# → CI builds :<calver>, :latest, :production Docker images
#
# Options:
# --sync-templates Upload changed templates to the target server via scp
#
# Templates are volume-mounted (not in the Docker image). The script always
# checks for template drift and warns if local/remote differ. Pass
# --sync-templates to actually push the changed files.
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
REMOTE="origin"
SYNC_TEMPLATES=false
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATE_DIR="$SCRIPT_DIR/../storage/templates"
# Server config
declare -A DEPLOY_HOST=([dev]="pd-database" [prod]="akamai")
declare -A TEMPLATE_PATH=(
[dev]="/home/cal/container-data/dev-pd-database/storage/templates"
[prod]="/root/container-data/paper-dynasty/storage/templates"
)
usage() {
echo "Usage: $0 <dev|prod> [--sync-templates] [commit]"
echo ""
echo " dev [commit] Force-update 'dev' tag on HEAD or specified commit"
echo " prod [commit] Create CalVer + 'latest' + 'production' tags on HEAD or specified commit"
echo ""
echo "Options:"
echo " --sync-templates Upload changed templates to the target server"
exit 1
}
[[ $# -lt 1 ]] && usage
ENV="$1"
shift
# Parse optional flags
COMMIT="HEAD"
while [[ $# -gt 0 ]]; do
case "$1" in
--sync-templates)
SYNC_TEMPLATES=true
shift
;;
--*)
echo -e "${RED}Unknown option: $1${NC}"
usage
;;
*)
COMMIT="$1"
shift
;;
esac
done
SHA=$(git rev-parse "$COMMIT" 2>/dev/null) || {
echo -e "${RED}Invalid commit: $COMMIT${NC}"
exit 1
}
SHA_SHORT="${SHA:0:7}"
git fetch --tags "$REMOTE"
if ! git branch -a --contains "$SHA" 2>/dev/null | grep -qE '(^|\s)(main|remotes/origin/main)$'; then
echo -e "${RED}Commit $SHA_SHORT is not on main. Aborting.${NC}"
exit 1
fi
# --- Template drift check ---
check_templates() {
local host="${DEPLOY_HOST[$ENV]}"
local remote_path="${TEMPLATE_PATH[$ENV]}"
echo -e "${CYAN}Checking templates against ${host}:${remote_path}...${NC}"
local local_hashes remote_hashes
local_hashes=$(cd "$TEMPLATE_DIR" && md5sum *.html 2>/dev/null | sort -k2)
remote_hashes=$(ssh "$host" "cd '$remote_path' && md5sum *.html 2>/dev/null | sort -k2" 2>/dev/null) || {
echo -e "${YELLOW} Could not reach ${host} — skipping template check.${NC}"
return 0
}
local changed=()
local missing_remote=()
while IFS= read -r line; do
local hash file remote_hash
hash=$(echo "$line" | awk '{print $1}')
file=$(echo "$line" | awk '{print $2}')
remote_hash=$(echo "$remote_hashes" | awk -v f="$file" '$2 == f {print $1}')
if [[ -z "$remote_hash" ]]; then
missing_remote+=("$file")
elif [[ "$hash" != "$remote_hash" ]]; then
changed+=("$file")
fi
done <<<"$local_hashes"
if [[ ${#changed[@]} -eq 0 && ${#missing_remote[@]} -eq 0 ]]; then
echo -e "${GREEN} Templates in sync.${NC}"
return 0
fi
echo -e "${YELLOW} Template drift detected:${NC}"
for f in "${changed[@]+"${changed[@]}"}"; do
[[ -n "$f" ]] && echo -e " ${YELLOW}CHANGED${NC} $f"
done
for f in "${missing_remote[@]+"${missing_remote[@]}"}"; do
[[ -n "$f" ]] && echo -e " ${YELLOW}MISSING${NC} $f (not on server)"
done
if [[ "$SYNC_TEMPLATES" == true ]]; then
echo -e "${CYAN} Syncing templates...${NC}"
for f in "${changed[@]+"${changed[@]}"}" "${missing_remote[@]+"${missing_remote[@]}"}"; do
[[ -n "$f" ]] && scp "$TEMPLATE_DIR/$f" "${host}:${remote_path}/$f"
done
echo -e "${GREEN} Templates synced to ${host}.${NC}"
else
echo -e "${YELLOW} Run with --sync-templates to push changes.${NC}"
fi
}
check_templates
# --- Cached image report ---
declare -A API_CONTAINER=([dev]="dev_pd_database" [prod]="pd_api")
report_cache() {
local host="${DEPLOY_HOST[$ENV]}"
local container="${API_CONTAINER[$ENV]}"
echo -e "${CYAN}Cached card images on ${host} (${container}):${NC}"
ssh "$host" "
png_count=\$(docker exec $container find /app/storage/cards -name '*.png' 2>/dev/null | wc -l)
apng_count=\$(docker exec $container find /app/storage/cards -name '*.apng' 2>/dev/null | wc -l)
echo \" PNG: \${png_count} files\"
echo \" APNG: \${apng_count} files\"
echo \" Total: \$((\${png_count} + \${apng_count})) files\"
" 2>/dev/null || echo -e "${YELLOW} Could not reach ${host} — skipping cache report.${NC}"
}
report_cache
case "$ENV" in
dev)
echo -e "${YELLOW}Deploying to dev...${NC}"
echo -e " Commit: ${SHA_SHORT}"
git tag -f dev "$SHA"
git push "$REMOTE" dev --force
echo -e "${GREEN}Tagged ${SHA_SHORT} as 'dev' and pushed. CI will build :dev image.${NC}"
;;
prod)
echo -e "${YELLOW}Deploying to prod...${NC}"
YEAR=$(date -u +%Y)
MONTH=$(date -u +%-m)
PREFIX="${YEAR}.${MONTH}."
LAST_BUILD=$(git tag -l "${PREFIX}*" | sed "s/^${PREFIX}//" | sort -n | tail -1)
BUILD=$((${LAST_BUILD:-0} + 1))
CALVER="${PREFIX}${BUILD}"
echo -e " Commit: ${SHA_SHORT}"
echo -e " Version: ${CALVER}"
echo -e " Tags: ${CALVER}, latest, production"
read -rp "Proceed? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || {
echo "Aborted."
exit 0
}
git tag "$CALVER" "$SHA"
git tag -f latest "$SHA"
git tag -f production "$SHA"
git push "$REMOTE" "$CALVER"
git push "$REMOTE" latest --force
git push "$REMOTE" production --force
echo -e "${GREEN}Tagged ${SHA_SHORT} as '${CALVER}', 'latest', 'production' and pushed.${NC}"
echo -e "${GREEN}CI will build :${CALVER}, :latest, :production images.${NC}"
;;
*)
usage
;;
esac

View File

@ -2,27 +2,9 @@
<html lang="en">
<head>
{% include 'style.html' %}
{% include 'tier_style.html' %}
</head>
<body>
<div id="fullCard" style="width: 1200px; height: 600px;">
{% if refractor_tier is defined and refractor_tier > 0 %}
{%- set diamond_colors = {
1: {'color': '#1a6b1a', 'highlight': '#40b040'},
2: {'color': '#2070b0', 'highlight': '#50a0e8'},
3: {'color': '#a82020', 'highlight': '#e85050'},
4: {'color': '#6b2d8e', 'highlight': '#a060d0'},
} -%}
{%- set dc = diamond_colors[refractor_tier] -%}
{%- set filled_bg = 'linear-gradient(135deg, ' ~ dc.highlight ~ ' 0%, ' ~ dc.color ~ ' 50%, ' ~ dc.color ~ ' 100%)' -%}
<div class="tier-diamond-backing"></div>
<div class="tier-diamond{% if refractor_tier == 4 %} diamond-glow{% endif %}">
<div class="diamond-quad{% if refractor_tier >= 2 %} filled{% endif %}" {% if refractor_tier >= 2 %}style="background: {{ filled_bg }};"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 1 %} filled{% endif %}" {% if refractor_tier >= 1 %}style="background: {{ filled_bg }};"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 3 %} filled{% endif %}" {% if refractor_tier >= 3 %}style="background: {{ filled_bg }};"{% endif %}></div>
<div class="diamond-quad{% if refractor_tier >= 4 %} filled{% endif %}" {% if refractor_tier >= 4 %}style="background: {{ filled_bg }};"{% endif %}></div>
</div>
{% endif %}
<div id="header" class="row-wrapper header-text border-bot" style="height: 65px">
<!-- <div id="headerLeft" style="flex-grow: 3; height: auto">-->
<div id="headerLeft" style="width: 477px; height: auto">

View File

@ -1,229 +0,0 @@
<style>
#fullCard {
position: relative;
overflow: hidden;
}
</style>
{% if refractor_tier is defined and refractor_tier > 0 %}
<style>
.tier-diamond-backing,
.tier-diamond {
position: absolute;
left: 597px;
top: 78.5px;
transform: translate(-50%, -50%) rotate(45deg);
border-radius: 2px;
pointer-events: none;
}
.tier-diamond-backing {
width: 44px;
height: 44px;
background: rgba(200,210,220,0.9);
z-index: 19;
}
.tier-diamond {
display: grid;
grid-template: 1fr 1fr / 1fr 1fr;
gap: 2px;
z-index: 20;
pointer-events: none;
background: transparent;
border-radius: 2px;
box-shadow: 0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5);
}
.diamond-quad {
width: 19px;
height: 19px;
background: rgba(0,0,0,0.55);
}
.diamond-quad.filled {
box-shadow: inset 0 1px 2px rgba(255,255,255,0.45),
inset 0 -1px 2px rgba(0,0,0,0.35),
inset 1px 0 2px rgba(255,255,255,0.15);
}
{% if refractor_tier == 1 %}
/* T1 — Base Chrome */
#header {
background: linear-gradient(135deg, rgba(185,195,210,0.25) 0%, rgba(210,218,228,0.35) 50%, rgba(185,195,210,0.25) 100%), #ffffff;
}
.border-bot {
border-bottom-color: #8e9baf;
border-bottom-width: 4px;
}
#resultHeader.border-bot {
border-bottom-width: 3px;
}
.border-right-thick {
border-right-color: #8e9baf;
}
.border-right-thin {
border-right-color: #8e9baf;
}
.vline {
border-left-color: #8e9baf;
}
{% elif refractor_tier == 2 %}
/* T2 — Refractor */
#header {
background: linear-gradient(135deg, rgba(100,155,230,0.28) 0%, rgba(155,90,220,0.18) 25%, rgba(90,200,210,0.24) 50%, rgba(185,80,170,0.16) 75%, rgba(100,155,230,0.28) 100%), #ffffff;
}
#fullCard {
box-shadow: inset 0 0 14px 3px rgba(90,143,207,0.22);
}
.border-bot {
border-bottom-color: #7a9cc4;
border-bottom-width: 4px;
}
#resultHeader .border-right-thick {
border-right-width: 6px;
}
.border-right-thick {
border-right-color: #7a9cc4;
}
.border-right-thin {
border-right-color: #7a9cc4;
border-right-width: 3px;
}
.vline {
border-left-color: #7a9cc4;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(60,110,200,1), rgba(100,55,185,0.55), rgba(60,110,200,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(190,35,80,1), rgba(165,25,100,0.55), rgba(190,35,80,1));
}
{% elif refractor_tier == 3 %}
/* T3 — Gold Refractor */
#header {
background: linear-gradient(135deg, rgba(195,155,35,0.26) 0%, rgba(235,200,70,0.2) 50%, rgba(195,155,35,0.26) 100%), #ffffff;
overflow: hidden;
position: relative;
}
#fullCard {
box-shadow: inset 0 0 16px 4px rgba(200,165,48,0.22);
}
.border-bot {
border-bottom-color: #c9a94e;
border-bottom-width: 4px;
}
.border-right-thick {
border-right-color: #c9a94e;
}
.border-right-thin {
border-right-color: #c9a94e;
border-right-width: 3px;
}
.vline {
border-left-color: #c9a94e;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
/* T3 shimmer animation — paused for static PNG capture */
@keyframes t3-shimmer {
0% { transform: translateX(-130%); }
100% { transform: translateX(230%); }
}
#header::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(
105deg,
transparent 38%,
rgba(255,240,140,0.18) 44%,
rgba(255,220,80,0.38) 50%,
rgba(255,200,60,0.30) 53%,
rgba(255,240,140,0.14) 58%,
transparent 64%
);
pointer-events: none;
z-index: 5;
animation: t3-shimmer 2.5s ease-in-out infinite;
animation-play-state: paused;
}
{% elif refractor_tier == 4 %}
/* T4 — Superfractor */
#header {
background: #ffffff;
overflow: hidden;
position: relative;
}
#fullCard {
box-shadow: inset 0 0 22px 6px rgba(45,212,191,0.28), inset 0 0 39px 9px rgba(200,165,48,0.15);
}
.border-bot {
border-bottom-color: #c9a94e;
border-bottom-width: 4px;
}
.border-right-thick {
border-right-color: #c9a94e;
}
.border-right-thin {
border-right-color: #c9a94e;
}
.vline {
border-left-color: #c9a94e;
}
.blue-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(195,160,40,1), rgba(220,185,60,0.55), rgba(195,160,40,1));
}
/* T4 prismatic header sweep — paused for static PNG capture */
@keyframes t4-prismatic-sweep {
0% { transform: translateX(0%); }
100% { transform: translateX(-50%); }
}
#header::after {
content: '';
position: absolute;
top: 0; left: 0;
width: 200%; height: 100%;
background: linear-gradient(135deg,
transparent 2%, rgba(255,100,100,0.28) 8%, rgba(255,200,50,0.32) 14%,
rgba(100,255,150,0.30) 20%, rgba(50,190,255,0.32) 26%, rgba(140,80,255,0.28) 32%,
rgba(255,100,180,0.24) 38%, transparent 44%,
transparent 52%, rgba(255,100,100,0.28) 58%, rgba(255,200,50,0.32) 64%,
rgba(100,255,150,0.30) 70%, rgba(50,190,255,0.32) 76%, rgba(140,80,255,0.28) 82%,
rgba(255,100,180,0.24) 88%, transparent 94%
);
z-index: 1;
pointer-events: none;
animation: t4-prismatic-sweep 6s linear infinite;
animation-play-state: paused;
}
#header > * { z-index: 2; }
/* T4 diamond glow pulse — paused for static PNG */
@keyframes diamond-glow-pulse {
0%, 100% { box-shadow: 0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5),
0 0 8px 2px rgba(107,45,142,0.6); }
50% { box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5), 0 2px 4px rgba(0,0,0,0.3),
0 0 14px 5px rgba(107,45,142,0.8),
0 0 24px 8px rgba(107,45,142,0.3); }
}
.tier-diamond.diamond-glow {
animation: diamond-glow-pulse 2s ease-in-out infinite;
animation-play-state: paused;
}
{% endif %}
</style>
{% endif %}

View File

@ -44,11 +44,10 @@ from app.db_engine import (
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
BattingCard,
PitchingCard,
RefractorTrack,
RefractorCardState,
RefractorBoostAudit,
RefractorTierBoost,
RefractorCosmetic,
ScoutOpportunity,
ScoutClaim,
)
@ -79,9 +78,8 @@ _TEST_MODELS = [
ScoutClaim,
RefractorTrack,
RefractorCardState,
BattingCard,
PitchingCard,
RefractorBoostAudit,
RefractorTierBoost,
RefractorCosmetic,
]

View File

@ -1,350 +0,0 @@
"""
Unit tests for app/services/card_storage.py S3 upload utility.
This module covers:
- S3 key construction for variant cards (batting, pitching, zero-padded cardset)
- Full S3 URL construction with cache-busting date param
- put_object call validation (correct params, return value)
- End-to-end backfill: read PNG from disk, upload to S3, update DB row
Why we test S3 key construction separately:
The key format is a contract used by both the renderer and the URL builder.
Validating it in isolation catches regressions before they corrupt stored URLs.
Why we test URL construction separately:
The cache-bust param (?d=...) must be appended consistently so that clients
invalidate cached images after a re-render. Testing it independently prevents
the formatter from silently changing.
Why we test upload params:
ContentType and CacheControl must be set exactly so that S3 serves images
with the correct headers. A missing header is a silent misconfiguration.
Why we test backfill error swallowing:
The backfill function is called as a background task it must never raise
exceptions that would abort a card render response. We verify that S3 failures
and missing files are both silently logged, not propagated.
Test isolation:
All tests use unittest.mock; no real S3 calls or DB connections are made.
The `backfill_variant_image_url` tests patch `get_s3_client` and the DB
model classes at the card_storage module level so lazy imports work correctly.
"""
import os
from datetime import date
from unittest.mock import MagicMock, patch
# Set env before importing module so db_engine doesn't try to connect
os.environ.setdefault("DATABASE_TYPE", "postgresql")
os.environ.setdefault("POSTGRES_PASSWORD", "test-dummy")
from app.services.card_storage import (
build_s3_key,
build_s3_url,
upload_card_to_s3,
backfill_variant_image_url,
S3_BUCKET,
S3_REGION,
)
# ---------------------------------------------------------------------------
# TestBuildS3Key
# ---------------------------------------------------------------------------
class TestBuildS3Key:
"""Tests for build_s3_key — S3 object key construction.
The key format must match the existing card-creation pipeline so that
the database API and card-creation tool write to the same S3 paths.
"""
def test_batting_card_key(self):
"""batting card type produces 'battingcard.png' in the key."""
key = build_s3_key(cardset_id=27, player_id=42, variant=1, card_type="batting")
assert key == "cards/cardset-027/player-42/v1/battingcard.png"
def test_pitching_card_key(self):
"""pitching card type produces 'pitchingcard.png' in the key."""
key = build_s3_key(cardset_id=27, player_id=99, variant=2, card_type="pitching")
assert key == "cards/cardset-027/player-99/v2/pitchingcard.png"
def test_cardset_zero_padded_to_three_digits(self):
"""Single-digit cardset IDs are zero-padded to three characters."""
key = build_s3_key(cardset_id=5, player_id=1, variant=0, card_type="batting")
assert "cardset-005" in key
def test_cardset_two_digit_zero_padded(self):
"""Two-digit cardset IDs are zero-padded correctly."""
key = build_s3_key(cardset_id=27, player_id=1, variant=0, card_type="batting")
assert "cardset-027" in key
def test_cardset_three_digit_no_padding(self):
"""Three-digit cardset IDs are not altered."""
key = build_s3_key(cardset_id=100, player_id=1, variant=0, card_type="batting")
assert "cardset-100" in key
def test_variant_included_in_key(self):
"""Variant number is included in the path so variants have distinct keys."""
key_v0 = build_s3_key(
cardset_id=27, player_id=1, variant=0, card_type="batting"
)
key_v3 = build_s3_key(
cardset_id=27, player_id=1, variant=3, card_type="batting"
)
assert "/v0/" in key_v0
assert "/v3/" in key_v3
assert key_v0 != key_v3
# ---------------------------------------------------------------------------
# TestBuildS3Url
# ---------------------------------------------------------------------------
class TestBuildS3Url:
"""Tests for build_s3_url — full URL construction with cache-bust param.
The URL format must be predictable so clients can construct and verify
image URLs without querying the database.
"""
def test_url_contains_bucket_and_region(self):
"""URL includes bucket name and region in the S3 hostname."""
key = "cards/cardset-027/player-42/v1/battingcard.png"
render_date = date(2026, 4, 6)
url = build_s3_url(key, render_date)
assert S3_BUCKET in url
assert S3_REGION in url
def test_url_contains_s3_key(self):
"""URL path includes the full S3 key."""
key = "cards/cardset-027/player-42/v1/battingcard.png"
render_date = date(2026, 4, 6)
url = build_s3_url(key, render_date)
assert key in url
def test_url_has_cache_bust_param(self):
"""URL ends with ?d=<render_date> for cache invalidation."""
key = "cards/cardset-027/player-42/v1/battingcard.png"
render_date = date(2026, 4, 6)
url = build_s3_url(key, render_date)
assert "?d=2026-04-06" in url
def test_url_format_full(self):
"""Full URL matches expected S3 pattern exactly."""
key = "cards/cardset-027/player-1/v0/battingcard.png"
render_date = date(2025, 11, 8)
url = build_s3_url(key, render_date)
expected = (
f"https://{S3_BUCKET}.s3.{S3_REGION}.amazonaws.com/{key}?d=2025-11-08"
)
assert url == expected
# ---------------------------------------------------------------------------
# TestUploadCardToS3
# ---------------------------------------------------------------------------
class TestUploadCardToS3:
"""Tests for upload_card_to_s3 — S3 put_object call validation.
We verify the exact parameters passed to put_object so that S3 serves
images with the correct Content-Type and Cache-Control headers.
"""
def test_put_object_called_with_correct_params(self):
"""put_object is called once with bucket, key, body, ContentType, CacheControl."""
mock_client = MagicMock()
png_bytes = b"\x89PNG\r\n\x1a\n"
s3_key = "cards/cardset-027/player-42/v1/battingcard.png"
upload_card_to_s3(mock_client, png_bytes, s3_key)
mock_client.put_object.assert_called_once_with(
Bucket=S3_BUCKET,
Key=s3_key,
Body=png_bytes,
ContentType="image/png",
CacheControl="public, max-age=300",
)
def test_upload_returns_none(self):
"""upload_card_to_s3 returns None (callers should not rely on a return value)."""
mock_client = MagicMock()
result = upload_card_to_s3(mock_client, b"PNG", "some/key.png")
assert result is None
# ---------------------------------------------------------------------------
# TestBackfillVariantImageUrl
# ---------------------------------------------------------------------------
class TestBackfillVariantImageUrl:
"""Tests for backfill_variant_image_url — end-to-end disk→S3→DB path.
The function is fire-and-forget: it reads a PNG from disk, uploads to S3,
then updates the appropriate card model's image_url. All errors are caught
and logged; the function must never raise.
Test strategy:
- Use tmp_path for temporary PNG files so no filesystem state leaks.
- Patch get_s3_client at the module level to intercept the S3 call.
- Patch BattingCard/PitchingCard at the module level (lazy import target).
"""
def test_batting_card_image_url_updated(self, tmp_path):
"""BattingCard.image_url is updated after a successful upload."""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"\x89PNG\r\n\x1a\n fake png data")
mock_s3 = MagicMock()
mock_card = MagicMock()
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard") as MockBatting,
):
MockBatting.get.return_value = mock_card
backfill_variant_image_url(
player_id=42,
variant=1,
card_type="batting",
cardset_id=27,
png_path=str(png_path),
)
MockBatting.get.assert_called_once_with(
MockBatting.player_id == 42, MockBatting.variant == 1
)
assert mock_card.image_url is not None
mock_card.save.assert_called_once()
def test_pitching_card_image_url_updated(self, tmp_path):
"""PitchingCard.image_url is updated after a successful upload."""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"\x89PNG\r\n\x1a\n fake png data")
mock_s3 = MagicMock()
mock_card = MagicMock()
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.PitchingCard") as MockPitching,
):
MockPitching.get.return_value = mock_card
backfill_variant_image_url(
player_id=99,
variant=2,
card_type="pitching",
cardset_id=27,
png_path=str(png_path),
)
MockPitching.get.assert_called_once_with(
MockPitching.player_id == 99, MockPitching.variant == 2
)
assert mock_card.image_url is not None
mock_card.save.assert_called_once()
def test_s3_upload_called_with_png_bytes(self, tmp_path):
"""The PNG bytes read from disk are passed to put_object."""
png_bytes = b"\x89PNG\r\n\x1a\n real png content"
png_path = tmp_path / "card.png"
png_path.write_bytes(png_bytes)
mock_s3 = MagicMock()
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard") as MockBatting,
):
MockBatting.get.return_value = MagicMock()
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=5,
png_path=str(png_path),
)
mock_s3.put_object.assert_called_once()
call_kwargs = mock_s3.put_object.call_args.kwargs
assert call_kwargs["Body"] == png_bytes
def test_s3_error_is_swallowed(self, tmp_path):
"""If S3 raises an exception, backfill swallows it and returns normally.
The function is called as a background task it must never propagate
exceptions that would abort the calling request handler.
"""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"PNG data")
mock_s3 = MagicMock()
mock_s3.put_object.side_effect = Exception("S3 connection refused")
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard"),
):
# Must not raise
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=27,
png_path=str(png_path),
)
def test_missing_file_is_swallowed(self, tmp_path):
"""If the PNG file does not exist, backfill swallows the error and returns.
Render failures may leave no file on disk; the background task must
handle this gracefully rather than crashing the request.
"""
missing_path = str(tmp_path / "nonexistent.png")
with (
patch("app.services.card_storage.get_s3_client"),
patch("app.services.card_storage.BattingCard"),
):
# Must not raise
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=27,
png_path=missing_path,
)
def test_db_error_is_swallowed(self, tmp_path):
"""If the DB save raises, backfill swallows it and returns normally."""
png_path = tmp_path / "card.png"
png_path.write_bytes(b"PNG data")
mock_s3 = MagicMock()
mock_card = MagicMock()
mock_card.save.side_effect = Exception("DB connection lost")
with (
patch("app.services.card_storage.get_s3_client", return_value=mock_s3),
patch("app.services.card_storage.BattingCard") as MockBatting,
):
MockBatting.get.return_value = mock_card
# Must not raise
backfill_variant_image_url(
player_id=1,
variant=0,
card_type="batting",
cardset_id=27,
png_path=str(png_path),
)

View File

@ -48,39 +48,35 @@ import os
os.environ.setdefault("API_TOKEN", "test-token")
import app.services.season_stats as _season_stats_module
import app.services.refractor_boost as _refractor_boost_module
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
BattingCard,
BattingCardRatings,
Cardset,
Decision,
Event,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
MlbPlayer,
Pack,
PackType,
PitchingCard,
PitchingCardRatings,
Player,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
Rarity,
RefractorBoostAudit,
RefractorCardState,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Decision,
Team,
Card,
Event,
)
# ---------------------------------------------------------------------------
@ -115,20 +111,15 @@ _WP13_MODELS = [
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
BattingCard,
BattingCardRatings,
PitchingCard,
PitchingCardRatings,
RefractorTrack,
RefractorCardState,
RefractorBoostAudit,
RefractorTierBoost,
RefractorCosmetic,
]
# Patch the service-layer 'db' references to use our shared test database so
# that db.atomic() in update_season_stats() and apply_tier_boost() operate on
# the same connection.
# Patch the service-layer 'db' reference to use our shared test database so
# that db.atomic() in update_season_stats() operates on the same connection.
_season_stats_module.db = _wp13_db
_refractor_boost_module.db = _wp13_db
# ---------------------------------------------------------------------------
# Auth header used by every authenticated request
@ -332,65 +323,6 @@ def _make_state(
)
# Base batter ratings that sum to exactly 108 for use in tier advancement tests.
# apply_tier_boost() requires a base card (variant=0) with ratings rows to
# create boosted variant cards — tests that push past T1 must set this up.
_WP13_BASE_BATTER_RATINGS = {
"homerun": 3.0,
"bp_homerun": 1.0,
"triple": 0.5,
"double_three": 2.0,
"double_two": 2.0,
"double_pull": 6.0,
"single_two": 4.0,
"single_one": 12.0,
"single_center": 5.0,
"bp_single": 2.0,
"hbp": 3.0,
"walk": 7.0,
"strikeout": 15.0,
"lineout": 3.0,
"popout": 2.0,
"flyout_a": 5.0,
"flyout_bq": 4.0,
"flyout_lf_b": 3.0,
"flyout_rf_b": 9.0,
"groundout_a": 6.0,
"groundout_b": 8.0,
"groundout_c": 5.5,
}
def _make_base_batter_card(player):
"""Create a BattingCard (variant=0) with two ratings rows for apply_tier_boost()."""
card = BattingCard.create(
player=player,
variant=0,
steal_low=1,
steal_high=6,
steal_auto=False,
steal_jump=0.5,
bunting="C",
hit_and_run="B",
running=3,
offense_col=2,
hand="R",
)
for vs_hand in ("L", "R"):
BattingCardRatings.create(
battingcard=card,
vs_hand=vs_hand,
pull_rate=0.4,
center_rate=0.35,
slap_rate=0.25,
avg=0.300,
obp=0.370,
slg=0.450,
**_WP13_BASE_BATTER_RATINGS,
)
return card
# ---------------------------------------------------------------------------
# Tests: POST /api/v2/season-stats/update-game/{game_id}
# ---------------------------------------------------------------------------
@ -554,8 +486,6 @@ def test_evaluate_game_tier_advancement(client):
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Tier Adv Track")
_make_state(batter, team_a, track, current_tier=0, current_value=34.0)
# Phase 2: base card required so apply_tier_boost() can create a variant.
_make_base_batter_card(batter)
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
BattingSeasonStats.create(
@ -637,8 +567,6 @@ def test_evaluate_game_tier_ups_in_response(client):
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 Tier-Ups Track")
_make_state(batter, team_a, track, current_tier=0)
# Phase 2: base card required so apply_tier_boost() can create a variant.
_make_base_batter_card(batter)
# Seed prior stats below threshold
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
@ -870,432 +798,3 @@ def test_evaluate_game_error_isolation(client, monkeypatch):
# The failing player must not appear in tier_ups
failing_ids = [tu["player_id"] for tu in data["tier_ups"]]
assert fail_player_id not in failing_ids
# ---------------------------------------------------------------------------
# Base pitcher card ratings that sum to exactly 108 for use in pitcher tier
# advancement tests.
# Variable columns (18): sum to 79.
# X-check columns (9): sum to 29.
# Total: 108.
# ---------------------------------------------------------------------------
_WP13_BASE_PITCHER_RATINGS = {
# 18 variable outcome columns (sum = 79)
"homerun": 2.0,
"bp_homerun": 1.0,
"triple": 0.5,
"double_three": 1.5,
"double_two": 2.0,
"double_cf": 2.0,
"single_two": 3.0,
"single_one": 4.0,
"single_center": 3.0,
"bp_single": 2.0,
"hbp": 1.0,
"walk": 3.0,
"strikeout": 30.0,
"flyout_lf_b": 4.0,
"flyout_cf_b": 5.0,
"flyout_rf_b": 5.0,
"groundout_a": 5.0,
"groundout_b": 5.0,
# 9 x-check columns (sum = 29)
"xcheck_p": 4.0,
"xcheck_c": 3.0,
"xcheck_1b": 3.0,
"xcheck_2b": 3.0,
"xcheck_3b": 3.0,
"xcheck_ss": 3.0,
"xcheck_lf": 3.0,
"xcheck_cf": 3.0,
"xcheck_rf": 4.0,
}
def _make_base_pitcher_card(player):
"""Create a PitchingCard (variant=0) with two ratings rows for apply_tier_boost().
Analogous to _make_base_batter_card but for pitcher cards. Ratings are
seeded from _WP13_BASE_PITCHER_RATINGS which satisfies the 108-sum invariant
required by apply_tier_boost() (18 variable cols summing to 79 plus 9
x-check cols summing to 29 = 108 total).
"""
card = PitchingCard.create(
player=player,
variant=0,
balk=1,
wild_pitch=2,
hold=3,
starter_rating=7,
relief_rating=5,
closer_rating=None,
batting=None,
offense_col=1,
hand="R",
)
for vs_hand in ("L", "R"):
PitchingCardRatings.create(
pitchingcard=card,
vs_hand=vs_hand,
avg=0.250,
obp=0.310,
slg=0.360,
**_WP13_BASE_PITCHER_RATINGS,
)
return card
# ---------------------------------------------------------------------------
# Gap 1: REFRACTOR_BOOST_ENABLED=false kill switch
# ---------------------------------------------------------------------------
def test_evaluate_game_boost_disabled_skips_tier_up(client, monkeypatch):
"""When REFRACTOR_BOOST_ENABLED=false, tier-ups are not reported even if formula says tier-up.
What: Seed a batter at tier=0 with stats above T1 (pa=34 prior + 4-PA game
pushes total to 38 > T1 threshold of 37). Set REFRACTOR_BOOST_ENABLED=false
before calling evaluate-game.
Why: The kill switch must suppress all tier-up notifications and leave
current_tier unchanged so that no variant card is created and no Discord
announcement is sent. If the kill switch is ignored the bot will announce
tier-ups during maintenance windows when card creation is deliberately
disabled.
"""
monkeypatch.setenv("REFRACTOR_BOOST_ENABLED", "false")
team_a = _make_team("BD1", gmid=20201)
team_b = _make_team("BD2", gmid=20202)
batter = _make_player("WP13 KillSwitch Batter")
pitcher = _make_player("WP13 KillSwitch Pitcher", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 KillSwitch Track")
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
_make_base_batter_card(batter)
# Seed prior stats just below T1
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
# Game adds 4 PA — total = 38 > T1 (37)
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# Kill switch: the boost block is bypassed so apply_tier_boost() is never
# called and current_tier must remain 0 in the DB.
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 0
# No BattingCard variant must have been created (boost never ran).
from app.services.refractor_boost import compute_variant_hash
t1_hash = compute_variant_hash(batter.player_id, 1)
assert (
BattingCard.get_or_none(
(BattingCard.player == batter) & (BattingCard.variant == t1_hash)
)
is None
), "Variant card must not be created when boost is disabled"
# When boost is disabled, no tier_up notification is sent — the router
# skips the append entirely to prevent false notifications to the bot.
assert len(data["tier_ups"]) == 0
# ---------------------------------------------------------------------------
# Gap 4: Multi-tier jump T0 -> T2 at HTTP layer
# ---------------------------------------------------------------------------
def test_evaluate_game_multi_tier_jump(client):
"""Player with stats above T2 threshold jumps from T0 to T2 in one game.
What: Seed a batter at tier=0 with no prior stats. The game itself
provides stats in range [T2=149, T3=448).
Using pa=50, hit=50 (all singles): value = 50 + 50*2 = 150.
Why: The evaluate-game loop must iterate through each tier from old+1 to
computed_tier, calling apply_tier_boost() once per tier. A multi-tier jump
must produce variant cards for every intermediate tier and report a single
tier_up entry whose new_tier equals the highest tier reached.
The variant_created in the response must match the T2 hash (not T1), because
the last apply_tier_boost() call returns the T2 variant.
"""
from app.services.refractor_boost import compute_variant_hash
team_a = _make_team("MJ1", gmid=20211)
team_b = _make_team("MJ2", gmid=20212)
batter = _make_player("WP13 MultiJump Batter")
pitcher = _make_player("WP13 MultiJump Pitcher", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 MultiJump Track")
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
_make_base_batter_card(batter)
# Target value in range [T2=149, T3=448).
# formula: pa + tb*2, tb = singles + 2*doubles + 3*triples + 4*HR.
# 50 PA, 50 hits (all singles): tb = 50; value = 50 + 50*2 = 150.
# 150 >= T2 (149) and < T3 (448) so tier lands exactly at 2.
for i in range(50):
_make_play(
game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, hit=1, outs=0
)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# Must have exactly one tier_up entry for this player.
assert len(data["tier_ups"]) == 1
tu = data["tier_ups"][0]
assert tu["old_tier"] == 0
assert tu["new_tier"] == 2
# The variant_created must match T2 hash (last boost iteration).
expected_t2_hash = compute_variant_hash(batter.player_id, 2)
assert tu["variant_created"] == expected_t2_hash
# Both T1 and T2 variant BattingCard rows must exist.
t1_hash = compute_variant_hash(batter.player_id, 1)
t2_hash = compute_variant_hash(batter.player_id, 2)
assert (
BattingCard.get_or_none(
(BattingCard.player == batter) & (BattingCard.variant == t1_hash)
)
is not None
), "T1 variant card missing"
assert (
BattingCard.get_or_none(
(BattingCard.player == batter) & (BattingCard.variant == t2_hash)
)
is not None
), "T2 variant card missing"
# DB state must reflect T2.
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 2
# ---------------------------------------------------------------------------
# Gap 5: Pitcher through evaluate-game
# ---------------------------------------------------------------------------
def test_evaluate_game_pitcher_tier_advancement(client):
"""Pitcher reaching T1 through evaluate-game creates a boosted PitchingCard variant.
What: Create a pitcher player with a PitchingCard + PitchingCardRatings
(variant=0) and a RefractorCardState on the 'sp' track. Seed
PitchingSeasonStats with outs and strikeouts just below T1 (prior season),
then add a game where the pitcher appears and records enough additional outs
to cross the threshold.
The pitcher formula is: outs/3 + strikeouts. Track thresholds are the same
(t1=37). Prior season: outs=60, strikeouts=16 -> value = 20 + 16 = 36.
Game adds 3 outs + 1 K -> career total outs=63, strikeouts=17 -> 21+17=38.
Why: Pitcher boost must follow the same evaluate-game flow as batter boost.
If card_type='sp' is not handled, the pitcher track silently skips the boost
and no tier_ups entry is emitted even when the threshold is passed.
"""
team_a = _make_team("PT1", gmid=20221)
team_b = _make_team("PT2", gmid=20222)
pitcher = _make_player("WP13 TierPitcher", pos="SP")
# We need a batter for the play records (pitcher is pitcher side).
batter = _make_player("WP13 PitcherTest Batter")
game = _make_game(team_a, team_b)
sp_track, _ = RefractorTrack.get_or_create(
name="WP13 SP Track",
defaults=dict(
card_type="sp",
formula="outs / 3 + strikeouts",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
_make_state(pitcher, team_a, sp_track, current_tier=0, current_value=0.0)
_make_base_pitcher_card(pitcher)
# Prior season: outs=60, K=16 -> 60/3 + 16 = 36 (below T1=37)
PitchingSeasonStats.create(
player=pitcher,
team=team_a,
season=10,
outs=60,
strikeouts=16,
)
# Game: pitcher records 3 outs (1 inning) and 1 K.
# Career after game: outs=63, K=17 -> 63/3 + 17 = 21 + 17 = 38 > T1=37.
_make_play(
game,
1,
batter,
team_b,
pitcher,
team_a,
pa=1,
ab=1,
outs=3,
so=1,
)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# The pitcher must appear in tier_ups.
pitcher_ups = [
tu for tu in data["tier_ups"] if tu["player_id"] == pitcher.player_id
]
assert len(pitcher_ups) == 1, (
f"Expected 1 tier_up for pitcher, got: {data['tier_ups']}"
)
tu = pitcher_ups[0]
assert tu["old_tier"] == 0
assert tu["new_tier"] >= 1
# A boosted PitchingCard variant must exist in the database.
from app.services.refractor_boost import compute_variant_hash
t1_hash = compute_variant_hash(pitcher.player_id, 1)
variant_card = PitchingCard.get_or_none(
(PitchingCard.player == pitcher) & (PitchingCard.variant == t1_hash)
)
assert variant_card is not None, "T1 PitchingCard variant was not created"
# ---------------------------------------------------------------------------
# Gap 7: variant_created field in tier_up response
# ---------------------------------------------------------------------------
def test_evaluate_game_tier_up_includes_variant_created(client):
"""Tier-up response includes variant_created with the correct hash.
What: Seed a batter at tier=0 with stats that push past T1. After
evaluate-game, the tier_ups entry must contain a 'variant_created' key
whose value matches compute_variant_hash(player_id, 1) and is a positive
non-zero integer.
Why: The bot reads variant_created to update the card image URL after a
tier-up. A missing or incorrect hash will point the bot at the wrong card
image (or no image at all), breaking the tier-up animation in Discord.
"""
from app.services.refractor_boost import compute_variant_hash
team_a = _make_team("VC1", gmid=20231)
team_b = _make_team("VC2", gmid=20232)
batter = _make_player("WP13 VariantCreated Batter")
pitcher = _make_player("WP13 VariantCreated Pitcher", pos="SP")
game = _make_game(team_a, team_b)
track = _make_track(name="WP13 VariantCreated Track")
_make_state(batter, team_a, track, current_tier=0, current_value=0.0)
_make_base_batter_card(batter)
# Prior season: pa=34, well below T1=37
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
# Game: 4 PA -> total pa=38 > T1=37
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
assert len(data["tier_ups"]) == 1
tu = data["tier_ups"][0]
# variant_created must be present, non-zero, and match the T1 hash.
assert "variant_created" in tu, "variant_created key missing from tier_up entry"
assert isinstance(tu["variant_created"], int)
assert tu["variant_created"] != 0
expected_hash = compute_variant_hash(batter.player_id, 1)
assert tu["variant_created"] == expected_hash
# ---------------------------------------------------------------------------
# Gap 8: Empty card_type on track produces no tier-up
# ---------------------------------------------------------------------------
def test_evaluate_game_skips_boost_when_track_has_no_card_type(client):
"""Track with empty card_type produces no tier-up notification.
What: Create a RefractorTrack with card_type="" (empty string) and seed a
batter with stats above T1. Call evaluate-game.
Why: apply_tier_boost() requires a valid card_type to know which card model
to use. When card_type is empty or None the boost cannot run. The endpoint
must log a warning and skip the tier-up notification entirely it must NOT
report a tier-up that was never applied to the database. Reporting a phantom
tier-up would cause the bot to announce a card upgrade that does not exist.
"""
team_a = _make_team("NC1", gmid=20241)
team_b = _make_team("NC2", gmid=20242)
batter = _make_player("WP13 NoCardType Batter")
pitcher = _make_player("WP13 NoCardType Pitcher", pos="SP")
game = _make_game(team_a, team_b)
# Create track with card_type="" — an intentionally invalid/empty value.
empty_type_track, _ = RefractorTrack.get_or_create(
name="WP13 NoCardType Track",
defaults=dict(
card_type="",
formula="pa + tb * 2",
t1_threshold=37,
t2_threshold=149,
t3_threshold=448,
t4_threshold=896,
),
)
_make_state(batter, team_a, empty_type_track, current_tier=0, current_value=0.0)
# Prior stats below T1; game pushes past T1.
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
for i in range(4):
_make_play(game, i + 1, batter, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
client.post(f"/api/v2/season-stats/update-game/{game.id}", headers=AUTH_HEADER)
resp = client.post(
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
)
assert resp.status_code == 200
data = resp.json()
# No tier-up must be reported when card_type is empty.
assert data["tier_ups"] == []
# current_tier must remain 0 — boost was never applied.
state = RefractorCardState.get(
(RefractorCardState.player == batter) & (RefractorCardState.team == team_a)
)
assert state.current_tier == 0

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -187,11 +187,10 @@ def _make_stats(player_id, team_id, season, **kwargs):
)
def _eval(player_id, team_id, dry_run: bool = False):
def _eval(player_id, team_id):
return evaluate_card(
player_id,
team_id,
dry_run=dry_run,
_stats_model=StatsStub,
_state_model=CardStateStub,
_compute_value_fn=_compute_value,
@ -393,20 +392,13 @@ class TestReturnShape:
"""Return dict has the expected keys and types."""
def test_return_keys(self, batter_track):
"""Result dict contains all expected keys.
Phase 2 addition: 'computed_tier' is included alongside 'current_tier'
so that evaluate-game can detect tier-ups without writing the tier
(dry_run=True path). Both keys must always be present.
"""
"""Result dict contains all expected keys."""
_make_state(1, 1, batter_track)
result = _eval(1, 1)
assert set(result.keys()) == {
"player_id",
"team_id",
"current_tier",
"computed_tier",
"computed_fully_evolved",
"current_value",
"fully_evolved",
"last_evaluated_at",
@ -629,176 +621,3 @@ class TestMultiTeamStatIsolation:
assert result_team2["current_tier"] == 2, (
f"Team 2 tier should be T2 for value=180, got {result_team2['current_tier']}"
)
class TestDryRun:
"""dry_run=True writes current_value and last_evaluated_at but NOT current_tier
or fully_evolved, allowing apply_tier_boost() to write tier + variant atomically.
All tests use stats that would produce a tier-up (value=160 T2) on a card
seeded at tier=0, so the delta between dry and non-dry behaviour is obvious.
Stub thresholds (batter): T1=37, T2=149, T3=448, T4=896.
value=160 T2 (149 <= 160 < 448); starting current_tier=0 tier-up to T2.
"""
def test_dry_run_does_not_write_current_tier(self, batter_track):
"""dry_run=True leaves current_tier unchanged in the database.
What: Seed a card at tier=0. Provide stats that would advance to T2
(value=160). Call evaluate_card with dry_run=True. Re-read the DB row
and assert current_tier is still 0.
Why: The dry_run path must not persist the tier so that apply_tier_boost()
can write tier + variant atomically on the next step. If current_tier
were written here, a boost failure would leave the tier advanced with no
corresponding variant, causing an inconsistent state.
"""
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=160)
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.current_tier == 0, (
f"dry_run should not write current_tier; expected 0, got {reloaded.current_tier}"
)
def test_dry_run_does_not_write_fully_evolved(self, batter_track):
"""dry_run=True leaves fully_evolved=False unchanged in the database.
What: Seed a card at tier=0 with fully_evolved=False. Provide stats that
would push to T4 (value=900). Call evaluate_card with dry_run=True.
Re-read the DB row and assert fully_evolved is still False.
Why: fully_evolved follows current_tier and must be written atomically
by apply_tier_boost(). Writing it here would let the flag get out of
sync with the tier if the boost subsequently fails.
"""
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=900) # value=900 → T4 → fully_evolved=True normally
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.fully_evolved is False, (
"dry_run should not write fully_evolved; expected False, "
f"got {reloaded.fully_evolved}"
)
def test_dry_run_writes_current_value(self, batter_track):
"""dry_run=True DOES update current_value in the database.
What: Seed a card with current_value=0. Provide stats giving value=160.
Call evaluate_card with dry_run=True. Re-read the DB row and assert
current_value has been updated to 160.0.
Why: current_value tracks formula progress and is safe to write
at any time it does not affect game logic atomicity, so it is
always persisted regardless of dry_run.
"""
_make_state(1, 1, batter_track, current_value=0.0)
_make_stats(1, 1, 1, pa=160)
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.current_value == 160.0, (
f"dry_run should still write current_value; expected 160.0, "
f"got {reloaded.current_value}"
)
def test_dry_run_writes_last_evaluated_at(self, batter_track):
"""dry_run=True DOES update last_evaluated_at in the database.
What: Seed a card with last_evaluated_at=None. Call evaluate_card with
dry_run=True. Re-read the DB row and assert last_evaluated_at is now a
non-None datetime.
Why: last_evaluated_at is a bookkeeping field used for scheduling and
audit purposes. It is safe to update independently of tier writes
and should always reflect the most recent evaluation attempt.
"""
_make_state(1, 1, batter_track)
_make_stats(1, 1, 1, pa=160)
_eval(1, 1, dry_run=True)
reloaded = CardStateStub.get(
(CardStateStub.player_id == 1) & (CardStateStub.team_id == 1)
)
assert reloaded.last_evaluated_at is not None, (
"dry_run should still write last_evaluated_at; got None"
)
def test_dry_run_returns_computed_tier(self, batter_track):
"""dry_run=True return dict has computed_tier=T2 while current_tier stays 0.
What: Seed at tier=0. Stats value=160 T2. Call dry_run=True.
Assert:
- result["computed_tier"] == 2 (what the formula says)
- result["current_tier"] == 0 (what is stored; unchanged)
Why: Callers use the divergence between computed_tier and current_tier
to detect a pending tier-up. Both keys must be present and correct for
the evaluate-game endpoint to gate apply_tier_boost() correctly.
"""
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=160)
result = _eval(1, 1, dry_run=True)
assert result["computed_tier"] == 2, (
f"computed_tier should reflect formula result T2; got {result['computed_tier']}"
)
assert result["current_tier"] == 0, (
f"current_tier should reflect unchanged DB value 0; got {result['current_tier']}"
)
def test_dry_run_returns_computed_fully_evolved(self, batter_track):
"""dry_run=True sets computed_fully_evolved correctly in the return dict.
What: Two sub-cases:
- Stats value=160 T2: computed_fully_evolved should be False.
- Stats value=900 T4: computed_fully_evolved should be True.
In both cases fully_evolved in the DB remains False (tier not written).
Why: computed_fully_evolved lets callers know whether the pending tier-up
will result in a fully-evolved card without having to re-query the DB
or recalculate the tier themselves. It must match (computed_tier >= 4),
not the stored fully_evolved value.
"""
# Sub-case 1: computed T2 → computed_fully_evolved=False
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=160)
result = _eval(1, 1, dry_run=True)
assert result["computed_fully_evolved"] is False, (
f"computed_fully_evolved should be False for T2; got {result['computed_fully_evolved']}"
)
assert result["fully_evolved"] is False, (
"stored fully_evolved should remain False after dry_run"
)
# Reset for sub-case 2: computed T4 → computed_fully_evolved=True
CardStateStub.delete().execute()
StatsStub.delete().execute()
_make_state(1, 1, batter_track, current_tier=0)
_make_stats(1, 1, 1, pa=900) # value=900 → T4
result2 = _eval(1, 1, dry_run=True)
assert result2["computed_fully_evolved"] is True, (
f"computed_fully_evolved should be True for T4; got {result2['computed_fully_evolved']}"
)
assert result2["fully_evolved"] is False, (
"stored fully_evolved should remain False after dry_run even at T4"
)

View File

@ -1,311 +0,0 @@
"""Tests for image_url field in refractor cards API response.
What: Verifies that GET /api/v2/refractor/cards includes image_url in each card state
item, pulling the URL from the variant BattingCard or PitchingCard row.
Why: The refractor card art pipeline stores rendered card image URLs in the
BattingCard/PitchingCard rows. The Discord bot and website need image_url in
the /refractor/cards response so they can display variant art without a separate
lookup. These tests guard against regressions where image_url is accidentally
dropped from the response serialization.
Test cases:
test_cards_response_includes_image_url -- BattingCard with image_url set; verify
the value appears in the /cards response.
test_cards_response_image_url_null_when_not_set -- BattingCard with image_url=None;
verify null is returned (not omitted).
Uses the shared-memory SQLite TestClient pattern from test_refractor_state_api.py
so no PostgreSQL connection is required.
"""
import os
os.environ.setdefault("API_TOKEN", "test")
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from peewee import SqliteDatabase
from app.db_engine import (
BattingCard,
BattingSeasonStats,
Card,
Cardset,
Decision,
Event,
MlbPlayer,
Pack,
PackType,
PitchingCard,
PitchingSeasonStats,
Player,
ProcessedGame,
Rarity,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
Roster,
RosterSlot,
ScoutClaim,
ScoutOpportunity,
StratGame,
StratPlay,
Team,
)
AUTH_HEADER = {"Authorization": "Bearer test"}
# ---------------------------------------------------------------------------
# SQLite database + model list
# ---------------------------------------------------------------------------
_img_url_db = SqliteDatabase(
"file:imgurlapitest?mode=memory&cache=shared",
uri=True,
pragmas={"foreign_keys": 1},
)
# Full model list matching the existing state API tests — needed so all FK
# constraints resolve in SQLite.
_IMG_URL_MODELS = [
Rarity,
Event,
Cardset,
MlbPlayer,
Player,
BattingCard,
PitchingCard,
Team,
PackType,
Pack,
Card,
Roster,
RosterSlot,
StratGame,
StratPlay,
Decision,
ScoutOpportunity,
ScoutClaim,
BattingSeasonStats,
PitchingSeasonStats,
ProcessedGame,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
@pytest.fixture(autouse=False)
def setup_img_url_db():
"""Bind image-url test models to shared-memory SQLite and create tables.
What: Initialises the in-process SQLite database before each test and drops
all tables afterwards to ensure test isolation.
Why: SQLite shared-memory databases persist between tests in the same
process unless tables are dropped. Creating and dropping around each test
guarantees a clean state without requiring a real PostgreSQL instance.
"""
_img_url_db.bind(_IMG_URL_MODELS)
_img_url_db.connect(reuse_if_open=True)
_img_url_db.create_tables(_IMG_URL_MODELS)
yield _img_url_db
_img_url_db.drop_tables(list(reversed(_IMG_URL_MODELS)), safe=True)
def _build_image_url_app() -> FastAPI:
"""Minimal FastAPI app with refractor router for image_url tests."""
from app.routers_v2.refractor import router as refractor_router
app = FastAPI()
@app.middleware("http")
async def db_middleware(request: Request, call_next):
_img_url_db.connect(reuse_if_open=True)
return await call_next(request)
app.include_router(refractor_router)
return app
@pytest.fixture
def img_url_client(setup_img_url_db):
"""FastAPI TestClient backed by shared-memory SQLite for image_url tests."""
with TestClient(_build_image_url_app()) as c:
yield c
# ---------------------------------------------------------------------------
# Seed helpers
# ---------------------------------------------------------------------------
def _make_rarity():
r, _ = Rarity.get_or_create(
value=10, name="IU_Common", defaults={"color": "#ffffff"}
)
return r
def _make_cardset():
cs, _ = Cardset.get_or_create(
name="IU Test Set",
defaults={"description": "image url test cardset", "total_cards": 1},
)
return cs
def _make_player(name: str = "Test Player") -> Player:
return Player.create(
p_name=name,
rarity=_make_rarity(),
cardset=_make_cardset(),
set_num=1,
pos_1="CF",
image="https://example.com/img.png",
mlbclub="TST",
franchise="TST",
description="image url test",
)
def _make_team(suffix: str = "IU") -> Team:
return Team.create(
abbrev=suffix,
sname=suffix,
lname=f"Team {suffix}",
gmid=99900 + len(suffix),
gmname=f"gm_{suffix.lower()}",
gsheet="https://docs.google.com/iu_test",
wallet=500,
team_value=1000,
collection_value=1000,
season=11,
is_ai=False,
)
def _make_track(card_type: str = "batter") -> RefractorTrack:
track, _ = RefractorTrack.get_or_create(
name=f"IU {card_type} Track",
defaults=dict(
card_type=card_type,
formula="pa",
t1_threshold=100,
t2_threshold=300,
t3_threshold=700,
t4_threshold=1200,
),
)
return track
def _make_batting_card(player: Player, variant: int, image_url=None) -> BattingCard:
return BattingCard.create(
player=player,
variant=variant,
steal_low=1,
steal_high=3,
steal_auto=False,
steal_jump=1.0,
bunting="N",
hit_and_run="N",
running=5,
offense_col=1,
hand="R",
image_url=image_url,
)
def _make_card_state(
player: Player,
team: Team,
track: RefractorTrack,
variant: int,
current_tier: int = 1,
current_value: float = 150.0,
) -> RefractorCardState:
import datetime
return RefractorCardState.create(
player=player,
team=team,
track=track,
current_tier=current_tier,
current_value=current_value,
fully_evolved=False,
last_evaluated_at=datetime.datetime(2026, 4, 1, 12, 0, 0),
variant=variant,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_cards_response_includes_image_url(setup_img_url_db, img_url_client):
"""GET /api/v2/refractor/cards includes image_url when the variant BattingCard has one.
What: Seeds a RefractorCardState at variant=1 and a matching BattingCard with
image_url set. Calls the /cards endpoint and asserts that image_url in the
response matches the seeded URL.
Why: This is the primary happy-path test for the image_url feature. If the
DB lookup in _build_card_state_response fails or the field is accidentally
omitted from the response dict, this test will catch it.
"""
player = _make_player("Homer Simpson")
team = _make_team("IU1")
track = _make_track("batter")
expected_url = (
"https://s3.example.com/cards/cardset-001/player-1/v1/battingcard.png"
)
_make_batting_card(player, variant=1, image_url=expected_url)
_make_card_state(player, team, track, variant=1)
resp = img_url_client.get(
f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false",
headers=AUTH_HEADER,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["count"] == 1
item = data["items"][0]
assert "image_url" in item, "image_url key missing from response"
assert item["image_url"] == expected_url
def test_cards_response_image_url_null_when_not_set(setup_img_url_db, img_url_client):
"""GET /api/v2/refractor/cards returns image_url: null when BattingCard.image_url is None.
What: Seeds a BattingCard with image_url=None and a RefractorCardState at
variant=1. Verifies the response contains image_url with a null value.
Why: The image_url field must always be present in the response (even when
null) so API consumers can rely on its presence. Returning null rather than
omitting the key is the correct contract omitting it would break consumers
that check for the key's presence to determine upload status.
"""
player = _make_player("Bart Simpson")
team = _make_team("IU2")
track = _make_track("batter")
_make_batting_card(player, variant=1, image_url=None)
_make_card_state(player, team, track, variant=1)
resp = img_url_client.get(
f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false",
headers=AUTH_HEADER,
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["count"] == 1
item = data["items"][0]
assert "image_url" in item, "image_url key missing from response"
assert item["image_url"] is None

View File

@ -5,6 +5,8 @@ Covers WP-01 acceptance criteria:
- RefractorTrack: CRUD and unique-name constraint
- RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
and FK resolution back to RefractorTrack
- RefractorTierBoost: CRUD and unique-(track, tier, boost_type, boost_target)
- RefractorCosmetic: CRUD and unique-name constraint
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
and in-place stat accumulation
@ -20,6 +22,8 @@ from playhouse.shortcuts import model_to_dict
from app.db_engine import (
BattingSeasonStats,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
)
@ -130,6 +134,115 @@ class TestRefractorCardState:
assert resolved_track.name == "Batter Track"
# ---------------------------------------------------------------------------
# RefractorTierBoost
# ---------------------------------------------------------------------------
class TestRefractorTierBoost:
"""Tests for RefractorTierBoost, 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 = RefractorTierBoost.create(
track=track,
tier=1,
boost_type="rating",
boost_target="contact_vl",
boost_value=1.5,
)
fetched = RefractorTierBoost.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.
"""
RefractorTierBoost.create(
track=track,
tier=2,
boost_type="rating",
boost_target="power_vr",
boost_value=2.0,
)
with pytest.raises(IntegrityError):
RefractorTierBoost.create(
track=track,
tier=2,
boost_type="rating",
boost_target="power_vr",
boost_value=3.0, # different value, same identity columns
)
# ---------------------------------------------------------------------------
# RefractorCosmetic
# ---------------------------------------------------------------------------
class TestRefractorCosmetic:
"""Tests for RefractorCosmetic, 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 = RefractorCosmetic.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 = RefractorCosmetic.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 RefractorCosmetic.name prevents duplicate
cosmetic definitions that could cause ambiguous tier unlock lookups.
"""
RefractorCosmetic.create(
name="Silver Badge",
tier_required=1,
cosmetic_type="badge",
)
with pytest.raises(IntegrityError):
RefractorCosmetic.create(
name="Silver Badge", # duplicate
tier_required=3,
cosmetic_type="badge",
)
# ---------------------------------------------------------------------------
# BattingSeasonStats
# ---------------------------------------------------------------------------

View File

@ -64,6 +64,8 @@ from app.db_engine import (
ProcessedGame,
Rarity,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
Roster,
RosterSlot,
@ -679,6 +681,8 @@ _STATE_API_MODELS = [
ProcessedGame,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]
@ -952,83 +956,3 @@ def test_get_card_state_last_evaluated_at_null(setup_state_api_db, state_api_cli
f"Expected last_evaluated_at=null for un-evaluated card, "
f"got {data['last_evaluated_at']!r}"
)
# ---------------------------------------------------------------------------
# T3-8: GET /refractor/cards?team_id=X&evaluated_only=false includes un-evaluated
# ---------------------------------------------------------------------------
def test_list_cards_evaluated_only_false_includes_unevaluated(
setup_state_api_db, state_api_client
):
"""GET /refractor/cards?team_id=X&evaluated_only=false returns cards with last_evaluated_at=NULL.
What: Create two RefractorCardState rows for the same team one with
last_evaluated_at=None (never evaluated) and one with last_evaluated_at set
to a timestamp (has been evaluated). Call the endpoint twice:
1. evaluated_only=true (default) only the evaluated card appears.
2. evaluated_only=false both cards appear.
Why: The default evaluated_only=True filter uses
`last_evaluated_at IS NOT NULL` to exclude placeholder rows created at
pack-open time but never run through the evaluator. At team scale (2753
rows, ~14 evaluated) this filter is critical for bot performance.
This test verifies the opt-out path (evaluated_only=false) exposes all rows,
which is needed for admin/pipeline use cases.
"""
from datetime import datetime, timezone
team = _sa_make_team("SA_T38", gmid=30380)
track = _sa_make_track("batter")
# Card 1: never evaluated — last_evaluated_at is NULL
player_unevaluated = _sa_make_player("T38 Unevaluated", pos="1B")
card_unevaluated = _sa_make_card(player_unevaluated, team) # noqa: F841
RefractorCardState.create(
player=player_unevaluated,
team=team,
track=track,
current_tier=0,
current_value=0.0,
fully_evolved=False,
last_evaluated_at=None,
)
# Card 2: has been evaluated — last_evaluated_at is a timestamp
player_evaluated = _sa_make_player("T38 Evaluated", pos="RF")
card_evaluated = _sa_make_card(player_evaluated, team) # noqa: F841
RefractorCardState.create(
player=player_evaluated,
team=team,
track=track,
current_tier=1,
current_value=5.0,
fully_evolved=False,
last_evaluated_at=datetime(2025, 4, 1, tzinfo=timezone.utc),
)
# Default (evaluated_only=true) — only the evaluated card should appear
resp_default = state_api_client.get(
f"/api/v2/refractor/cards?team_id={team.id}", headers=AUTH_HEADER
)
assert resp_default.status_code == 200
data_default = resp_default.json()
assert data_default["count"] == 1, (
f"evaluated_only=true should return 1 card, got {data_default['count']}"
)
assert data_default["items"][0]["player_name"] == "T38 Evaluated"
# evaluated_only=false — both cards should appear
resp_all = state_api_client.get(
f"/api/v2/refractor/cards?team_id={team.id}&evaluated_only=false",
headers=AUTH_HEADER,
)
assert resp_all.status_code == 200
data_all = resp_all.json()
assert data_all["count"] == 2, (
f"evaluated_only=false should return 2 cards, got {data_all['count']}"
)
names = {item["player_name"] for item in data_all["items"]}
assert "T38 Unevaluated" in names
assert "T38 Evaluated" in names

View File

@ -41,6 +41,8 @@ from app.db_engine import ( # noqa: E402
ProcessedGame,
Rarity,
RefractorCardState,
RefractorCosmetic,
RefractorTierBoost,
RefractorTrack,
Roster,
RosterSlot,
@ -202,6 +204,8 @@ _TRACK_API_MODELS = [
ProcessedGame,
RefractorTrack,
RefractorCardState,
RefractorTierBoost,
RefractorCosmetic,
]