Merge pull request 'feat: refractor tier-specific card art rendering' (#179) from feature/refractor-card-art into main
This commit is contained in:
commit
3e84a06b23
2
.gitignore
vendored
2
.gitignore
vendored
@ -59,6 +59,8 @@ pyenv.cfg
|
||||
pyvenv.cfg
|
||||
docker-compose.override.yml
|
||||
docker-compose.*.yml
|
||||
.run-local.pid
|
||||
.env.local
|
||||
*.db
|
||||
venv
|
||||
.claude/
|
||||
|
||||
@ -32,6 +32,7 @@ from ..db_engine import (
|
||||
)
|
||||
from ..db_helpers import upsert_players
|
||||
from ..dependencies import oauth2_scheme, valid_token
|
||||
from ..services.refractor_boost import compute_variant_hash
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistent browser instance (WP-02)
|
||||
@ -132,6 +133,19 @@ def normalize_franchise(franchise: str) -> str:
|
||||
return FRANCHISE_NORMALIZE.get(titled, titled)
|
||||
|
||||
|
||||
def resolve_refractor_tier(player_id: int, variant: int) -> int:
|
||||
"""Determine the refractor tier (0-4) from a player's variant hash.
|
||||
|
||||
Pure math — no DB query needed. Returns 0 for base cards or unknown variants.
|
||||
"""
|
||||
if variant == 0:
|
||||
return 0
|
||||
for tier in range(1, 5):
|
||||
if compute_variant_hash(player_id, tier) == variant:
|
||||
return tier
|
||||
return 0
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v2/players", tags=["players"])
|
||||
|
||||
|
||||
@ -723,6 +737,9 @@ async def get_batter_card(
|
||||
variant: int = 0,
|
||||
d: str = None,
|
||||
html: Optional[bool] = False,
|
||||
tier: Optional[int] = Query(
|
||||
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
|
||||
),
|
||||
):
|
||||
try:
|
||||
this_player = Player.get_by_id(player_id)
|
||||
@ -786,6 +803,9 @@ async def get_batter_card(
|
||||
card_data["cardset_name"] = this_player.cardset.name
|
||||
else:
|
||||
card_data["cardset_name"] = this_player.description
|
||||
card_data["refractor_tier"] = (
|
||||
tier if tier is not None else resolve_refractor_tier(player_id, variant)
|
||||
)
|
||||
card_data["request"] = request
|
||||
html_response = templates.TemplateResponse("player_card.html", card_data)
|
||||
|
||||
@ -823,6 +843,9 @@ async def get_batter_card(
|
||||
card_data["cardset_name"] = this_player.cardset.name
|
||||
else:
|
||||
card_data["cardset_name"] = this_player.description
|
||||
card_data["refractor_tier"] = (
|
||||
tier if tier is not None else resolve_refractor_tier(player_id, variant)
|
||||
)
|
||||
card_data["request"] = request
|
||||
html_response = templates.TemplateResponse("player_card.html", card_data)
|
||||
|
||||
|
||||
132
run-local.sh
Executable file
132
run-local.sh
Executable file
@ -0,0 +1,132 @@
|
||||
#!/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()
|
||||
b = p.chromium.launch(args=['--no-sandbox'])
|
||||
b.close()
|
||||
p.stop()
|
||||
" 2>/dev/null || {
|
||||
echo "Playwright Chromium not installed. Run: playwright install chromium"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check dev DB is reachable
|
||||
python -c "
|
||||
import socket
|
||||
s = socket.create_connection(('10.10.0.42', 5432), timeout=3)
|
||||
s.close()
|
||||
" 2>/dev/null || {
|
||||
echo "Cannot reach dev PostgreSQL at 10.10.0.42: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="${POSTGRES_HOST_LOCAL:-10.10.0.42}"
|
||||
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,9 +2,26 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'style.html' %}
|
||||
{% include 'tier_style.html' %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="fullCard" style="width: 1200px; height: 600px;">
|
||||
{%- set diamond_colors = {
|
||||
1: {'color': '#1a6b1a', 'highlight': '#40b040'},
|
||||
2: {'color': '#2070b0', 'highlight': '#50a0e8'},
|
||||
3: {'color': '#a82020', 'highlight': '#e85050'},
|
||||
4: {'color': '#6b2d8e', 'highlight': '#a060d0'},
|
||||
} -%}
|
||||
{% if refractor_tier is defined and refractor_tier > 0 %}
|
||||
{%- 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="headerLeft" style="flex-grow: 3; height: auto">-->
|
||||
<div id="headerLeft" style="width: 477px; height: auto">
|
||||
|
||||
227
storage/templates/tier_style.html
Normal file
227
storage/templates/tier_style.html
Normal file
@ -0,0 +1,227 @@
|
||||
{% if refractor_tier is defined and refractor_tier > 0 %}
|
||||
<style>
|
||||
/* Shared rules for all refractor tiers */
|
||||
#fullCard {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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-bot {
|
||||
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>
|
||||
{% else %}
|
||||
<style>
|
||||
/* T0 Standard — no tier overrides */
|
||||
#fullCard {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
@ -19,6 +19,7 @@ from app.services.refractor_boost import (
|
||||
PITCHER_PRIORITY,
|
||||
PITCHER_XCHECK_COLUMNS,
|
||||
)
|
||||
from app.routers_v2.players import resolve_refractor_tier
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -911,6 +912,34 @@ class TestVariantHash:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier resolution (from variant hash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveTier:
|
||||
"""Verify resolve_refractor_tier correctly reverse-maps variant hashes to
|
||||
tier numbers using compute_variant_hash as the source of truth.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("tier", [1, 2, 3, 4])
|
||||
def test_known_tier_roundtrips(self, tier):
|
||||
"""resolve_refractor_tier returns the correct tier for a variant hash
|
||||
produced by compute_variant_hash.
|
||||
"""
|
||||
player_id = 42
|
||||
variant = compute_variant_hash(player_id, tier)
|
||||
assert resolve_refractor_tier(player_id, variant) == tier
|
||||
|
||||
def test_base_card_returns_zero(self):
|
||||
"""variant=0 (base card) always returns tier 0."""
|
||||
assert resolve_refractor_tier(999, 0) == 0
|
||||
|
||||
def test_unknown_variant_returns_zero(self):
|
||||
"""An unrecognized variant hash falls back to tier 0."""
|
||||
assert resolve_refractor_tier(1, 99999999) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batter display stats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user