Compare commits
No commits in common. "main" and "2026.3.6" have entirely different histories.
@ -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"
|
|
||||||
@ -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
2
.gitignore
vendored
@ -59,8 +59,6 @@ pyenv.cfg
|
|||||||
pyvenv.cfg
|
pyvenv.cfg
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
docker-compose.*.yml
|
docker-compose.*.yml
|
||||||
.run-local.pid
|
|
||||||
.env.local
|
|
||||||
*.db
|
*.db
|
||||||
venv
|
venv
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@ -474,7 +474,6 @@ class Card(BaseModel):
|
|||||||
team = ForeignKeyField(Team, null=True)
|
team = ForeignKeyField(Team, null=True)
|
||||||
pack = ForeignKeyField(Pack, null=True)
|
pack = ForeignKeyField(Pack, null=True)
|
||||||
value = IntegerField(default=0)
|
value = IntegerField(default=0)
|
||||||
variant = IntegerField(null=True, default=None)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
if self.player:
|
if self.player:
|
||||||
@ -756,7 +755,6 @@ class BattingCard(BaseModel):
|
|||||||
running = IntegerField()
|
running = IntegerField()
|
||||||
offense_col = IntegerField()
|
offense_col = IntegerField()
|
||||||
hand = CharField(default="R")
|
hand = CharField(default="R")
|
||||||
image_url = CharField(null=True, max_length=500)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
@ -826,7 +824,6 @@ class PitchingCard(BaseModel):
|
|||||||
batting = CharField(null=True)
|
batting = CharField(null=True)
|
||||||
offense_col = IntegerField()
|
offense_col = IntegerField()
|
||||||
hand = CharField(default="R")
|
hand = CharField(default="R")
|
||||||
image_url = CharField(null=True, max_length=500)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
@ -1235,7 +1232,6 @@ class RefractorCardState(BaseModel):
|
|||||||
current_value = FloatField(default=0.0)
|
current_value = FloatField(default=0.0)
|
||||||
fully_evolved = BooleanField(default=False)
|
fully_evolved = BooleanField(default=False)
|
||||||
last_evaluated_at = DateTimeField(null=True)
|
last_evaluated_at = DateTimeField(null=True)
|
||||||
variant = IntegerField(null=True)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
@ -1249,37 +1245,47 @@ refractor_card_state_index = ModelIndex(
|
|||||||
)
|
)
|
||||||
RefractorCardState.add_index(refractor_card_state_index)
|
RefractorCardState.add_index(refractor_card_state_index)
|
||||||
|
|
||||||
refractor_card_state_team_index = ModelIndex(
|
|
||||||
RefractorCardState,
|
|
||||||
(RefractorCardState.team,),
|
|
||||||
unique=False,
|
|
||||||
)
|
|
||||||
RefractorCardState.add_index(refractor_card_state_team_index)
|
|
||||||
|
|
||||||
|
class RefractorTierBoost(BaseModel):
|
||||||
class RefractorBoostAudit(BaseModel):
|
track = ForeignKeyField(RefractorTrack)
|
||||||
card_state = ForeignKeyField(RefractorCardState, on_delete="CASCADE")
|
|
||||||
tier = IntegerField() # 1-4
|
tier = IntegerField() # 1-4
|
||||||
battingcard = ForeignKeyField(BattingCard, null=True)
|
boost_type = CharField() # e.g. 'rating', 'stat'
|
||||||
pitchingcard = ForeignKeyField(PitchingCard, null=True)
|
boost_target = CharField() # e.g. 'contact_vl', 'power_vr'
|
||||||
variant_created = IntegerField()
|
boost_value = FloatField(default=0.0)
|
||||||
boost_delta_json = (
|
|
||||||
TextField()
|
|
||||||
) # JSONB in PostgreSQL; TextField for SQLite test compat
|
|
||||||
applied_at = DateTimeField(default=datetime.now)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
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:
|
if not SKIP_TABLE_CREATION:
|
||||||
db.create_tables(
|
db.create_tables(
|
||||||
[
|
[RefractorTrack, RefractorCardState, RefractorTierBoost, RefractorCosmetic],
|
||||||
RefractorTrack,
|
|
||||||
RefractorCardState,
|
|
||||||
RefractorBoostAudit,
|
|
||||||
],
|
|
||||||
safe=True,
|
safe=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,6 @@ async def get_card_positions(
|
|||||||
cardset_id: list = Query(default=None),
|
cardset_id: list = Query(default=None),
|
||||||
short_output: Optional[bool] = False,
|
short_output: Optional[bool] = False,
|
||||||
sort: Optional[str] = "innings-desc",
|
sort: Optional[str] = "innings-desc",
|
||||||
limit: int = 100,
|
|
||||||
):
|
):
|
||||||
all_pos = (
|
all_pos = (
|
||||||
CardPosition.select()
|
CardPosition.select()
|
||||||
@ -87,9 +86,6 @@ async def get_card_positions(
|
|||||||
elif sort == "range-asc":
|
elif sort == "range-asc":
|
||||||
all_pos = all_pos.order_by(CardPosition.range, CardPosition.id)
|
all_pos = all_pos.order_by(CardPosition.range, CardPosition.id)
|
||||||
|
|
||||||
limit = max(0, min(limit, 500))
|
|
||||||
all_pos = all_pos.limit(limit)
|
|
||||||
|
|
||||||
return_val = {
|
return_val = {
|
||||||
"count": all_pos.count(),
|
"count": all_pos.count(),
|
||||||
"positions": [model_to_dict(x, recurse=not short_output) for x in all_pos],
|
"positions": [model_to_dict(x, recurse=not short_output) for x in all_pos],
|
||||||
|
|||||||
@ -9,36 +9,34 @@ from ..db_engine import Paperdex, model_to_dict, Player, Cardset, Team, DoesNotE
|
|||||||
from ..dependencies import oauth2_scheme, valid_token
|
from ..dependencies import oauth2_scheme, valid_token
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v2/paperdex", tags=["paperdex"])
|
router = APIRouter(
|
||||||
|
prefix='/api/v2/paperdex',
|
||||||
|
tags=['paperdex']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PaperdexModel(pydantic.BaseModel):
|
class PaperdexModel(pydantic.BaseModel):
|
||||||
team_id: int
|
team_id: int
|
||||||
player_id: int
|
player_id: int
|
||||||
created: Optional[int] = int(datetime.timestamp(datetime.now()) * 1000)
|
created: Optional[int] = int(datetime.timestamp(datetime.now())*1000)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get('')
|
||||||
async def get_paperdex(
|
async def get_paperdex(
|
||||||
team_id: Optional[int] = None,
|
team_id: Optional[int] = None, player_id: Optional[int] = None, created_after: Optional[int] = None,
|
||||||
player_id: Optional[int] = None,
|
cardset_id: Optional[int] = None, created_before: Optional[int] = None, flat: Optional[bool] = False,
|
||||||
created_after: Optional[int] = None,
|
csv: Optional[bool] = None):
|
||||||
cardset_id: Optional[int] = None,
|
|
||||||
created_before: Optional[int] = None,
|
|
||||||
flat: Optional[bool] = False,
|
|
||||||
csv: Optional[bool] = None,
|
|
||||||
limit: int = 100,
|
|
||||||
):
|
|
||||||
all_dex = Paperdex.select().join(Player).join(Cardset).order_by(Paperdex.id)
|
all_dex = Paperdex.select().join(Player).join(Cardset).order_by(Paperdex.id)
|
||||||
|
|
||||||
if all_dex.count() == 0:
|
if all_dex.count() == 0:
|
||||||
raise HTTPException(status_code=404, detail="There are no paperdex to filter")
|
raise HTTPException(status_code=404, detail=f'There are no paperdex to filter')
|
||||||
|
|
||||||
if team_id is not None:
|
if team_id is not None:
|
||||||
all_dex = all_dex.where(Paperdex.team_id == team_id)
|
all_dex = all_dex.where(Paperdex.team_id == team_id)
|
||||||
if player_id is not None:
|
if player_id is not None:
|
||||||
all_dex = all_dex.where(Paperdex.player_id == player_id)
|
all_dex = all_dex.where(Paperdex.player_id == player_id)
|
||||||
if cardset_id is not None:
|
if cardset_id is not None:
|
||||||
|
all_sets = Cardset.select().where(Cardset.id == cardset_id)
|
||||||
all_dex = all_dex.where(Paperdex.player.cardset.id == cardset_id)
|
all_dex = all_dex.where(Paperdex.player.cardset.id == cardset_id)
|
||||||
if created_after is not None:
|
if created_after is not None:
|
||||||
# Convert milliseconds timestamp to datetime for PostgreSQL comparison
|
# Convert milliseconds timestamp to datetime for PostgreSQL comparison
|
||||||
@ -53,62 +51,57 @@ async def get_paperdex(
|
|||||||
# db.close()
|
# db.close()
|
||||||
# raise HTTPException(status_code=404, detail=f'No paperdex found')
|
# raise HTTPException(status_code=404, detail=f'No paperdex found')
|
||||||
|
|
||||||
limit = max(0, min(limit, 500))
|
|
||||||
all_dex = all_dex.limit(limit)
|
|
||||||
|
|
||||||
if csv:
|
if csv:
|
||||||
data_list = [["id", "team_id", "player_id", "created"]]
|
data_list = [['id', 'team_id', 'player_id', 'created']]
|
||||||
for line in all_dex:
|
for line in all_dex:
|
||||||
data_list.append(
|
data_list.append(
|
||||||
[line.id, line.team.id, line.player.player_id, line.created]
|
[
|
||||||
|
line.id, line.team.id, line.player.player_id, line.created
|
||||||
|
]
|
||||||
)
|
)
|
||||||
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
||||||
|
|
||||||
return Response(content=return_val, media_type="text/csv")
|
return Response(content=return_val, media_type='text/csv')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return_val = {"count": all_dex.count(), "paperdex": []}
|
return_val = {'count': all_dex.count(), 'paperdex': []}
|
||||||
for x in all_dex:
|
for x in all_dex:
|
||||||
return_val["paperdex"].append(model_to_dict(x, recurse=not flat))
|
return_val['paperdex'].append(model_to_dict(x, recurse=not flat))
|
||||||
|
|
||||||
return return_val
|
return return_val
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{paperdex_id}")
|
@router.get('/{paperdex_id}')
|
||||||
async def get_one_paperdex(paperdex_id, csv: Optional[bool] = False):
|
async def get_one_paperdex(paperdex_id, csv: Optional[bool] = False):
|
||||||
try:
|
try:
|
||||||
this_dex = Paperdex.get_by_id(paperdex_id)
|
this_dex = Paperdex.get_by_id(paperdex_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}')
|
||||||
status_code=404, detail=f"No paperdex found with id {paperdex_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if csv:
|
if csv:
|
||||||
data_list = [
|
data_list = [
|
||||||
["id", "team_id", "player_id", "created"],
|
['id', 'team_id', 'player_id', 'created'],
|
||||||
[this_dex.id, this_dex.team.id, this_dex.player.id, this_dex.created],
|
[this_dex.id, this_dex.team.id, this_dex.player.id, this_dex.created]
|
||||||
]
|
]
|
||||||
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
return_val = DataFrame(data_list).to_csv(header=False, index=False)
|
||||||
|
|
||||||
return Response(content=return_val, media_type="text/csv")
|
return Response(content=return_val, media_type='text/csv')
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return_val = model_to_dict(this_dex)
|
return_val = model_to_dict(this_dex)
|
||||||
return return_val
|
return return_val
|
||||||
|
|
||||||
|
|
||||||
@router.post("")
|
@router.post('')
|
||||||
async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_scheme)):
|
async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_scheme)):
|
||||||
if not valid_token(token):
|
if not valid_token(token):
|
||||||
logging.warning("Bad Token: [REDACTED]")
|
logging.warning('Bad Token: [REDACTED]')
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="You are not authorized to post paperdex. This event has been logged.",
|
detail='You are not authorized to post paperdex. This event has been logged.'
|
||||||
)
|
)
|
||||||
|
|
||||||
dupe_dex = Paperdex.get_or_none(
|
dupe_dex = Paperdex.get_or_none(Paperdex.team_id == paperdex.team_id, Paperdex.player_id == paperdex.player_id)
|
||||||
Paperdex.team_id == paperdex.team_id, Paperdex.player_id == paperdex.player_id
|
|
||||||
)
|
|
||||||
if dupe_dex:
|
if dupe_dex:
|
||||||
return_val = model_to_dict(dupe_dex)
|
return_val = model_to_dict(dupe_dex)
|
||||||
return return_val
|
return return_val
|
||||||
@ -116,7 +109,7 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch
|
|||||||
this_dex = Paperdex(
|
this_dex = Paperdex(
|
||||||
team_id=paperdex.team_id,
|
team_id=paperdex.team_id,
|
||||||
player_id=paperdex.player_id,
|
player_id=paperdex.player_id,
|
||||||
created=datetime.fromtimestamp(paperdex.created / 1000),
|
created=datetime.fromtimestamp(paperdex.created / 1000)
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = this_dex.save()
|
saved = this_dex.save()
|
||||||
@ -126,30 +119,24 @@ async def post_paperdex(paperdex: PaperdexModel, token: str = Depends(oauth2_sch
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=418,
|
status_code=418,
|
||||||
detail="Well slap my ass and call me a teapot; I could not save that dex",
|
detail='Well slap my ass and call me a teapot; I could not save that dex'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{paperdex_id}")
|
@router.patch('/{paperdex_id}')
|
||||||
async def patch_paperdex(
|
async def patch_paperdex(
|
||||||
paperdex_id,
|
paperdex_id, team_id: Optional[int] = None, player_id: Optional[int] = None, created: Optional[int] = None,
|
||||||
team_id: Optional[int] = None,
|
token: str = Depends(oauth2_scheme)):
|
||||||
player_id: Optional[int] = None,
|
|
||||||
created: Optional[int] = None,
|
|
||||||
token: str = Depends(oauth2_scheme),
|
|
||||||
):
|
|
||||||
if not valid_token(token):
|
if not valid_token(token):
|
||||||
logging.warning("Bad Token: [REDACTED]")
|
logging.warning('Bad Token: [REDACTED]')
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="You are not authorized to patch paperdex. This event has been logged.",
|
detail='You are not authorized to patch paperdex. This event has been logged.'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
this_dex = Paperdex.get_by_id(paperdex_id)
|
this_dex = Paperdex.get_by_id(paperdex_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}')
|
||||||
status_code=404, detail=f"No paperdex found with id {paperdex_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if team_id is not None:
|
if team_id is not None:
|
||||||
this_dex.team_id = team_id
|
this_dex.team_id = team_id
|
||||||
@ -164,43 +151,40 @@ async def patch_paperdex(
|
|||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=418,
|
status_code=418,
|
||||||
detail="Well slap my ass and call me a teapot; I could not save that rarity",
|
detail='Well slap my ass and call me a teapot; I could not save that rarity'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{paperdex_id}")
|
@router.delete('/{paperdex_id}')
|
||||||
async def delete_paperdex(paperdex_id, token: str = Depends(oauth2_scheme)):
|
async def delete_paperdex(paperdex_id, token: str = Depends(oauth2_scheme)):
|
||||||
if not valid_token(token):
|
if not valid_token(token):
|
||||||
logging.warning("Bad Token: [REDACTED]")
|
logging.warning('Bad Token: [REDACTED]')
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
detail="You are not authorized to delete rewards. This event has been logged.",
|
detail='You are not authorized to delete rewards. This event has been logged.'
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
this_dex = Paperdex.get_by_id(paperdex_id)
|
this_dex = Paperdex.get_by_id(paperdex_id)
|
||||||
except DoesNotExist:
|
except DoesNotExist:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail=f'No paperdex found with id {paperdex_id}')
|
||||||
status_code=404, detail=f"No paperdex found with id {paperdex_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
count = this_dex.delete_instance()
|
count = this_dex.delete_instance()
|
||||||
|
|
||||||
if count == 1:
|
if count == 1:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=200, detail=f'Paperdex {this_dex} has been deleted')
|
||||||
status_code=200, detail=f"Paperdex {this_dex} has been deleted"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=500, detail=f'Paperdex {this_dex} was not deleted')
|
||||||
status_code=500, detail=f"Paperdex {this_dex} was not deleted"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/wipe-ai")
|
@router.post('/wipe-ai')
|
||||||
async def wipe_ai_paperdex(token: str = Depends(oauth2_scheme)):
|
async def wipe_ai_paperdex(token: str = Depends(oauth2_scheme)):
|
||||||
if not valid_token(token):
|
if not valid_token(token):
|
||||||
logging.warning("Bad Token: [REDACTED]")
|
logging.warning('Bad Token: [REDACTED]')
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail='Unauthorized'
|
||||||
|
)
|
||||||
|
|
||||||
g_teams = Team.select().where(Team.abbrev.contains("Gauntlet"))
|
g_teams = Team.select().where(Team.abbrev.contains('Gauntlet'))
|
||||||
count = Paperdex.delete().where(Paperdex.team << g_teams).execute()
|
count = Paperdex.delete().where(Paperdex.team << g_teams).execute()
|
||||||
return f"Deleted {count} records"
|
return f'Deleted {count} records'
|
||||||
|
|||||||
@ -32,7 +32,6 @@ from ..db_engine import (
|
|||||||
)
|
)
|
||||||
from ..db_helpers import upsert_players
|
from ..db_helpers import upsert_players
|
||||||
from ..dependencies import oauth2_scheme, valid_token
|
from ..dependencies import oauth2_scheme, valid_token
|
||||||
from ..services.refractor_boost import compute_variant_hash
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Persistent browser instance (WP-02)
|
# Persistent browser instance (WP-02)
|
||||||
@ -133,19 +132,6 @@ def normalize_franchise(franchise: str) -> str:
|
|||||||
return FRANCHISE_NORMALIZE.get(titled, titled)
|
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"])
|
router = APIRouter(prefix="/api/v2/players", tags=["players"])
|
||||||
|
|
||||||
|
|
||||||
@ -737,9 +723,6 @@ async def get_batter_card(
|
|||||||
variant: int = 0,
|
variant: int = 0,
|
||||||
d: str = None,
|
d: str = None,
|
||||||
html: Optional[bool] = False,
|
html: Optional[bool] = False,
|
||||||
tier: Optional[int] = Query(
|
|
||||||
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
this_player = Player.get_by_id(player_id)
|
this_player = Player.get_by_id(player_id)
|
||||||
@ -757,7 +740,6 @@ async def get_batter_card(
|
|||||||
f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png"
|
f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png"
|
||||||
)
|
)
|
||||||
and html is False
|
and html is False
|
||||||
and tier is None
|
|
||||||
):
|
):
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png",
|
path=f"storage/cards/cardset-{this_player.cardset.id}/{card_type}/{player_id}-{d}-v{variant}.png",
|
||||||
@ -804,9 +786,6 @@ async def get_batter_card(
|
|||||||
card_data["cardset_name"] = this_player.cardset.name
|
card_data["cardset_name"] = this_player.cardset.name
|
||||||
else:
|
else:
|
||||||
card_data["cardset_name"] = this_player.description
|
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
|
card_data["request"] = request
|
||||||
html_response = templates.TemplateResponse("player_card.html", card_data)
|
html_response = templates.TemplateResponse("player_card.html", card_data)
|
||||||
|
|
||||||
@ -844,9 +823,6 @@ async def get_batter_card(
|
|||||||
card_data["cardset_name"] = this_player.cardset.name
|
card_data["cardset_name"] = this_player.cardset.name
|
||||||
else:
|
else:
|
||||||
card_data["cardset_name"] = this_player.description
|
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
|
card_data["request"] = request
|
||||||
html_response = templates.TemplateResponse("player_card.html", card_data)
|
html_response = templates.TemplateResponse("player_card.html", card_data)
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,9 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from ..db_engine import model_to_dict
|
from ..db_engine import model_to_dict
|
||||||
from ..dependencies import oauth2_scheme, valid_token
|
from ..dependencies import oauth2_scheme, valid_token
|
||||||
from ..services.refractor_init import initialize_card_refractor, _determine_card_type
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -24,15 +21,14 @@ _NEXT_THRESHOLD_ATTR = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_card_state_response(state, player_name=None) -> dict:
|
def _build_card_state_response(state) -> dict:
|
||||||
"""Serialise a RefractorCardState into the standard API response shape.
|
"""Serialise a RefractorCardState into the standard API response shape.
|
||||||
|
|
||||||
Produces a flat dict with player_id and team_id as plain integers,
|
Produces a flat dict with player_id and team_id as plain integers,
|
||||||
a nested 'track' dict with all threshold fields, and computed fields:
|
a nested 'track' dict with all threshold fields, and a computed
|
||||||
- 'next_threshold': threshold for the tier immediately above (None when fully evolved).
|
'next_threshold' field:
|
||||||
- 'progress_pct': current_value / next_threshold * 100, rounded to 1 decimal
|
- For tiers 0-3: the threshold value for the tier immediately above.
|
||||||
(None when fully evolved or next_threshold is zero).
|
- For tier 4 (fully evolved): None.
|
||||||
- 'player_name': included when passed (e.g. from a list join); omitted otherwise.
|
|
||||||
|
|
||||||
Uses model_to_dict(recurse=False) internally so FK fields are returned
|
Uses model_to_dict(recurse=False) internally so FK fields are returned
|
||||||
as IDs rather than nested objects, then promotes the needed IDs up to
|
as IDs rather than nested objects, then promotes the needed IDs up to
|
||||||
@ -44,31 +40,19 @@ def _build_card_state_response(state, player_name=None) -> dict:
|
|||||||
next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier)
|
next_attr = _NEXT_THRESHOLD_ATTR.get(state.current_tier)
|
||||||
next_threshold = getattr(track, next_attr) if next_attr else None
|
next_threshold = getattr(track, next_attr) if next_attr else None
|
||||||
|
|
||||||
progress_pct = None
|
return {
|
||||||
if next_threshold is not None and next_threshold > 0:
|
|
||||||
progress_pct = round((state.current_value / next_threshold) * 100, 1)
|
|
||||||
|
|
||||||
result = {
|
|
||||||
"player_id": state.player_id,
|
"player_id": state.player_id,
|
||||||
"team_id": state.team_id,
|
"team_id": state.team_id,
|
||||||
"current_tier": state.current_tier,
|
"current_tier": state.current_tier,
|
||||||
"current_value": state.current_value,
|
"current_value": state.current_value,
|
||||||
"fully_evolved": state.fully_evolved,
|
"fully_evolved": state.fully_evolved,
|
||||||
"last_evaluated_at": (
|
"last_evaluated_at": (
|
||||||
state.last_evaluated_at.isoformat()
|
state.last_evaluated_at.isoformat() if state.last_evaluated_at else None
|
||||||
if hasattr(state.last_evaluated_at, "isoformat")
|
|
||||||
else state.last_evaluated_at or None
|
|
||||||
),
|
),
|
||||||
"track": track_dict,
|
"track": track_dict,
|
||||||
"next_threshold": next_threshold,
|
"next_threshold": next_threshold,
|
||||||
"progress_pct": progress_pct,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if player_name is not None:
|
|
||||||
result["player_name"] = player_name
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tracks")
|
@router.get("/tracks")
|
||||||
async def list_tracks(
|
async def list_tracks(
|
||||||
@ -105,125 +89,6 @@ async def get_track(track_id: int, token: str = Depends(oauth2_scheme)):
|
|||||||
return model_to_dict(track, recurse=False)
|
return model_to_dict(track, recurse=False)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cards")
|
|
||||||
async def list_card_states(
|
|
||||||
team_id: int = Query(...),
|
|
||||||
card_type: Optional[str] = Query(default=None),
|
|
||||||
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),
|
|
||||||
):
|
|
||||||
"""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
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Pagination:
|
|
||||||
limit -- page size (1-100, default 10)
|
|
||||||
offset -- items to skip (default 0)
|
|
||||||
|
|
||||||
Response: {"count": N, "items": [...]}
|
|
||||||
count is the total matching rows before limit/offset.
|
|
||||||
Each item includes player_name and progress_pct in addition to the
|
|
||||||
standard single-card response fields.
|
|
||||||
|
|
||||||
Sort order: current_tier DESC, current_value DESC.
|
|
||||||
"""
|
|
||||||
if not valid_token(token):
|
|
||||||
logging.warning("Bad Token: [REDACTED]")
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
|
|
||||||
from ..db_engine import (
|
|
||||||
RefractorCardState,
|
|
||||||
RefractorTrack,
|
|
||||||
Player,
|
|
||||||
BattingSeasonStats,
|
|
||||||
PitchingSeasonStats,
|
|
||||||
fn,
|
|
||||||
Case,
|
|
||||||
JOIN,
|
|
||||||
)
|
|
||||||
|
|
||||||
query = (
|
|
||||||
RefractorCardState.select(RefractorCardState, RefractorTrack, Player)
|
|
||||||
.join(RefractorTrack)
|
|
||||||
.switch(RefractorCardState)
|
|
||||||
.join(
|
|
||||||
Player, JOIN.LEFT_OUTER, on=(RefractorCardState.player == Player.player_id)
|
|
||||||
)
|
|
||||||
.where(RefractorCardState.team == team_id)
|
|
||||||
.order_by(
|
|
||||||
RefractorCardState.current_tier.desc(),
|
|
||||||
RefractorCardState.current_value.desc(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if card_type is not None:
|
|
||||||
query = query.where(RefractorTrack.card_type == card_type)
|
|
||||||
|
|
||||||
if tier is not None:
|
|
||||||
query = query.where(RefractorCardState.current_tier == tier)
|
|
||||||
|
|
||||||
if season is not None:
|
|
||||||
batter_exists = BattingSeasonStats.select().where(
|
|
||||||
(BattingSeasonStats.player == RefractorCardState.player)
|
|
||||||
& (BattingSeasonStats.team == RefractorCardState.team)
|
|
||||||
& (BattingSeasonStats.season == season)
|
|
||||||
)
|
|
||||||
pitcher_exists = PitchingSeasonStats.select().where(
|
|
||||||
(PitchingSeasonStats.player == RefractorCardState.player)
|
|
||||||
& (PitchingSeasonStats.team == RefractorCardState.team)
|
|
||||||
& (PitchingSeasonStats.season == season)
|
|
||||||
)
|
|
||||||
query = query.where(fn.EXISTS(batter_exists) | fn.EXISTS(pitcher_exists))
|
|
||||||
|
|
||||||
if progress == "close":
|
|
||||||
next_threshold_expr = Case(
|
|
||||||
RefractorCardState.current_tier,
|
|
||||||
(
|
|
||||||
(0, RefractorTrack.t1_threshold),
|
|
||||||
(1, RefractorTrack.t2_threshold),
|
|
||||||
(2, RefractorTrack.t3_threshold),
|
|
||||||
(3, RefractorTrack.t4_threshold),
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
query = query.where(
|
|
||||||
(RefractorCardState.fully_evolved == False) # noqa: E712
|
|
||||||
& (RefractorCardState.current_value >= next_threshold_expr * 0.8)
|
|
||||||
)
|
|
||||||
|
|
||||||
if evaluated_only:
|
|
||||||
query = query.where(RefractorCardState.last_evaluated_at.is_null(False))
|
|
||||||
|
|
||||||
total = query.count()
|
|
||||||
items = []
|
|
||||||
for state in query.offset(offset).limit(limit):
|
|
||||||
player_name = None
|
|
||||||
try:
|
|
||||||
player_name = state.player.p_name
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
items.append(_build_card_state_response(state, player_name=player_name))
|
|
||||||
|
|
||||||
return {"count": total, "items": items}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/cards/{card_id}")
|
@router.get("/cards/{card_id}")
|
||||||
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
|
async def get_card_state(card_id: int, token: str = Depends(oauth2_scheme)):
|
||||||
"""Return the RefractorCardState for a card identified by its Card.id.
|
"""Return the RefractorCardState for a card identified by its Card.id.
|
||||||
@ -303,16 +168,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,
|
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
|
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
|
tier. Pairs without a state row are silently skipped. Per-player errors are
|
||||||
initialize_card_refractor (idempotent). Per-player errors are logged but
|
logged but do not abort the batch.
|
||||||
do not abort the batch.
|
|
||||||
"""
|
"""
|
||||||
if not valid_token(token):
|
if not valid_token(token):
|
||||||
logging.warning("Bad Token: [REDACTED]")
|
logging.warning("Bad Token: [REDACTED]")
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
from ..db_engine import RefractorCardState, Player, StratPlay
|
from ..db_engine import RefractorCardState, RefractorTrack, Player, StratPlay
|
||||||
from ..services.refractor_boost import apply_tier_boost
|
|
||||||
from ..services.refractor_evaluator import evaluate_card
|
from ..services.refractor_evaluator import evaluate_card
|
||||||
|
|
||||||
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
plays = list(StratPlay.select().where(StratPlay.game == game_id))
|
||||||
@ -327,8 +190,6 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
|||||||
evaluated = 0
|
evaluated = 0
|
||||||
tier_ups = []
|
tier_ups = []
|
||||||
|
|
||||||
boost_enabled = os.environ.get("REFRACTOR_BOOST_ENABLED", "true").lower() != "false"
|
|
||||||
|
|
||||||
for player_id, team_id in pairs:
|
for player_id, team_id in pairs:
|
||||||
try:
|
try:
|
||||||
state = RefractorCardState.get_or_none(
|
state = RefractorCardState.get_or_none(
|
||||||
@ -336,29 +197,14 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
|||||||
& (RefractorCardState.team_id == team_id)
|
& (RefractorCardState.team_id == team_id)
|
||||||
)
|
)
|
||||||
if state is None:
|
if state is None:
|
||||||
try:
|
continue
|
||||||
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
|
|
||||||
|
|
||||||
old_tier = state.current_tier
|
old_tier = state.current_tier
|
||||||
# Use dry_run=True so that current_tier is NOT written here.
|
result = evaluate_card(player_id, team_id)
|
||||||
# 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)
|
|
||||||
evaluated += 1
|
evaluated += 1
|
||||||
|
|
||||||
# Use computed_tier (what the formula says) to detect tier-ups.
|
new_tier = result.get("current_tier", old_tier)
|
||||||
computed_tier = result.get("computed_tier", old_tier)
|
if new_tier > old_tier:
|
||||||
if computed_tier > old_tier:
|
|
||||||
player_name = "Unknown"
|
player_name = "Unknown"
|
||||||
try:
|
try:
|
||||||
p = Player.get_by_id(player_id)
|
p = Player.get_by_id(player_id)
|
||||||
@ -366,66 +212,17 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Phase 2: Apply rating boosts for each tier gained.
|
tier_ups.append(
|
||||||
# apply_tier_boost() writes current_tier + variant atomically.
|
{
|
||||||
# If it fails, current_tier stays at old_tier — automatic retry next game.
|
"player_id": player_id,
|
||||||
boost_result = None
|
"team_id": team_id,
|
||||||
if not boost_enabled:
|
"player_name": player_name,
|
||||||
# Boost disabled via REFRACTOR_BOOST_ENABLED=false.
|
"old_tier": old_tier,
|
||||||
# Skip notification — current_tier was not written (dry_run),
|
"new_tier": new_tier,
|
||||||
# so reporting a tier-up would be a false notification.
|
"current_value": result.get("current_value", 0),
|
||||||
continue
|
"track_name": state.track.name if state.track else "Unknown",
|
||||||
|
}
|
||||||
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:
|
|
||||||
tier_up_entry["variant_created"] = boost_result.get(
|
|
||||||
"variant_created"
|
|
||||||
)
|
|
||||||
|
|
||||||
tier_ups.append(tier_up_entry)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Refractor eval failed for player={player_id} team={team_id}: {exc}"
|
f"Refractor eval failed for player={player_id} team={team_id}: {exc}"
|
||||||
|
|||||||
@ -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 (0–4) 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),
|
|
||||||
}
|
|
||||||
@ -9,20 +9,9 @@ evaluate_card() is the main entry point:
|
|||||||
4. Compare value to track thresholds to determine new_tier
|
4. Compare value to track thresholds to determine new_tier
|
||||||
5. Update card_state.current_value = computed value
|
5. Update card_state.current_value = computed value
|
||||||
6. Update card_state.current_tier = max(current_tier, new_tier) — no regression
|
6. Update card_state.current_tier = max(current_tier, new_tier) — no regression
|
||||||
(SKIPPED when dry_run=True)
|
7. Update card_state.fully_evolved = (new_tier >= 4)
|
||||||
7. Update card_state.fully_evolved = (current_tier >= 4)
|
|
||||||
(SKIPPED when dry_run=True)
|
|
||||||
8. Update card_state.last_evaluated_at = NOW()
|
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 6–7 (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.
|
Idempotent: calling multiple times with the same data produces the same result.
|
||||||
|
|
||||||
Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
|
Depends on WP-05 (RefractorCardState), WP-07 (BattingSeasonStats/PitchingSeasonStats),
|
||||||
@ -58,7 +47,6 @@ class _CareerTotals:
|
|||||||
def evaluate_card(
|
def evaluate_card(
|
||||||
player_id: int,
|
player_id: int,
|
||||||
team_id: int,
|
team_id: int,
|
||||||
dry_run: bool = False,
|
|
||||||
_stats_model=None,
|
_stats_model=None,
|
||||||
_state_model=None,
|
_state_model=None,
|
||||||
_compute_value_fn=None,
|
_compute_value_fn=None,
|
||||||
@ -68,26 +56,15 @@ def evaluate_card(
|
|||||||
|
|
||||||
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
|
Sums all BattingSeasonStats or PitchingSeasonStats rows (based on
|
||||||
card_type) for (player_id, team_id) across all seasons, then delegates
|
card_type) for (player_id, team_id) across all seasons, then delegates
|
||||||
formula computation and tier classification to the formula engine. The
|
formula computation and tier classification to the formula engine. The result is written back to refractor_card_state and
|
||||||
result is written back to refractor_card_state and returned as a dict.
|
returned as a dict.
|
||||||
|
|
||||||
current_tier never decreases (no regression):
|
current_tier never decreases (no regression):
|
||||||
card_state.current_tier = max(card_state.current_tier, new_tier)
|
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:
|
Args:
|
||||||
player_id: Player primary key.
|
player_id: Player primary key.
|
||||||
team_id: Team 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
|
_stats_model: Override for BattingSeasonStats/PitchingSeasonStats
|
||||||
(used in tests to inject a stub model with all stat fields).
|
(used in tests to inject a stub model with all stat fields).
|
||||||
_state_model: Override for RefractorCardState (used in tests to avoid
|
_state_model: Override for RefractorCardState (used in tests to avoid
|
||||||
@ -98,10 +75,8 @@ def evaluate_card(
|
|||||||
(used in tests).
|
(used in tests).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with current_tier, computed_tier, current_value, fully_evolved,
|
Dict with updated current_tier, current_value, fully_evolved,
|
||||||
last_evaluated_at (ISO-8601 string). "computed_tier" reflects what
|
last_evaluated_at (ISO-8601 string).
|
||||||
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).
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If no refractor_card_state row exists for (player_id, team_id).
|
ValueError: If no refractor_card_state row exists for (player_id, team_id).
|
||||||
@ -194,30 +169,21 @@ def evaluate_card(
|
|||||||
value = _compute_value_fn(track.card_type, totals)
|
value = _compute_value_fn(track.card_type, totals)
|
||||||
new_tier = _tier_from_value_fn(value, track)
|
new_tier = _tier_from_value_fn(value, track)
|
||||||
|
|
||||||
# 5–8. Update card state.
|
# 5–8. Update card state (no tier regression)
|
||||||
now = datetime.now()
|
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_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
|
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()
|
card_state.save()
|
||||||
|
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"refractor_eval: player=%s team=%s value=%.2f computed_tier=%s "
|
"refractor_eval: player=%s team=%s value=%.2f tier=%s fully_evolved=%s",
|
||||||
"stored_tier=%s dry_run=%s",
|
|
||||||
player_id,
|
player_id,
|
||||||
team_id,
|
team_id,
|
||||||
value,
|
value,
|
||||||
computed_tier,
|
|
||||||
card_state.current_tier,
|
card_state.current_tier,
|
||||||
dry_run,
|
card_state.fully_evolved,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -225,8 +191,6 @@ def evaluate_card(
|
|||||||
"team_id": team_id,
|
"team_id": team_id,
|
||||||
"current_value": card_state.current_value,
|
"current_value": card_state.current_value,
|
||||||
"current_tier": card_state.current_tier,
|
"current_tier": card_state.current_tier,
|
||||||
"computed_tier": computed_tier,
|
|
||||||
"computed_fully_evolved": computed_fully_evolved,
|
|
||||||
"fully_evolved": card_state.fully_evolved,
|
"fully_evolved": card_state.fully_evolved,
|
||||||
"last_evaluated_at": card_state.last_evaluated_at.isoformat(),
|
"last_evaluated_at": card_state.last_evaluated_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
-- Migration: Add team_id index to refractor_card_state
|
|
||||||
-- Date: 2026-03-25
|
|
||||||
--
|
|
||||||
-- Adds a non-unique index on refractor_card_state.team_id to support the new
|
|
||||||
-- GET /api/v2/refractor/cards list endpoint, which filters by team as its
|
|
||||||
-- primary discriminator and is called on every /refractor status bot command.
|
|
||||||
--
|
|
||||||
-- The existing unique index is on (player_id, team_id) with player leading,
|
|
||||||
-- so team-only queries cannot use it efficiently.
|
|
||||||
|
|
||||||
BEGIN;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refractor_card_state_team
|
|
||||||
ON refractor_card_state (team_id);
|
|
||||||
|
|
||||||
COMMIT;
|
|
||||||
|
|
||||||
-- Rollback:
|
|
||||||
-- DROP INDEX IF EXISTS idx_refractor_card_state_team;
|
|
||||||
@ -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;
|
|
||||||
@ -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;
|
|
||||||
132
run-local.sh
132
run-local.sh
@ -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
|
|
||||||
@ -2,26 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
{% include 'style.html' %}
|
{% include 'style.html' %}
|
||||||
{% include 'tier_style.html' %}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="fullCard" style="width: 1200px; height: 600px;">
|
<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{% 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="header" class="row-wrapper header-text border-bot" style="height: 65px">
|
||||||
<!-- <div id="headerLeft" style="flex-grow: 3; height: auto">-->
|
<!-- <div id="headerLeft" style="flex-grow: 3; height: auto">-->
|
||||||
<div id="headerLeft" style="width: 477px; height: auto">
|
<div id="headerLeft" style="width: 477px; height: auto">
|
||||||
|
|||||||
@ -1,216 +0,0 @@
|
|||||||
<style>
|
|
||||||
#fullCard {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% if refractor_tier is defined and refractor_tier > 0 %}
|
|
||||||
<style>
|
|
||||||
.tier-diamond {
|
|
||||||
position: absolute;
|
|
||||||
left: 597px;
|
|
||||||
top: 78.5px;
|
|
||||||
transform: translate(-50%, -50%) rotate(45deg);
|
|
||||||
display: grid;
|
|
||||||
grid-template: 1fr 1fr / 1fr 1fr;
|
|
||||||
gap: 2px;
|
|
||||||
z-index: 20;
|
|
||||||
pointer-events: none;
|
|
||||||
background: rgba(0,0,0,0.75);
|
|
||||||
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.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 %}
|
|
||||||
@ -44,11 +44,10 @@ from app.db_engine import (
|
|||||||
BattingSeasonStats,
|
BattingSeasonStats,
|
||||||
PitchingSeasonStats,
|
PitchingSeasonStats,
|
||||||
ProcessedGame,
|
ProcessedGame,
|
||||||
BattingCard,
|
|
||||||
PitchingCard,
|
|
||||||
RefractorTrack,
|
RefractorTrack,
|
||||||
RefractorCardState,
|
RefractorCardState,
|
||||||
RefractorBoostAudit,
|
RefractorTierBoost,
|
||||||
|
RefractorCosmetic,
|
||||||
ScoutOpportunity,
|
ScoutOpportunity,
|
||||||
ScoutClaim,
|
ScoutClaim,
|
||||||
)
|
)
|
||||||
@ -79,9 +78,8 @@ _TEST_MODELS = [
|
|||||||
ScoutClaim,
|
ScoutClaim,
|
||||||
RefractorTrack,
|
RefractorTrack,
|
||||||
RefractorCardState,
|
RefractorCardState,
|
||||||
BattingCard,
|
RefractorTierBoost,
|
||||||
PitchingCard,
|
RefractorCosmetic,
|
||||||
RefractorBoostAudit,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -204,120 +204,3 @@ def test_tier_t3_boundary():
|
|||||||
def test_tier_accepts_namespace_track():
|
def test_tier_accepts_namespace_track():
|
||||||
"""tier_from_value must work with attribute-style track objects (Peewee models)."""
|
"""tier_from_value must work with attribute-style track objects (Peewee models)."""
|
||||||
assert tier_from_value(37, track_ns("batter")) == 1
|
assert tier_from_value(37, track_ns("batter")) == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T1-1: Negative singles guard in compute_batter_value
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_batter_negative_singles_component():
|
|
||||||
"""hits=1, doubles=1, triples=1, hr=0 produces singles=-1.
|
|
||||||
|
|
||||||
What: The formula computes singles = hits - doubles - triples - hr.
|
|
||||||
With hits=1, doubles=1, triples=1, hr=0 the result is singles = -1,
|
|
||||||
which is a physically impossible stat line but valid arithmetic input.
|
|
||||||
|
|
||||||
Why: Document the formula's actual behaviour when given an incoherent stat
|
|
||||||
line so that callers are aware that no clamping or guard exists. If a
|
|
||||||
guard is added in the future, this test will catch the change in behaviour.
|
|
||||||
|
|
||||||
singles = 1 - 1 - 1 - 0 = -1
|
|
||||||
tb = (-1)*1 + 1*2 + 1*3 + 0*4 = -1 + 2 + 3 = 4
|
|
||||||
value = pa + tb*2 = 0 + 4*2 = 8
|
|
||||||
"""
|
|
||||||
stats = batter_stats(hits=1, doubles=1, triples=1, hr=0)
|
|
||||||
# singles will be -1; the formula does NOT clamp, so TB = 4 and value = 8.0
|
|
||||||
result = compute_batter_value(stats)
|
|
||||||
assert result == 8.0, (
|
|
||||||
f"Expected 8.0 (negative singles flows through unclamped), got {result}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_batter_negative_singles_is_not_clamped():
|
|
||||||
"""A singles value below zero is NOT clamped to zero by the formula.
|
|
||||||
|
|
||||||
What: Confirms that singles < 0 propagates into TB rather than being
|
|
||||||
floored at 0. If clamping were added, tb would be 0*1 + 1*2 + 1*3 = 5
|
|
||||||
and value would be 10.0, not 8.0.
|
|
||||||
|
|
||||||
Why: Guards future refactors — if someone adds `singles = max(0, ...)`,
|
|
||||||
this assertion will fail immediately, surfacing the behaviour change.
|
|
||||||
"""
|
|
||||||
stats = batter_stats(hits=1, doubles=1, triples=1, hr=0)
|
|
||||||
unclamped_value = compute_batter_value(stats)
|
|
||||||
# If singles were clamped to 0: tb = 0+2+3 = 5, value = 10.0
|
|
||||||
clamped_value = 10.0
|
|
||||||
assert unclamped_value != clamped_value, (
|
|
||||||
"Formula appears to clamp negative singles — behaviour has changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T1-2: Tier boundary precision with float SP values
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_sp_tier_just_below_t1_outs29():
|
|
||||||
"""SP with outs=29 produces IP=9.666..., which is below T1 threshold (10) → T0.
|
|
||||||
|
|
||||||
What: 29 outs / 3 = 9.6666... IP + 0 K = 9.6666... value.
|
|
||||||
The SP T1 threshold is 10.0, so this value is strictly below T1.
|
|
||||||
|
|
||||||
Why: Floating-point IP values accumulate slowly for pitchers. A bug that
|
|
||||||
truncated or rounded IP upward could cause premature tier advancement.
|
|
||||||
Verify that tier_from_value uses a >= comparison (not >) and handles
|
|
||||||
non-integer values correctly.
|
|
||||||
"""
|
|
||||||
stats = pitcher_stats(outs=29, strikeouts=0)
|
|
||||||
value = compute_sp_value(stats)
|
|
||||||
assert value == pytest.approx(29 / 3) # 9.6666...
|
|
||||||
assert value < 10.0 # strictly below T1
|
|
||||||
assert tier_from_value(value, track_dict("sp")) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_sp_tier_exactly_t1_outs30():
|
|
||||||
"""SP with outs=30 produces IP=10.0, exactly at T1 threshold → T1.
|
|
||||||
|
|
||||||
What: 30 outs / 3 = 10.0 IP + 0 K = 10.0 value.
|
|
||||||
The SP T1 threshold is 10.0, so value == t1 satisfies the >= condition.
|
|
||||||
|
|
||||||
Why: Off-by-one or strictly-greater-than comparisons would classify
|
|
||||||
this as T0 instead of T1. The boundary value must correctly promote
|
|
||||||
to the matching tier.
|
|
||||||
"""
|
|
||||||
stats = pitcher_stats(outs=30, strikeouts=0)
|
|
||||||
value = compute_sp_value(stats)
|
|
||||||
assert value == 10.0
|
|
||||||
assert tier_from_value(value, track_dict("sp")) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_sp_float_value_at_exact_t2_boundary():
|
|
||||||
"""SP value exactly at T2 threshold (40.0) → T2.
|
|
||||||
|
|
||||||
What: outs=120 -> IP=40.0, strikeouts=0 -> value=40.0.
|
|
||||||
T2 threshold for SP is 40. The >= comparison must promote to T2.
|
|
||||||
|
|
||||||
Why: Validates that all four tier thresholds use inclusive lower-bound
|
|
||||||
comparisons for float values, not just T1.
|
|
||||||
"""
|
|
||||||
stats = pitcher_stats(outs=120, strikeouts=0)
|
|
||||||
value = compute_sp_value(stats)
|
|
||||||
assert value == 40.0
|
|
||||||
assert tier_from_value(value, track_dict("sp")) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_sp_float_value_just_below_t2():
|
|
||||||
"""SP value just below T2 (39.999...) stays at T1.
|
|
||||||
|
|
||||||
What: outs=119 -> IP=39.6666..., strikeouts=0 -> value=39.666...
|
|
||||||
This is strictly less than T2=40, so tier should be 1 (already past T1=10).
|
|
||||||
|
|
||||||
Why: Confirms that sub-threshold float values are not prematurely promoted
|
|
||||||
due to floating-point comparison imprecision.
|
|
||||||
"""
|
|
||||||
stats = pitcher_stats(outs=119, strikeouts=0)
|
|
||||||
value = compute_sp_value(stats)
|
|
||||||
assert value == pytest.approx(119 / 3) # 39.666...
|
|
||||||
assert value < 40.0
|
|
||||||
assert tier_from_value(value, track_dict("sp")) == 1
|
|
||||||
|
|||||||
@ -48,39 +48,35 @@ import os
|
|||||||
os.environ.setdefault("API_TOKEN", "test-token")
|
os.environ.setdefault("API_TOKEN", "test-token")
|
||||||
|
|
||||||
import app.services.season_stats as _season_stats_module
|
import app.services.season_stats as _season_stats_module
|
||||||
import app.services.refractor_boost as _refractor_boost_module
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from peewee import SqliteDatabase
|
from peewee import SqliteDatabase
|
||||||
|
|
||||||
from app.db_engine import (
|
from app.db_engine import (
|
||||||
BattingCard,
|
|
||||||
BattingCardRatings,
|
|
||||||
Cardset,
|
Cardset,
|
||||||
Decision,
|
RefractorCardState,
|
||||||
Event,
|
RefractorCosmetic,
|
||||||
|
RefractorTierBoost,
|
||||||
|
RefractorTrack,
|
||||||
MlbPlayer,
|
MlbPlayer,
|
||||||
Pack,
|
Pack,
|
||||||
PackType,
|
PackType,
|
||||||
PitchingCard,
|
|
||||||
PitchingCardRatings,
|
|
||||||
Player,
|
Player,
|
||||||
BattingSeasonStats,
|
BattingSeasonStats,
|
||||||
PitchingSeasonStats,
|
PitchingSeasonStats,
|
||||||
ProcessedGame,
|
ProcessedGame,
|
||||||
Rarity,
|
Rarity,
|
||||||
RefractorBoostAudit,
|
|
||||||
RefractorCardState,
|
|
||||||
RefractorTrack,
|
|
||||||
Roster,
|
Roster,
|
||||||
RosterSlot,
|
RosterSlot,
|
||||||
ScoutClaim,
|
ScoutClaim,
|
||||||
ScoutOpportunity,
|
ScoutOpportunity,
|
||||||
StratGame,
|
StratGame,
|
||||||
StratPlay,
|
StratPlay,
|
||||||
|
Decision,
|
||||||
Team,
|
Team,
|
||||||
Card,
|
Card,
|
||||||
|
Event,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -115,20 +111,15 @@ _WP13_MODELS = [
|
|||||||
BattingSeasonStats,
|
BattingSeasonStats,
|
||||||
PitchingSeasonStats,
|
PitchingSeasonStats,
|
||||||
ProcessedGame,
|
ProcessedGame,
|
||||||
BattingCard,
|
|
||||||
BattingCardRatings,
|
|
||||||
PitchingCard,
|
|
||||||
PitchingCardRatings,
|
|
||||||
RefractorTrack,
|
RefractorTrack,
|
||||||
RefractorCardState,
|
RefractorCardState,
|
||||||
RefractorBoostAudit,
|
RefractorTierBoost,
|
||||||
|
RefractorCosmetic,
|
||||||
]
|
]
|
||||||
|
|
||||||
# Patch the service-layer 'db' references to use our shared test database so
|
# Patch the service-layer 'db' reference to use our shared test database so
|
||||||
# that db.atomic() in update_season_stats() and apply_tier_boost() operate on
|
# that db.atomic() in update_season_stats() operates on the same connection.
|
||||||
# the same connection.
|
|
||||||
_season_stats_module.db = _wp13_db
|
_season_stats_module.db = _wp13_db
|
||||||
_refractor_boost_module.db = _wp13_db
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Auth header used by every authenticated request
|
# 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}
|
# 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)
|
game = _make_game(team_a, team_b)
|
||||||
track = _make_track(name="WP13 Tier Adv Track")
|
track = _make_track(name="WP13 Tier Adv Track")
|
||||||
_make_state(batter, team_a, track, current_tier=0, current_value=34.0)
|
_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)
|
# Seed prior stats: 34 PA (value = 34; T1 threshold = 37)
|
||||||
BattingSeasonStats.create(
|
BattingSeasonStats.create(
|
||||||
@ -637,8 +567,6 @@ def test_evaluate_game_tier_ups_in_response(client):
|
|||||||
game = _make_game(team_a, team_b)
|
game = _make_game(team_a, team_b)
|
||||||
track = _make_track(name="WP13 Tier-Ups Track")
|
track = _make_track(name="WP13 Tier-Ups Track")
|
||||||
_make_state(batter, team_a, track, current_tier=0)
|
_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
|
# Seed prior stats below threshold
|
||||||
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
BattingSeasonStats.create(player=batter, team=team_a, season=10, pa=34)
|
||||||
@ -737,565 +665,3 @@ def test_auth_required_evaluate_game(client):
|
|||||||
|
|
||||||
resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}")
|
resp = client.post(f"/api/v2/refractor/evaluate-game/{game.id}")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T1-3: evaluate-game with non-existent game_id
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_game_nonexistent_game_id(client):
|
|
||||||
"""POST /refractor/evaluate-game/99999 with a game_id that does not exist.
|
|
||||||
|
|
||||||
What: There is no StratGame row with id=99999. The endpoint queries
|
|
||||||
StratPlay for plays in that game, finds zero rows, builds an empty
|
|
||||||
pairs set, and returns without evaluating anyone.
|
|
||||||
|
|
||||||
Why: Documents the confirmed behaviour: 200 with {"evaluated": 0,
|
|
||||||
"tier_ups": []}. The endpoint does not treat a missing game as an
|
|
||||||
error because StratPlay.select().where(game_id=N) returning 0 rows is
|
|
||||||
a valid (if unusual) outcome — there are simply no players to evaluate.
|
|
||||||
|
|
||||||
If the implementation is ever changed to return 404 for missing games,
|
|
||||||
this test will fail and alert the developer to update the contract.
|
|
||||||
"""
|
|
||||||
resp = client.post("/api/v2/refractor/evaluate-game/99999", headers=AUTH_HEADER)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["evaluated"] == 0
|
|
||||||
assert data["tier_ups"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T2-3: evaluate-game with zero plays
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_game_zero_plays(client):
|
|
||||||
"""evaluate-game on a game with no StratPlay rows returns empty results.
|
|
||||||
|
|
||||||
What: Create a StratGame but insert zero StratPlay rows for it. POST
|
|
||||||
to evaluate-game for that game_id.
|
|
||||||
|
|
||||||
Why: The endpoint builds its player list from StratPlay rows. A game
|
|
||||||
with no plays has no players to evaluate. Verify the endpoint does not
|
|
||||||
crash and returns the expected empty-batch shape rather than raising a
|
|
||||||
KeyError or returning an unexpected structure.
|
|
||||||
"""
|
|
||||||
team_a = _make_team("ZP1", gmid=20101)
|
|
||||||
team_b = _make_team("ZP2", gmid=20102)
|
|
||||||
game = _make_game(team_a, team_b)
|
|
||||||
# Intentionally no plays created
|
|
||||||
|
|
||||||
resp = client.post(
|
|
||||||
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["evaluated"] == 0
|
|
||||||
assert data["tier_ups"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T2-9: Per-player error isolation in evaluate_game
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_game_error_isolation(client, monkeypatch):
|
|
||||||
"""An exception raised for one player does not abort the rest of the batch.
|
|
||||||
|
|
||||||
What: Create two batters in the same game. Both have RefractorCardState
|
|
||||||
rows. Patch evaluate_card in the refractor router to raise RuntimeError
|
|
||||||
on the first call and succeed on the second. Verify the endpoint returns
|
|
||||||
200, evaluated==1 (not 0 or 2), and no tier_ups from the failing player.
|
|
||||||
|
|
||||||
Why: The evaluate-game loop catches per-player exceptions and logs them.
|
|
||||||
If the isolation breaks, a single bad card would silently drop all
|
|
||||||
evaluations for the rest of the game. The 'evaluated' count is the
|
|
||||||
observable signal that error isolation is functioning.
|
|
||||||
|
|
||||||
Implementation note: we patch the evaluate_card function inside the
|
|
||||||
router module directly so that the test is independent of how the router
|
|
||||||
imports it. We use a counter to let the first call fail and the second
|
|
||||||
succeed.
|
|
||||||
"""
|
|
||||||
from app.services import refractor_evaluator
|
|
||||||
|
|
||||||
team_a = _make_team("EI1", gmid=20111)
|
|
||||||
team_b = _make_team("EI2", gmid=20112)
|
|
||||||
|
|
||||||
batter_fail = _make_player("WP13 Fail Batter", pos="1B")
|
|
||||||
batter_ok = _make_player("WP13 Ok Batter", pos="1B")
|
|
||||||
pitcher = _make_player("WP13 EI Pitcher", pos="SP")
|
|
||||||
|
|
||||||
game = _make_game(team_a, team_b)
|
|
||||||
|
|
||||||
# Both batters need season stats and a track/state so they are not
|
|
||||||
# skipped by the "no state" guard before evaluate_card is called.
|
|
||||||
track = _make_track(name="EI Batter Track")
|
|
||||||
_make_state(batter_fail, team_a, track)
|
|
||||||
_make_state(batter_ok, team_a, track)
|
|
||||||
|
|
||||||
_make_play(game, 1, batter_fail, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
||||||
_make_play(game, 2, batter_ok, team_a, pitcher, team_b, pa=1, ab=1, outs=1)
|
|
||||||
|
|
||||||
# The real evaluate_card for batter_ok so we know what it returns
|
|
||||||
real_evaluate = refractor_evaluator.evaluate_card
|
|
||||||
|
|
||||||
call_count = {"n": 0}
|
|
||||||
fail_player_id = batter_fail.player_id
|
|
||||||
|
|
||||||
def patched_evaluate(player_id, team_id, **kwargs):
|
|
||||||
call_count["n"] += 1
|
|
||||||
if player_id == fail_player_id:
|
|
||||||
raise RuntimeError("simulated per-player error")
|
|
||||||
return real_evaluate(player_id, team_id, **kwargs)
|
|
||||||
|
|
||||||
# The router does `from ..services.refractor_evaluator import evaluate_card`
|
|
||||||
# inside the async function body, so the local import re-resolves on each
|
|
||||||
# call. Patching the function on its source module ensures the local `from`
|
|
||||||
# import picks up our patched version when the route handler executes.
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"app.services.refractor_evaluator.evaluate_card", patched_evaluate
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = client.post(
|
|
||||||
f"/api/v2/refractor/evaluate-game/{game.id}", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
# One player succeeded; one was caught by the exception handler
|
|
||||||
assert data["evaluated"] == 1
|
|
||||||
# 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
@ -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(
|
return evaluate_card(
|
||||||
player_id,
|
player_id,
|
||||||
team_id,
|
team_id,
|
||||||
dry_run=dry_run,
|
|
||||||
_stats_model=StatsStub,
|
_stats_model=StatsStub,
|
||||||
_state_model=CardStateStub,
|
_state_model=CardStateStub,
|
||||||
_compute_value_fn=_compute_value,
|
_compute_value_fn=_compute_value,
|
||||||
@ -326,59 +325,6 @@ class TestCareerTotals:
|
|||||||
assert result["current_value"] == 50.0
|
assert result["current_value"] == 50.0
|
||||||
|
|
||||||
|
|
||||||
class TestFullyEvolvedPersistence:
|
|
||||||
"""T2-1: fully_evolved=True is preserved even when stats drop or are absent."""
|
|
||||||
|
|
||||||
def test_fully_evolved_persists_when_stats_zeroed(self, batter_track):
|
|
||||||
"""Card at T4/fully_evolved=True stays fully_evolved after stats are removed.
|
|
||||||
|
|
||||||
What: Set up a RefractorCardState at tier=4 with fully_evolved=True.
|
|
||||||
Then call evaluate_card with no season stats rows (zero career totals).
|
|
||||||
The evaluator computes value=0 -> new_tier=0, but current_tier must
|
|
||||||
stay at 4 (no regression) and fully_evolved must remain True.
|
|
||||||
|
|
||||||
Why: fully_evolved is a permanent achievement flag — it must not be
|
|
||||||
revoked if a team's stats are rolled back, corrected, or simply not
|
|
||||||
yet imported. The no-regression rule (max(current, new)) prevents
|
|
||||||
tier demotion; this test confirms that fully_evolved follows the same
|
|
||||||
protection.
|
|
||||||
"""
|
|
||||||
# Seed state at T4 fully_evolved
|
|
||||||
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
|
|
||||||
# No stats rows — career totals will be all zeros
|
|
||||||
# (no _make_stats call)
|
|
||||||
|
|
||||||
result = _eval(1, 1)
|
|
||||||
|
|
||||||
# The no-regression rule keeps tier at 4
|
|
||||||
assert result["current_tier"] == 4, (
|
|
||||||
f"Expected tier=4 (no regression), got {result['current_tier']}"
|
|
||||||
)
|
|
||||||
# fully_evolved must still be True since tier >= 4
|
|
||||||
assert result["fully_evolved"] is True, (
|
|
||||||
"fully_evolved was reset to False after re-evaluation with zero stats"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fully_evolved_persists_with_partial_stats(self, batter_track):
|
|
||||||
"""Card at T4 stays fully_evolved even with stats below T1.
|
|
||||||
|
|
||||||
What: Same setup as above but with a season stats row giving value=30
|
|
||||||
(below T1=37). The computed tier would be 0, but current_tier must
|
|
||||||
not regress from 4.
|
|
||||||
|
|
||||||
Why: Validates that no-regression applies regardless of whether stats
|
|
||||||
are zero or merely insufficient for the achieved tier.
|
|
||||||
"""
|
|
||||||
_make_state(1, 1, batter_track, current_tier=4, current_value=900.0)
|
|
||||||
# pa=30 -> value=30, which is below T1=37 -> computed tier=0
|
|
||||||
_make_stats(1, 1, 1, pa=30)
|
|
||||||
|
|
||||||
result = _eval(1, 1)
|
|
||||||
|
|
||||||
assert result["current_tier"] == 4
|
|
||||||
assert result["fully_evolved"] is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestMissingState:
|
class TestMissingState:
|
||||||
"""ValueError when no card state exists for (player_id, team_id)."""
|
"""ValueError when no card state exists for (player_id, team_id)."""
|
||||||
|
|
||||||
@ -393,20 +339,13 @@ class TestReturnShape:
|
|||||||
"""Return dict has the expected keys and types."""
|
"""Return dict has the expected keys and types."""
|
||||||
|
|
||||||
def test_return_keys(self, batter_track):
|
def test_return_keys(self, batter_track):
|
||||||
"""Result dict contains all expected keys.
|
"""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.
|
|
||||||
"""
|
|
||||||
_make_state(1, 1, batter_track)
|
_make_state(1, 1, batter_track)
|
||||||
result = _eval(1, 1)
|
result = _eval(1, 1)
|
||||||
assert set(result.keys()) == {
|
assert set(result.keys()) == {
|
||||||
"player_id",
|
"player_id",
|
||||||
"team_id",
|
"team_id",
|
||||||
"current_tier",
|
"current_tier",
|
||||||
"computed_tier",
|
|
||||||
"computed_fully_evolved",
|
|
||||||
"current_value",
|
"current_value",
|
||||||
"fully_evolved",
|
"fully_evolved",
|
||||||
"last_evaluated_at",
|
"last_evaluated_at",
|
||||||
@ -420,385 +359,3 @@ class TestReturnShape:
|
|||||||
assert isinstance(ts, str) and len(ts) > 0
|
assert isinstance(ts, str) and len(ts) > 0
|
||||||
# Must be parseable as a datetime
|
# Must be parseable as a datetime
|
||||||
datetime.fromisoformat(ts)
|
datetime.fromisoformat(ts)
|
||||||
|
|
||||||
|
|
||||||
class TestFullyEvolvedFlagCorrection:
|
|
||||||
"""T3-7: fully_evolved/tier mismatch is corrected by evaluate_card.
|
|
||||||
|
|
||||||
A database corruption where fully_evolved=True but current_tier < 4 can
|
|
||||||
occur if the flag was set incorrectly by a migration or external script.
|
|
||||||
evaluate_card must re-derive fully_evolved from the freshly-computed tier
|
|
||||||
(after the no-regression max() is applied), not trust the stored flag.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_fully_evolved_flag_corrected_when_tier_below_4(self, batter_track):
|
|
||||||
"""fully_evolved=True with current_tier=3 is corrected to False after evaluation.
|
|
||||||
|
|
||||||
What: Manually set database state to fully_evolved=True, current_tier=3
|
|
||||||
(a corruption scenario — tier 3 cannot be "fully evolved" since T4 is
|
|
||||||
the maximum tier). Provide stats that compute to a value in the T3
|
|
||||||
range (value=500, which is >= T3=448 but < T4=896).
|
|
||||||
|
|
||||||
After evaluate_card:
|
|
||||||
- computed value = 500 → new_tier = 3
|
|
||||||
- no-regression: max(current_tier=3, new_tier=3) = 3 → tier stays 3
|
|
||||||
- fully_evolved = (3 >= 4) = False → flag is corrected
|
|
||||||
|
|
||||||
Why: The evaluator always recomputes fully_evolved from the final
|
|
||||||
current_tier rather than preserving the stored flag. This ensures
|
|
||||||
that a corrupted fully_evolved=True at tier<4 is silently repaired
|
|
||||||
on the next evaluation without requiring a separate migration.
|
|
||||||
"""
|
|
||||||
# Inject corruption: fully_evolved=True but tier=3
|
|
||||||
state = CardStateStub.create(
|
|
||||||
player_id=1,
|
|
||||||
team_id=1,
|
|
||||||
track=batter_track,
|
|
||||||
current_tier=3,
|
|
||||||
current_value=500.0,
|
|
||||||
fully_evolved=True, # intentionally wrong
|
|
||||||
last_evaluated_at=None,
|
|
||||||
)
|
|
||||||
# Stats that compute to value=500: pa=500, no hits → value=500+0=500
|
|
||||||
# T3 threshold=448, T4 threshold=896 → tier=3, NOT 4
|
|
||||||
_make_stats(1, 1, 1, pa=500)
|
|
||||||
|
|
||||||
result = _eval(1, 1)
|
|
||||||
|
|
||||||
assert result["current_tier"] == 3, (
|
|
||||||
f"Expected tier=3 after evaluation with value=500, got {result['current_tier']}"
|
|
||||||
)
|
|
||||||
assert result["fully_evolved"] is False, (
|
|
||||||
"fully_evolved should have been corrected to False for tier=3, "
|
|
||||||
f"got {result['fully_evolved']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Confirm the database row was updated (not just the return dict)
|
|
||||||
state_reloaded = CardStateStub.get_by_id(state.id)
|
|
||||||
assert state_reloaded.fully_evolved is False, (
|
|
||||||
"fully_evolved was not persisted as False after correction"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_fully_evolved_flag_preserved_when_tier_reaches_4(self, batter_track):
|
|
||||||
"""fully_evolved=True with current_tier=3 stays True when new stats push to T4.
|
|
||||||
|
|
||||||
What: Same corruption setup as above (fully_evolved=True, tier=3),
|
|
||||||
but now provide stats with value=900 (>= T4=896).
|
|
||||||
|
|
||||||
After evaluate_card:
|
|
||||||
- computed value = 900 → new_tier = 4
|
|
||||||
- no-regression: max(current_tier=3, new_tier=4) = 4 → advances to 4
|
|
||||||
- fully_evolved = (4 >= 4) = True → flag stays True (correctly)
|
|
||||||
|
|
||||||
Why: Confirms the evaluator correctly sets fully_evolved=True when
|
|
||||||
the re-computed tier legitimately reaches T4 regardless of whether
|
|
||||||
the stored flag was already True before evaluation.
|
|
||||||
"""
|
|
||||||
CardStateStub.create(
|
|
||||||
player_id=1,
|
|
||||||
team_id=1,
|
|
||||||
track=batter_track,
|
|
||||||
current_tier=3,
|
|
||||||
current_value=500.0,
|
|
||||||
fully_evolved=True, # stored flag (will be re-derived)
|
|
||||||
last_evaluated_at=None,
|
|
||||||
)
|
|
||||||
# pa=900 → value=900 >= T4=896 → new_tier=4
|
|
||||||
_make_stats(1, 1, 1, pa=900)
|
|
||||||
|
|
||||||
result = _eval(1, 1)
|
|
||||||
|
|
||||||
assert result["current_tier"] == 4, (
|
|
||||||
f"Expected tier=4 for value=900, got {result['current_tier']}"
|
|
||||||
)
|
|
||||||
assert result["fully_evolved"] is True, (
|
|
||||||
f"Expected fully_evolved=True for tier=4, got {result['fully_evolved']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMultiTeamStatIsolation:
|
|
||||||
"""T3-8: A player's refractor value is isolated to a specific team's stats.
|
|
||||||
|
|
||||||
The evaluator queries BattingSeasonStats WHERE player_id=? AND team_id=?.
|
|
||||||
When a player has stats on two different teams in the same season, each
|
|
||||||
team's RefractorCardState must reflect only that team's stats — not a
|
|
||||||
combined total.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_multi_team_same_season_stats_isolated(self, batter_track):
|
|
||||||
"""Each team's refractor value reflects only that team's stats, not combined.
|
|
||||||
|
|
||||||
What: Create one player with BattingSeasonStats on team_id=1 (pa=80)
|
|
||||||
and team_id=2 (pa=120) in the same season. Create a RefractorCardState
|
|
||||||
for each team. Evaluate each team's card separately and verify:
|
|
||||||
- Team 1 state: value = 80 → tier = T1 (80 >= T1=37, < T2=149)
|
|
||||||
- Team 2 state: value = 120 → tier = T1 (120 >= T1=37, < T2=149)
|
|
||||||
- Neither value equals the combined total (80+120=200 → would be T2)
|
|
||||||
|
|
||||||
Why: Confirms the `WHERE player_id=? AND team_id=?` filter in the
|
|
||||||
evaluator is correctly applied. Without proper team isolation, the
|
|
||||||
combined total of 200 would cross the T2 threshold (149) and both
|
|
||||||
states would be incorrectly assigned to T2. This is a critical
|
|
||||||
correctness requirement: a player traded between teams should have
|
|
||||||
separate refractor progressions for their time with each franchise.
|
|
||||||
"""
|
|
||||||
# Stats on team 1: pa=80 → value=80 (T1: 37<=80<149)
|
|
||||||
_make_stats(player_id=1, team_id=1, season=11, pa=80)
|
|
||||||
# Stats on team 2: pa=120 → value=120 (T1: 37<=120<149)
|
|
||||||
_make_stats(player_id=1, team_id=2, season=11, pa=120)
|
|
||||||
|
|
||||||
# combined pa would be 200 → value=200 → T2 (149<=200<448)
|
|
||||||
# Each team must see only its own stats, not 200
|
|
||||||
|
|
||||||
_make_state(player_id=1, team_id=1, track=batter_track)
|
|
||||||
_make_state(player_id=1, team_id=2, track=batter_track)
|
|
||||||
|
|
||||||
result_team1 = _eval(player_id=1, team_id=1)
|
|
||||||
result_team2 = _eval(player_id=1, team_id=2)
|
|
||||||
|
|
||||||
# Team 1: only pa=80 counted → value=80 → T1
|
|
||||||
assert result_team1["current_value"] == 80.0, (
|
|
||||||
f"Team 1 value should be 80.0 (its own stats only), "
|
|
||||||
f"got {result_team1['current_value']}"
|
|
||||||
)
|
|
||||||
assert result_team1["current_tier"] == 1, (
|
|
||||||
f"Team 1 tier should be T1 for value=80, got {result_team1['current_tier']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Team 2: only pa=120 counted → value=120 → T1
|
|
||||||
assert result_team2["current_value"] == 120.0, (
|
|
||||||
f"Team 2 value should be 120.0 (its own stats only), "
|
|
||||||
f"got {result_team2['current_value']}"
|
|
||||||
)
|
|
||||||
assert result_team2["current_tier"] == 1, (
|
|
||||||
f"Team 2 tier should be T1 for value=120, got {result_team2['current_tier']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sanity: neither team crossed T2 (which would happen if stats were combined)
|
|
||||||
assert (
|
|
||||||
result_team1["current_tier"] != 2 and result_team2["current_tier"] != 2
|
|
||||||
), (
|
|
||||||
"At least one team was incorrectly assigned T2 — stats may have been combined"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_multi_team_different_seasons_isolated(self, batter_track):
|
|
||||||
"""Stats for the same player across multiple seasons remain per-team isolated.
|
|
||||||
|
|
||||||
What: Same player with two seasons of stats for each of two teams:
|
|
||||||
- team_id=1: season 10 pa=90, season 11 pa=70 → combined=160
|
|
||||||
- team_id=2: season 10 pa=100, season 11 pa=80 → combined=180
|
|
||||||
|
|
||||||
After evaluation:
|
|
||||||
- Team 1: value=160 → T2 (149<=160<448)
|
|
||||||
- Team 2: value=180 → T2 (149<=180<448)
|
|
||||||
|
|
||||||
The test confirms that cross-team season aggregation does not bleed
|
|
||||||
stats from team 2 into team 1's calculation or vice versa.
|
|
||||||
|
|
||||||
Why: Multi-season aggregation and multi-team isolation must work
|
|
||||||
together. A bug that incorrectly sums all player stats regardless
|
|
||||||
of team would produce combined values of 340 → T2, which coincidentally
|
|
||||||
passes, but the per-team values and tiers would be wrong.
|
|
||||||
This test uses values where cross-contamination would produce a
|
|
||||||
materially different value (340 vs 160/180), catching that class of bug.
|
|
||||||
"""
|
|
||||||
# Team 1 stats: total pa=160 → value=160 → T2
|
|
||||||
_make_stats(player_id=1, team_id=1, season=10, pa=90)
|
|
||||||
_make_stats(player_id=1, team_id=1, season=11, pa=70)
|
|
||||||
|
|
||||||
# Team 2 stats: total pa=180 → value=180 → T2
|
|
||||||
_make_stats(player_id=1, team_id=2, season=10, pa=100)
|
|
||||||
_make_stats(player_id=1, team_id=2, season=11, pa=80)
|
|
||||||
|
|
||||||
_make_state(player_id=1, team_id=1, track=batter_track)
|
|
||||||
_make_state(player_id=1, team_id=2, track=batter_track)
|
|
||||||
|
|
||||||
result_team1 = _eval(player_id=1, team_id=1)
|
|
||||||
result_team2 = _eval(player_id=1, team_id=2)
|
|
||||||
|
|
||||||
assert result_team1["current_value"] == 160.0, (
|
|
||||||
f"Team 1 multi-season value should be 160.0, got {result_team1['current_value']}"
|
|
||||||
)
|
|
||||||
assert result_team1["current_tier"] == 2, (
|
|
||||||
f"Team 1 tier should be T2 for value=160, got {result_team1['current_tier']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result_team2["current_value"] == 180.0, (
|
|
||||||
f"Team 2 multi-season value should be 180.0, got {result_team2['current_value']}"
|
|
||||||
)
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|||||||
@ -158,50 +158,6 @@ class TestDetermineCardType:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestDetermineCardTypeEdgeCases:
|
|
||||||
"""T2-2: Parametrized edge cases for _determine_card_type.
|
|
||||||
|
|
||||||
Covers all the boundary inputs identified in the PO review:
|
|
||||||
DH, C, 2B (batters), empty string, None, and the compound 'SP/RP'
|
|
||||||
which contains both 'SP' and 'RP' substrings.
|
|
||||||
|
|
||||||
The function checks 'SP' before 'RP'/'CP', so 'SP/RP' resolves to 'sp'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"pos_1, expected",
|
|
||||||
[
|
|
||||||
# Plain batter positions
|
|
||||||
("DH", "batter"),
|
|
||||||
("C", "batter"),
|
|
||||||
("2B", "batter"),
|
|
||||||
# Empty / None — fall through to batter default
|
|
||||||
("", "batter"),
|
|
||||||
(None, "batter"),
|
|
||||||
# Compound string containing 'SP' first — must resolve to 'sp'
|
|
||||||
# because _determine_card_type checks "SP" in pos.upper() before RP/CP
|
|
||||||
("SP/RP", "sp"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_position_mapping(self, pos_1, expected):
|
|
||||||
"""_determine_card_type maps each pos_1 value to the expected card_type.
|
|
||||||
|
|
||||||
What: Directly exercises _determine_card_type with the given pos_1 string.
|
|
||||||
None is handled by the `(player.pos_1 or "").upper()` guard in the
|
|
||||||
implementation, so it falls through to 'batter'.
|
|
||||||
|
|
||||||
Why: The card_type string is the key used to look up a RefractorTrack.
|
|
||||||
An incorrect mapping silently assigns the wrong thresholds to a player's
|
|
||||||
entire refractor journey. Parametrized so each edge case is a
|
|
||||||
distinct, independently reported test failure.
|
|
||||||
"""
|
|
||||||
player = _FakePlayer(pos_1)
|
|
||||||
assert _determine_card_type(player) == expected, (
|
|
||||||
f"pos_1={pos_1!r}: expected {expected!r}, "
|
|
||||||
f"got {_determine_card_type(player)!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInitializeCardEvolution:
|
class TestInitializeCardEvolution:
|
||||||
"""Integration tests for initialize_card_refractor against in-memory SQLite.
|
"""Integration tests for initialize_card_refractor against in-memory SQLite.
|
||||||
|
|
||||||
|
|||||||
@ -5,6 +5,8 @@ Covers WP-01 acceptance criteria:
|
|||||||
- RefractorTrack: CRUD and unique-name constraint
|
- RefractorTrack: CRUD and unique-name constraint
|
||||||
- RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
|
- RefractorCardState: CRUD, defaults, unique-(player,team) constraint,
|
||||||
and FK resolution back to RefractorTrack
|
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),
|
- BattingSeasonStats: CRUD with defaults, unique-(player, team, season),
|
||||||
and in-place stat accumulation
|
and in-place stat accumulation
|
||||||
|
|
||||||
@ -20,6 +22,8 @@ from playhouse.shortcuts import model_to_dict
|
|||||||
from app.db_engine import (
|
from app.db_engine import (
|
||||||
BattingSeasonStats,
|
BattingSeasonStats,
|
||||||
RefractorCardState,
|
RefractorCardState,
|
||||||
|
RefractorCosmetic,
|
||||||
|
RefractorTierBoost,
|
||||||
RefractorTrack,
|
RefractorTrack,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -130,6 +134,115 @@ class TestRefractorCardState:
|
|||||||
assert resolved_track.name == "Batter Track"
|
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
|
# BattingSeasonStats
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@ -124,89 +124,6 @@ def test_seed_idempotent():
|
|||||||
assert RefractorTrack.select().count() == 3
|
assert RefractorTrack.select().count() == 3
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T1-4: Seed threshold ordering invariant (t1 < t2 < t3 < t4 + all positive)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_all_thresholds_strictly_ascending_after_seed():
|
|
||||||
"""After seeding, every track satisfies t1 < t2 < t3 < t4.
|
|
||||||
|
|
||||||
What: Call seed_refractor_tracks(), then assert the full ordering chain
|
|
||||||
t1 < t2 < t3 < t4 for every row in the database. Also assert that all
|
|
||||||
four thresholds are strictly positive (> 0).
|
|
||||||
|
|
||||||
Why: The refractor tier engine uses these thresholds as exclusive partition
|
|
||||||
points. If any threshold is out-of-order or zero the tier assignment
|
|
||||||
becomes incorrect or undefined. This test is the authoritative invariant
|
|
||||||
guard; if a JSON edit accidentally violates the ordering this test fails
|
|
||||||
loudly before any cards are affected.
|
|
||||||
|
|
||||||
Separate from test_seed_thresholds_ascending which was written earlier —
|
|
||||||
this test combines ordering + positivity into a single explicit assertion
|
|
||||||
block and uses more descriptive messages to aid debugging.
|
|
||||||
"""
|
|
||||||
seed_refractor_tracks()
|
|
||||||
for track in RefractorTrack.select():
|
|
||||||
assert track.t1_threshold > 0, (
|
|
||||||
f"{track.name}: t1_threshold={track.t1_threshold} is not positive"
|
|
||||||
)
|
|
||||||
assert track.t2_threshold > 0, (
|
|
||||||
f"{track.name}: t2_threshold={track.t2_threshold} is not positive"
|
|
||||||
)
|
|
||||||
assert track.t3_threshold > 0, (
|
|
||||||
f"{track.name}: t3_threshold={track.t3_threshold} is not positive"
|
|
||||||
)
|
|
||||||
assert track.t4_threshold > 0, (
|
|
||||||
f"{track.name}: t4_threshold={track.t4_threshold} is not positive"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
track.t1_threshold
|
|
||||||
< track.t2_threshold
|
|
||||||
< track.t3_threshold
|
|
||||||
< track.t4_threshold
|
|
||||||
), (
|
|
||||||
f"{track.name}: thresholds are not strictly ascending: "
|
|
||||||
f"t1={track.t1_threshold}, t2={track.t2_threshold}, "
|
|
||||||
f"t3={track.t3_threshold}, t4={track.t4_threshold}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T2-10: Duplicate card_type tracks guard
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_each_card_type_has_exactly_one_track():
|
|
||||||
"""Each card_type must appear exactly once across all RefractorTrack rows.
|
|
||||||
|
|
||||||
What: After seeding, group the rows by card_type and assert that every
|
|
||||||
card_type has a count of exactly 1.
|
|
||||||
|
|
||||||
Why: RefractorTrack rows are looked up by card_type (e.g.
|
|
||||||
RefractorTrack.get(card_type='batter')). If a card_type appears more
|
|
||||||
than once, Peewee's .get() raises MultipleObjectsReturned, crashing
|
|
||||||
every pack opening and card evaluation for that type. This test acts as
|
|
||||||
a uniqueness contract so that seed bugs or accidental DB drift surface
|
|
||||||
immediately.
|
|
||||||
"""
|
|
||||||
seed_refractor_tracks()
|
|
||||||
from peewee import fn as peewee_fn
|
|
||||||
|
|
||||||
# Group by card_type and count occurrences
|
|
||||||
query = (
|
|
||||||
RefractorTrack.select(
|
|
||||||
RefractorTrack.card_type, peewee_fn.COUNT(RefractorTrack.id).alias("cnt")
|
|
||||||
)
|
|
||||||
.group_by(RefractorTrack.card_type)
|
|
||||||
.tuples()
|
|
||||||
)
|
|
||||||
for card_type, count in query:
|
|
||||||
assert count == 1, (
|
|
||||||
f"card_type={card_type!r} has {count} tracks; expected exactly 1"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_seed_updates_on_rerun(json_tracks):
|
def test_seed_updates_on_rerun(json_tracks):
|
||||||
"""A second seed call must restore any manually changed threshold to the JSON value.
|
"""A second seed call must restore any manually changed threshold to the JSON value.
|
||||||
|
|
||||||
|
|||||||
@ -34,45 +34,12 @@ Test matrix
|
|||||||
test_get_card_404_no_state -- card with no RefractorCardState returns 404
|
test_get_card_404_no_state -- card with no RefractorCardState returns 404
|
||||||
test_duplicate_cards_share_state -- two cards same player+team return the same state row
|
test_duplicate_cards_share_state -- two cards same player+team return the same state row
|
||||||
test_auth_required -- missing token returns 401 on both endpoints
|
test_auth_required -- missing token returns 401 on both endpoints
|
||||||
|
|
||||||
Tier 3 tests (T3-6) use a SQLite-backed TestClient and run without a PostgreSQL
|
|
||||||
connection. They test GET /api/v2/refractor/cards/{card_id} when the state row
|
|
||||||
has last_evaluated_at=None (card initialised but never evaluated).
|
|
||||||
test_get_card_state_last_evaluated_at_null -- last_evaluated_at: null in response
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from peewee import SqliteDatabase
|
|
||||||
|
|
||||||
from app.db_engine import (
|
|
||||||
BattingSeasonStats,
|
|
||||||
Card,
|
|
||||||
Cardset,
|
|
||||||
Decision,
|
|
||||||
Event,
|
|
||||||
MlbPlayer,
|
|
||||||
Pack,
|
|
||||||
PackType,
|
|
||||||
PitchingSeasonStats,
|
|
||||||
Player,
|
|
||||||
ProcessedGame,
|
|
||||||
Rarity,
|
|
||||||
RefractorCardState,
|
|
||||||
RefractorTrack,
|
|
||||||
Roster,
|
|
||||||
RosterSlot,
|
|
||||||
ScoutClaim,
|
|
||||||
ScoutOpportunity,
|
|
||||||
StratGame,
|
|
||||||
StratPlay,
|
|
||||||
Team,
|
|
||||||
)
|
|
||||||
|
|
||||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
||||||
_skip_no_pg = pytest.mark.skipif(
|
_skip_no_pg = pytest.mark.skipif(
|
||||||
@ -640,395 +607,3 @@ def test_auth_required(client, seeded_data):
|
|||||||
|
|
||||||
resp_card = client.get(f"/api/v2/refractor/cards/{card_id}")
|
resp_card = client.get(f"/api/v2/refractor/cards/{card_id}")
|
||||||
assert resp_card.status_code == 401
|
assert resp_card.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# SQLite-backed tests for T2-4, T2-5, T2-6, T3-6
|
|
||||||
#
|
|
||||||
# These tests use the same shared-memory SQLite pattern as test_postgame_refractor
|
|
||||||
# so they run without a PostgreSQL connection. They test the
|
|
||||||
# GET /api/v2/teams/{team_id}/refractors, POST /refractor/cards/{card_id}/evaluate,
|
|
||||||
# and GET /api/v2/refractor/cards/{card_id} endpoints in isolation.
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
_state_api_db = SqliteDatabase(
|
|
||||||
"file:stateapitest?mode=memory&cache=shared",
|
|
||||||
uri=True,
|
|
||||||
pragmas={"foreign_keys": 1},
|
|
||||||
)
|
|
||||||
|
|
||||||
_STATE_API_MODELS = [
|
|
||||||
Rarity,
|
|
||||||
Event,
|
|
||||||
Cardset,
|
|
||||||
MlbPlayer,
|
|
||||||
Player,
|
|
||||||
Team,
|
|
||||||
PackType,
|
|
||||||
Pack,
|
|
||||||
Card,
|
|
||||||
Roster,
|
|
||||||
RosterSlot,
|
|
||||||
StratGame,
|
|
||||||
StratPlay,
|
|
||||||
Decision,
|
|
||||||
ScoutOpportunity,
|
|
||||||
ScoutClaim,
|
|
||||||
BattingSeasonStats,
|
|
||||||
PitchingSeasonStats,
|
|
||||||
ProcessedGame,
|
|
||||||
RefractorTrack,
|
|
||||||
RefractorCardState,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=False)
|
|
||||||
def setup_state_api_db():
|
|
||||||
"""Bind state-api test models to shared-memory SQLite and create tables.
|
|
||||||
|
|
||||||
Not autouse — only the SQLite-backed tests in this section depend on it.
|
|
||||||
"""
|
|
||||||
_state_api_db.bind(_STATE_API_MODELS)
|
|
||||||
_state_api_db.connect(reuse_if_open=True)
|
|
||||||
_state_api_db.create_tables(_STATE_API_MODELS)
|
|
||||||
yield _state_api_db
|
|
||||||
_state_api_db.drop_tables(list(reversed(_STATE_API_MODELS)), safe=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_state_api_app() -> FastAPI:
|
|
||||||
"""Minimal FastAPI app with teams + refractor routers for SQLite tests."""
|
|
||||||
from app.routers_v2.teams import router as teams_router
|
|
||||||
from app.routers_v2.refractor import router as refractor_router
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def db_middleware(request: Request, call_next):
|
|
||||||
_state_api_db.connect(reuse_if_open=True)
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
app.include_router(teams_router)
|
|
||||||
app.include_router(refractor_router)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def state_api_client(setup_state_api_db):
|
|
||||||
"""FastAPI TestClient for the SQLite-backed state API tests."""
|
|
||||||
with TestClient(_build_state_api_app()) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helper factories for SQLite-backed tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_rarity():
|
|
||||||
r, _ = Rarity.get_or_create(
|
|
||||||
value=50, name="SA_Common", defaults={"color": "#aabbcc"}
|
|
||||||
)
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_cardset():
|
|
||||||
cs, _ = Cardset.get_or_create(
|
|
||||||
name="SA Test Set",
|
|
||||||
defaults={"description": "state api test", "total_cards": 10},
|
|
||||||
)
|
|
||||||
return cs
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_team(abbrev: str, gmid: int) -> Team:
|
|
||||||
return Team.create(
|
|
||||||
abbrev=abbrev,
|
|
||||||
sname=abbrev,
|
|
||||||
lname=f"Team {abbrev}",
|
|
||||||
gmid=gmid,
|
|
||||||
gmname=f"gm_{abbrev.lower()}",
|
|
||||||
gsheet="https://docs.google.com/sa_test",
|
|
||||||
wallet=500,
|
|
||||||
team_value=1000,
|
|
||||||
collection_value=1000,
|
|
||||||
season=11,
|
|
||||||
is_ai=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_player(name: str, pos: str = "1B") -> Player:
|
|
||||||
return Player.create(
|
|
||||||
p_name=name,
|
|
||||||
rarity=_sa_make_rarity(),
|
|
||||||
cardset=_sa_make_cardset(),
|
|
||||||
set_num=1,
|
|
||||||
pos_1=pos,
|
|
||||||
image="https://example.com/sa.png",
|
|
||||||
mlbclub="TST",
|
|
||||||
franchise="TST",
|
|
||||||
description=f"sa test: {name}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_track(card_type: str = "batter") -> RefractorTrack:
|
|
||||||
track, _ = RefractorTrack.get_or_create(
|
|
||||||
name=f"SA {card_type} Track",
|
|
||||||
defaults=dict(
|
|
||||||
card_type=card_type,
|
|
||||||
formula="pa + tb * 2",
|
|
||||||
t1_threshold=37,
|
|
||||||
t2_threshold=149,
|
|
||||||
t3_threshold=448,
|
|
||||||
t4_threshold=896,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return track
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_pack(team: Team) -> Pack:
|
|
||||||
pt, _ = PackType.get_or_create(
|
|
||||||
name="SA PackType",
|
|
||||||
defaults={"cost": 100, "card_count": 5, "description": "sa test pack type"},
|
|
||||||
)
|
|
||||||
return Pack.create(team=team, pack_type=pt)
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_card(player: Player, team: Team) -> Card:
|
|
||||||
pack = _sa_make_pack(team)
|
|
||||||
return Card.create(player=player, team=team, pack=pack, value=0)
|
|
||||||
|
|
||||||
|
|
||||||
def _sa_make_state(player, team, track, current_tier=0, current_value=0.0):
|
|
||||||
return RefractorCardState.create(
|
|
||||||
player=player,
|
|
||||||
team=team,
|
|
||||||
track=track,
|
|
||||||
current_tier=current_tier,
|
|
||||||
current_value=current_value,
|
|
||||||
fully_evolved=False,
|
|
||||||
last_evaluated_at=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T2-4: GET /teams/{valid_team_id}/refractors — team exists, zero states
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_team_refractors_zero_states(setup_state_api_db, state_api_client):
|
|
||||||
"""GET /teams/{id}/refractors for a team with no RefractorCardState rows.
|
|
||||||
|
|
||||||
What: Create a Team with no associated RefractorCardState rows.
|
|
||||||
Call the endpoint and verify the response is {"count": 0, "items": []}.
|
|
||||||
|
|
||||||
Why: The endpoint uses a JOIN from RefractorCardState to RefractorTrack
|
|
||||||
filtered by team_id. If the WHERE produces no rows, the correct response
|
|
||||||
is an empty list with count=0, not a 404 or 500. This is the base-case
|
|
||||||
for a newly-created team that hasn't opened any packs yet.
|
|
||||||
"""
|
|
||||||
team = _sa_make_team("SA4", gmid=30041)
|
|
||||||
|
|
||||||
resp = state_api_client.get(
|
|
||||||
f"/api/v2/teams/{team.id}/refractors", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["count"] == 0
|
|
||||||
assert data["items"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T2-5: GET /teams/99999/refractors — non-existent team
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_team_refractors_nonexistent_team(setup_state_api_db, state_api_client):
|
|
||||||
"""GET /teams/99999/refractors where team_id 99999 does not exist.
|
|
||||||
|
|
||||||
What: Call the endpoint with a team_id that has no Team row and no
|
|
||||||
RefractorCardState rows.
|
|
||||||
|
|
||||||
Why: Documents the confirmed behaviour: 200 with {"count": 0, "items": []}.
|
|
||||||
The endpoint queries RefractorCardState WHERE team_id=99999. Because no
|
|
||||||
state rows reference that team, the result is an empty list. The endpoint
|
|
||||||
does NOT validate that the Team row itself exists, so it does not return 404.
|
|
||||||
|
|
||||||
If the implementation is ever changed to validate team existence and return
|
|
||||||
404 for missing teams, this test will fail and surface the contract change.
|
|
||||||
"""
|
|
||||||
resp = state_api_client.get("/api/v2/teams/99999/refractors", headers=AUTH_HEADER)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
# No state rows reference team 99999 — empty list with count=0
|
|
||||||
assert data["count"] == 0
|
|
||||||
assert data["items"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T2-6: POST /refractor/cards/{card_id}/evaluate — zero season stats → T0
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_evaluate_card_zero_stats_stays_t0(setup_state_api_db, state_api_client):
|
|
||||||
"""POST /cards/{card_id}/evaluate for a card with no season stats stays at T0.
|
|
||||||
|
|
||||||
What: Create a Player, Team, Card, and RefractorCardState. Do NOT create
|
|
||||||
any BattingSeasonStats rows for this player+team. Call the evaluate
|
|
||||||
endpoint. The response must show current_tier=0 and current_value=0.0.
|
|
||||||
|
|
||||||
Why: A player who has never appeared in a game has zero career stats.
|
|
||||||
The evaluator sums all stats rows (none) -> all-zero totals ->
|
|
||||||
compute_batter_value(zeros) = 0.0 -> tier_from_value(0.0) = T0.
|
|
||||||
Verifies the happy-path zero-stats case returns a valid response rather
|
|
||||||
than crashing on an empty aggregation.
|
|
||||||
"""
|
|
||||||
team = _sa_make_team("SA6", gmid=30061)
|
|
||||||
player = _sa_make_player("SA6 Batter", pos="1B")
|
|
||||||
track = _sa_make_track("batter")
|
|
||||||
card = _sa_make_card(player, team)
|
|
||||||
_sa_make_state(player, team, track, current_tier=0, current_value=0.0)
|
|
||||||
|
|
||||||
# No BattingSeasonStats rows — intentionally empty
|
|
||||||
|
|
||||||
resp = state_api_client.post(
|
|
||||||
f"/api/v2/refractor/cards/{card.id}/evaluate", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["current_tier"] == 0
|
|
||||||
assert data["current_value"] == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T3-6: GET /refractor/cards/{card_id} — last_evaluated_at is None
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_card_state_last_evaluated_at_null(setup_state_api_db, state_api_client):
|
|
||||||
"""GET /refractor/cards/{card_id} returns last_evaluated_at: null for un-evaluated card.
|
|
||||||
|
|
||||||
What: Create a Player, Team, Card, and RefractorCardState where
|
|
||||||
last_evaluated_at is explicitly None (the state was initialised via a
|
|
||||||
pack-open hook but has never been through the evaluator). Call
|
|
||||||
GET /api/v2/refractor/cards/{card_id} and verify:
|
|
||||||
- The response status is 200 (not a 500 crash from calling .isoformat() on None).
|
|
||||||
- The response body contains the key 'last_evaluated_at'.
|
|
||||||
- The value of 'last_evaluated_at' is JSON null (Python None after parsing).
|
|
||||||
|
|
||||||
Why: The _build_card_state_response helper serialises last_evaluated_at
|
|
||||||
with `state.last_evaluated_at.isoformat() if state.last_evaluated_at else None`.
|
|
||||||
This test confirms that the None branch is exercised and the field is always
|
|
||||||
present in the response envelope. Callers must be able to distinguish
|
|
||||||
"never evaluated" (null) from a real ISO-8601 timestamp, and the API must
|
|
||||||
not crash on a newly-created card that has not yet been evaluated.
|
|
||||||
"""
|
|
||||||
team = _sa_make_team("SA_T36", gmid=30360)
|
|
||||||
player = _sa_make_player("T36 Batter", pos="1B")
|
|
||||||
track = _sa_make_track("batter")
|
|
||||||
card = _sa_make_card(player, team)
|
|
||||||
|
|
||||||
# Create state with last_evaluated_at=None — simulates a freshly initialised
|
|
||||||
# card that has not yet been through the evaluator
|
|
||||||
RefractorCardState.create(
|
|
||||||
player=player,
|
|
||||||
team=team,
|
|
||||||
track=track,
|
|
||||||
current_tier=0,
|
|
||||||
current_value=0.0,
|
|
||||||
fully_evolved=False,
|
|
||||||
last_evaluated_at=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
resp = state_api_client.get(
|
|
||||||
f"/api/v2/refractor/cards/{card.id}", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
# 'last_evaluated_at' must be present as a key even when the value is null
|
|
||||||
assert "last_evaluated_at" in data, (
|
|
||||||
"Response is missing the 'last_evaluated_at' key"
|
|
||||||
)
|
|
||||||
assert data["last_evaluated_at"] is None, (
|
|
||||||
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
|
|
||||||
|
|||||||
@ -11,45 +11,12 @@ Tests auto-skip when POSTGRES_HOST is not set.
|
|||||||
Test data is inserted via psycopg2 before the test module runs and deleted
|
Test data is inserted via psycopg2 before the test module runs and deleted
|
||||||
afterwards so the tests are repeatable. ON CONFLICT keeps the table clean
|
afterwards so the tests are repeatable. ON CONFLICT keeps the table clean
|
||||||
even if a previous run did not complete teardown.
|
even if a previous run did not complete teardown.
|
||||||
|
|
||||||
Tier 3 tests (T3-1) in this file use a SQLite-backed TestClient so they run
|
|
||||||
without a PostgreSQL connection. They test the card_type filter edge cases:
|
|
||||||
an unrecognised card_type string and an empty string should both return an
|
|
||||||
empty list (200 with count=0) rather than an error.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from peewee import SqliteDatabase
|
|
||||||
|
|
||||||
os.environ.setdefault("API_TOKEN", "test-token")
|
|
||||||
|
|
||||||
from app.db_engine import ( # noqa: E402
|
|
||||||
BattingSeasonStats,
|
|
||||||
Card,
|
|
||||||
Cardset,
|
|
||||||
Decision,
|
|
||||||
Event,
|
|
||||||
MlbPlayer,
|
|
||||||
Pack,
|
|
||||||
PackType,
|
|
||||||
PitchingSeasonStats,
|
|
||||||
Player,
|
|
||||||
ProcessedGame,
|
|
||||||
Rarity,
|
|
||||||
RefractorCardState,
|
|
||||||
RefractorTrack,
|
|
||||||
Roster,
|
|
||||||
RosterSlot,
|
|
||||||
ScoutClaim,
|
|
||||||
ScoutOpportunity,
|
|
||||||
StratGame,
|
|
||||||
StratPlay,
|
|
||||||
Team,
|
|
||||||
)
|
|
||||||
|
|
||||||
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
POSTGRES_HOST = os.environ.get("POSTGRES_HOST")
|
||||||
_skip_no_pg = pytest.mark.skipif(
|
_skip_no_pg = pytest.mark.skipif(
|
||||||
@ -163,170 +130,3 @@ def test_auth_required(client, seeded_tracks):
|
|||||||
track_id = seeded_tracks[0]
|
track_id = seeded_tracks[0]
|
||||||
resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}")
|
resp_single = client.get(f"/api/v2/refractor/tracks/{track_id}")
|
||||||
assert resp_single.status_code == 401
|
assert resp_single.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
|
||||||
# SQLite-backed tests for T3-1: invalid card_type query parameter
|
|
||||||
#
|
|
||||||
# These tests run without a PostgreSQL connection. They verify that the
|
|
||||||
# card_type filter on GET /api/v2/refractor/tracks handles values that match
|
|
||||||
# no known track (an unrecognised string, an empty string) gracefully: the
|
|
||||||
# endpoint must return 200 with {"count": 0, "items": []}, not a 4xx/5xx.
|
|
||||||
# ===========================================================================
|
|
||||||
|
|
||||||
_track_api_db = SqliteDatabase(
|
|
||||||
"file:trackapitest?mode=memory&cache=shared",
|
|
||||||
uri=True,
|
|
||||||
pragmas={"foreign_keys": 1},
|
|
||||||
)
|
|
||||||
|
|
||||||
_TRACK_API_MODELS = [
|
|
||||||
Rarity,
|
|
||||||
Event,
|
|
||||||
Cardset,
|
|
||||||
MlbPlayer,
|
|
||||||
Player,
|
|
||||||
Team,
|
|
||||||
PackType,
|
|
||||||
Pack,
|
|
||||||
Card,
|
|
||||||
Roster,
|
|
||||||
RosterSlot,
|
|
||||||
StratGame,
|
|
||||||
StratPlay,
|
|
||||||
Decision,
|
|
||||||
ScoutOpportunity,
|
|
||||||
ScoutClaim,
|
|
||||||
BattingSeasonStats,
|
|
||||||
PitchingSeasonStats,
|
|
||||||
ProcessedGame,
|
|
||||||
RefractorTrack,
|
|
||||||
RefractorCardState,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=False)
|
|
||||||
def setup_track_api_db():
|
|
||||||
"""Bind track-API test models to shared-memory SQLite and create tables.
|
|
||||||
|
|
||||||
Inserts exactly two tracks (batter, sp) so the filter tests have a
|
|
||||||
non-empty table to query against — confirming that the WHERE predicate
|
|
||||||
excludes them rather than the table simply being empty.
|
|
||||||
"""
|
|
||||||
_track_api_db.bind(_TRACK_API_MODELS)
|
|
||||||
_track_api_db.connect(reuse_if_open=True)
|
|
||||||
_track_api_db.create_tables(_TRACK_API_MODELS)
|
|
||||||
|
|
||||||
# Seed two real tracks so the table is not empty
|
|
||||||
RefractorTrack.get_or_create(
|
|
||||||
name="T3-1 Batter Track",
|
|
||||||
defaults=dict(
|
|
||||||
card_type="batter",
|
|
||||||
formula="pa + tb * 2",
|
|
||||||
t1_threshold=37,
|
|
||||||
t2_threshold=149,
|
|
||||||
t3_threshold=448,
|
|
||||||
t4_threshold=896,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
RefractorTrack.get_or_create(
|
|
||||||
name="T3-1 SP Track",
|
|
||||||
defaults=dict(
|
|
||||||
card_type="sp",
|
|
||||||
formula="ip + k",
|
|
||||||
t1_threshold=10,
|
|
||||||
t2_threshold=40,
|
|
||||||
t3_threshold=120,
|
|
||||||
t4_threshold=240,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield _track_api_db
|
|
||||||
_track_api_db.drop_tables(list(reversed(_TRACK_API_MODELS)), safe=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_track_api_app() -> FastAPI:
|
|
||||||
"""Minimal FastAPI app containing only the refractor router for T3-1 tests."""
|
|
||||||
from app.routers_v2.refractor import router as refractor_router
|
|
||||||
|
|
||||||
app = FastAPI()
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def db_middleware(request: Request, call_next):
|
|
||||||
_track_api_db.connect(reuse_if_open=True)
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
app.include_router(refractor_router)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def track_api_client(setup_track_api_db):
|
|
||||||
"""FastAPI TestClient for the SQLite-backed T3-1 track filter tests."""
|
|
||||||
with TestClient(_build_track_api_app()) as c:
|
|
||||||
yield c
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T3-1a: card_type=foo (unrecognised value) returns empty list
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_card_type_returns_empty_list(setup_track_api_db, track_api_client):
|
|
||||||
"""GET /tracks?card_type=foo returns 200 with count=0, not a 4xx/5xx.
|
|
||||||
|
|
||||||
What: Query the track list with a card_type value ('foo') that matches
|
|
||||||
no row in refractor_track. The table contains batter and sp tracks so
|
|
||||||
the result must be an empty list rather than a full list (which would
|
|
||||||
indicate the filter was ignored).
|
|
||||||
|
|
||||||
Why: The endpoint applies `WHERE card_type == card_type` when the
|
|
||||||
parameter is not None. An unrecognised value is a valid no-match query
|
|
||||||
— the contract is an empty list, not a validation error. Returning
|
|
||||||
a 422 Unprocessable Entity or 500 here would break clients that probe
|
|
||||||
for tracks by card type before knowing which types are registered.
|
|
||||||
"""
|
|
||||||
resp = track_api_client.get(
|
|
||||||
"/api/v2/refractor/tracks?card_type=foo", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["count"] == 0, (
|
|
||||||
f"Expected count=0 for unknown card_type 'foo', got {data['count']}"
|
|
||||||
)
|
|
||||||
assert data["items"] == [], (
|
|
||||||
f"Expected empty items list for unknown card_type 'foo', got {data['items']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T3-1b: card_type= (empty string) returns empty list
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_empty_string_card_type_returns_empty_list(
|
|
||||||
setup_track_api_db, track_api_client
|
|
||||||
):
|
|
||||||
"""GET /tracks?card_type= (empty string) returns 200 with count=0.
|
|
||||||
|
|
||||||
What: Pass an empty string as the card_type query parameter. No track
|
|
||||||
has card_type='' so the response must be an empty list with count=0.
|
|
||||||
|
|
||||||
Why: An empty string is not None — FastAPI will pass it through as ''
|
|
||||||
rather than treating it as an absent parameter. The WHERE predicate
|
|
||||||
`card_type == ''` produces no matches, which is the correct silent
|
|
||||||
no-results behaviour. This guards against regressions where an empty
|
|
||||||
string might be mishandled as a None/absent value and accidentally return
|
|
||||||
all tracks, or raise a server error.
|
|
||||||
"""
|
|
||||||
resp = track_api_client.get(
|
|
||||||
"/api/v2/refractor/tracks?card_type=", headers=AUTH_HEADER
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["count"] == 0, (
|
|
||||||
f"Expected count=0 for empty card_type string, got {data['count']}"
|
|
||||||
)
|
|
||||||
assert data["items"] == [], (
|
|
||||||
f"Expected empty items list for empty card_type string, got {data['items']}"
|
|
||||||
)
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user