Compare commits

..

No commits in common. "main" and "next-release" have entirely different histories.

28 changed files with 450 additions and 5541 deletions

View File

@ -1,14 +1,8 @@
import asyncio import asyncio
import sys
from pathlib import Path
import aiohttp import aiohttp
import pandas as pd import pandas as pd
# Add project root so we can import db_calls AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"}
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from db_calls import AUTH_TOKEN
PROD_URL = "https://pd.manticorum.com/api" PROD_URL = "https://pd.manticorum.com/api"

View File

@ -1,2 +0,0 @@
# Paper Dynasty API
PD_API_TOKEN=your-bearer-token-here

View File

@ -118,9 +118,6 @@ pd-cards scouting all && pd-cards scouting upload
pd-cards upload s3 --cardset "2005 Live" --dry-run pd-cards upload s3 --cardset "2005 Live" --dry-run
pd-cards upload s3 --cardset "2005 Live" --limit 10 pd-cards upload s3 --cardset "2005 Live" --limit 10
# High-concurrency local rendering (start API server locally first)
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
# Check cards without uploading # Check cards without uploading
pd-cards upload check --cardset "2005 Live" --limit 10 pd-cards upload check --cardset "2005 Live" --limit 10
@ -266,7 +263,6 @@ Before running retrosheet_data.py, verify these configuration settings:
- `UPDATE_PLAYER_URLS`: Enable/disable updating player records with S3 URLs (careful - modifies database) - `UPDATE_PLAYER_URLS`: Enable/disable updating player records with S3 URLs (careful - modifies database)
- `AWS_BUCKET_NAME`: S3 bucket name (default: 'paper-dynasty') - `AWS_BUCKET_NAME`: S3 bucket name (default: 'paper-dynasty')
- `AWS_REGION`: AWS region (default: 'us-east-1') - `AWS_REGION`: AWS region (default: 'us-east-1')
- `PD_API_URL` (env var): Override the API base URL for card rendering (default: `https://pd.manticorum.com/api`). Set to `http://localhost:8000/api` for local rendering.
**S3 URL Structure**: `cards/cardset-{cardset_id:03d}/player-{player_id}/{batting|pitching}card.png?d={release_date}` **S3 URL Structure**: `cards/cardset-{cardset_id:03d}/player-{player_id}/{batting|pitching}card.png?d={release_date}`
- Uses zero-padded 3-digit cardset ID for consistent sorting - Uses zero-padded 3-digit cardset ID for consistent sorting

View File

@ -573,7 +573,7 @@ def stealing_line(steal_data: dict):
else: else:
good_jump = "2-12" good_jump = "2-12"
return f"{'*' if sd[2] else ''}{good_jump}/- ({sd[1] if sd[1] else '-'}-{sd[0] if sd[0] else '-'})" return f'{"*" if sd[2] else ""}{good_jump}/- ({sd[1] if sd[1] else "-"}-{sd[0] if sd[0] else "-"})'
def running(extra_base_pct: str): def running(extra_base_pct: str):
@ -583,7 +583,7 @@ def running(extra_base_pct: str):
xb_pct = float(extra_base_pct.strip("%")) / 80 xb_pct = float(extra_base_pct.strip("%")) / 80
except Exception as e: except Exception as e:
logger.error(f"calcs_batter running - {e}") logger.error(f"calcs_batter running - {e}")
return 8 xb_pct = 20
return max(min(round(6 + (10 * xb_pct)), 17), 8) return max(min(round(6 + (10 * xb_pct)), 17), 8)
@ -693,11 +693,11 @@ def get_batter_ratings(df_data) -> List[dict]:
logger.debug( logger.debug(
f"all on base: {vl.hbp + vl.walk + vl.total_hits()} / all chances: {vl.total_chances()}" f"all on base: {vl.hbp + vl.walk + vl.total_hits()} / all chances: {vl.total_chances()}"
f"{'*******ERROR ABOVE*******' if vl.hbp + vl.walk + vl.total_hits() != vl.total_chances() else ''}" f'{"*******ERROR ABOVE*******" if vl.hbp + vl.walk + vl.total_hits() != vl.total_chances() else ""}'
) )
logger.debug( logger.debug(
f"all on base: {vr.hbp + vr.walk + vr.total_hits()} / all chances: {vr.total_chances()}" f"all on base: {vr.hbp + vr.walk + vr.total_hits()} / all chances: {vr.total_chances()}"
f"{'*******ERROR ABOVE*******' if vr.hbp + vr.walk + vr.total_hits() != vr.total_chances() else ''}" f'{"*******ERROR ABOVE*******" if vr.hbp + vr.walk + vr.total_hits() != vr.total_chances() else ""}'
) )
vl.calculate_strikeouts(df_data["SO_vL"], df_data["AB_vL"], df_data["H_vL"]) vl.calculate_strikeouts(df_data["SO_vL"], df_data["AB_vL"], df_data["H_vL"])

View File

@ -3,7 +3,7 @@ import urllib.parse
import pandas as pd import pandas as pd
import numpy as np import numpy as np
from typing import Any, Dict from typing import Dict
from creation_helpers import ( from creation_helpers import (
get_all_pybaseball_ids, get_all_pybaseball_ids,
sanitize_name, sanitize_name,
@ -158,8 +158,8 @@ async def create_new_players(
{ {
"p_name": f"{f_name} {l_name}", "p_name": f"{f_name} {l_name}",
"cost": NEW_PLAYER_COST, "cost": NEW_PLAYER_COST,
"image": f"{card_base_url}/{df_data['player_id']}/battingcard" "image": f'{card_base_url}/{df_data["player_id"]}/battingcard'
f"{urllib.parse.quote('?d=')}{release_dir}", f'{urllib.parse.quote("?d=")}{release_dir}',
"mlbclub": CLUB_LIST[df_data["Tm_vL"]], "mlbclub": CLUB_LIST[df_data["Tm_vL"]],
"franchise": FRANCHISE_LIST[df_data["Tm_vL"]], "franchise": FRANCHISE_LIST[df_data["Tm_vL"]],
"cardset_id": cardset["id"], "cardset_id": cardset["id"],
@ -302,7 +302,7 @@ async def calculate_batting_ratings(offense_stats: pd.DataFrame, to_post: bool):
async def post_player_updates( async def post_player_updates(
cardset: Dict[str, Any], cardset: Dict[str, any],
card_base_url: str, card_base_url: str,
release_dir: str, release_dir: str,
player_desc: str, player_desc: str,
@ -432,8 +432,8 @@ async def post_player_updates(
[ [
( (
"image", "image",
f"{card_base_url}/{df_data['player_id']}/battingcard" f'{card_base_url}/{df_data["player_id"]}/battingcard'
f"{urllib.parse.quote('?d=')}{release_dir}", f'{urllib.parse.quote("?d=")}{release_dir}',
) )
] ]
) )

View File

@ -1,7 +1,5 @@
import asyncio import asyncio
import datetime import datetime
import functools
import os
import sys import sys
import boto3 import boto3
@ -16,9 +14,6 @@ HTML_CARDS = False # boolean to only check and not generate cards
SKIP_ARMS = False SKIP_ARMS = False
SKIP_BATS = False SKIP_BATS = False
# Concurrency
CONCURRENCY = 8 # number of parallel card-processing tasks
# AWS Configuration # AWS Configuration
AWS_BUCKET_NAME = "paper-dynasty" # Change to your bucket name AWS_BUCKET_NAME = "paper-dynasty" # Change to your bucket name
AWS_REGION = "us-east-1" # Change to your region AWS_REGION = "us-east-1" # Change to your region
@ -28,11 +23,11 @@ UPLOAD_TO_S3 = (
) )
UPDATE_PLAYER_URLS = True # Set to False to skip player URL updates (testing) - STEP 6: Update player URLs UPDATE_PLAYER_URLS = True # Set to False to skip player URL updates (testing) - STEP 6: Update player URLs
# Initialize S3 client (module-level; boto3 client is thread-safe for concurrent reads) # Initialize S3 client
s3_client = boto3.client("s3", region_name=AWS_REGION) if UPLOAD_TO_S3 else None s3_client = boto3.client("s3", region_name=AWS_REGION) if UPLOAD_TO_S3 else None
async def fetch_card_image(session, card_url: str, timeout: int = 10) -> bytes: async def fetch_card_image(session, card_url: str, timeout: int = 6) -> bytes:
""" """
Fetch card image from URL and return raw bytes. Fetch card image from URL and return raw bytes.
@ -136,220 +131,168 @@ async def main(args):
timestamp = int(now.timestamp()) timestamp = int(now.timestamp())
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}" release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
# PD API base URL for card generation (override with PD_API_URL env var for local rendering) # PD API base URL for card generation
PD_API_URL = os.environ.get("PD_API_URL", "https://pd.manticorum.com/api") PD_API_URL = "https://pd.manticorum.com/api"
print(f"\nRelease date for cards: {release_date}")
print(f"API URL: {PD_API_URL}")
print(f"S3 Upload: {'ENABLED' if UPLOAD_TO_S3 else 'DISABLED'}")
print(f"URL Update: {'ENABLED' if UPDATE_PLAYER_URLS else 'DISABLED'}")
print(f"Concurrency: {CONCURRENCY} parallel tasks\n")
# Build filtered list respecting SKIP_ARMS, SKIP_BATS, START_ID, TEST_COUNT
max_count = TEST_COUNT if TEST_COUNT is not None else 9999
filtered_players = []
for x in all_players:
if len(filtered_players) >= max_count:
break
if "pitching" in x["image"] and SKIP_ARMS:
continue
if "batting" in x["image"] and SKIP_BATS:
continue
if START_ID is not None and START_ID > x["player_id"]:
continue
filtered_players.append(x)
total = len(filtered_players)
logger.info(f"Processing {total} cards with concurrency={CONCURRENCY}")
# Shared mutable state protected by locks
errors = [] errors = []
successes = [] successes = []
uploads = [] uploads = []
url_updates = [] url_updates = []
completed = 0 cxn_error = False
progress_lock = asyncio.Lock() count = -1
results_lock = asyncio.Lock()
start_time = datetime.datetime.now() start_time = datetime.datetime.now()
loop = asyncio.get_running_loop()
semaphore = asyncio.Semaphore(CONCURRENCY)
async def report_progress(): print(f"\nRelease date for cards: {release_date}")
"""Increment the completed counter and log/print every 20 completions.""" print(f"S3 Upload: {'ENABLED' if UPLOAD_TO_S3 else 'DISABLED'}")
nonlocal completed print(f"URL Update: {'ENABLED' if UPDATE_PLAYER_URLS else 'DISABLED'}\n")
async with progress_lock:
completed += 1
if completed % 20 == 0 or completed == total:
print(f"Progress: {completed}/{total} cards processed")
logger.info(f"Progress: {completed}/{total} cards processed")
async def process_single_card(x: dict) -> None: # Create persistent aiohttp session for all card fetches
""" async with aiohttp.ClientSession() as session:
Process one player entry under the semaphore: fetch card image(s), upload for x in all_players:
to S3 (offloading the synchronous boto3 call to a thread pool), and if "pitching" in x["image"] and SKIP_ARMS:
optionally patch the player record with the new S3 URL. pass
elif "batting" in x["image"] and SKIP_BATS:
Both the primary card (image) and the secondary card for two-way players pass
(image2) are handled. Failures are appended to the shared errors list elif START_ID is not None and START_ID > x["player_id"]:
rather than re-raised so the overall batch continues. pass
""" elif "sombaseball" in x["image"]:
async with semaphore: errors.append((x, f"Bad card url: {x['image']}"))
player_id = x["player_id"]
# --- primary card ---
if "sombaseball" in x["image"]:
async with results_lock:
errors.append((x, f"Bad card url: {x['image']}"))
await report_progress()
return
card_type = "pitching" if "pitching" in x["image"] else "batting"
pd_card_url = (
f"{PD_API_URL}/v2/players/{player_id}/{card_type}card?d={release_date}"
)
if HTML_CARDS:
card_url = f"{pd_card_url}&html=true"
timeout = 2
else: else:
card_url = pd_card_url count += 1
timeout = 10 if count % 20 == 0:
print(f"Card #{count + 1} being pulled is {x['p_name']}...")
elif TEST_COUNT is not None and TEST_COUNT < count:
print("Done test run")
break
primary_ok = False # Determine card type from existing image URL
try: card_type = "pitching" if "pitching" in x["image"] else "batting"
if UPLOAD_TO_S3 and not HTML_CARDS:
image_bytes = await fetch_card_image( # Generate card URL from PD API (forces fresh generation from database)
session, card_url, timeout=timeout pd_card_url = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type}card?d={release_date}"
)
# boto3 is synchronous — offload to thread pool so the event if HTML_CARDS:
# loop is not blocked during the S3 PUT card_url = f"{pd_card_url}&html=true"
s3_url = await loop.run_in_executor( timeout = 2
None, else:
functools.partial( card_url = pd_card_url
upload_card_to_s3, timeout = 6
try:
# Upload to S3 if enabled
if UPLOAD_TO_S3 and not HTML_CARDS:
# Fetch card image bytes directly
image_bytes = await fetch_card_image(
session, card_url, timeout=timeout
)
s3_url = upload_card_to_s3(
image_bytes, image_bytes,
player_id, x["player_id"],
card_type, card_type,
release_date, release_date,
cardset["id"], cardset["id"],
),
)
async with results_lock:
uploads.append((player_id, card_type, s3_url))
if UPDATE_PLAYER_URLS:
await db_patch(
"players",
object_id=player_id,
params=[("image", s3_url)],
) )
async with results_lock: uploads.append((x["player_id"], card_type, s3_url))
url_updates.append((player_id, card_type, s3_url))
logger.info(f"Updated player {player_id} image URL to S3")
else:
# Just validate card exists (old behavior)
logger.info("calling the card url")
await url_get(card_url, timeout=timeout)
primary_ok = True
except ConnectionError as e:
logger.error(f"Connection error for player {player_id}: {e}")
async with results_lock:
errors.append((x, e))
except ValueError as e:
async with results_lock:
errors.append((x, e))
except Exception as e:
logger.error(f"S3 upload/update failed for player {player_id}: {e}")
async with results_lock:
errors.append((x, f"S3 error: {e}"))
if not primary_ok:
await report_progress()
return
# --- secondary card (two-way players) ---
if x["image2"] is not None:
if "sombaseball" in x["image2"]:
async with results_lock:
errors.append((x, f"Bad card url: {x['image2']}"))
await report_progress()
return
card_type2 = "pitching" if "pitching" in x["image2"] else "batting"
pd_card_url2 = f"{PD_API_URL}/v2/players/{player_id}/{card_type2}card?d={release_date}"
if HTML_CARDS:
card_url2 = f"{pd_card_url2}&html=true"
else:
card_url2 = pd_card_url2
try:
if UPLOAD_TO_S3 and not HTML_CARDS:
image_bytes2 = await fetch_card_image(
session, card_url2, timeout=10
)
s3_url2 = await loop.run_in_executor(
None,
functools.partial(
upload_card_to_s3,
image_bytes2,
player_id,
card_type2,
release_date,
cardset["id"],
),
)
async with results_lock:
uploads.append((player_id, card_type2, s3_url2))
# Update player record with new S3 URL
if UPDATE_PLAYER_URLS: if UPDATE_PLAYER_URLS:
await db_patch( await db_patch(
"players", "players",
object_id=x["player_id"], object_id=x["player_id"],
params=[("image2", s3_url2)], params=[("image", s3_url)],
)
url_updates.append((x["player_id"], card_type, s3_url))
logger.info(
f"Updated player {x['player_id']} image URL to S3"
) )
async with results_lock:
url_updates.append((player_id, card_type2, s3_url2))
logger.info(f"Updated player {player_id} image2 URL to S3")
else: else:
# Just validate card exists (old behavior) # Just validate card exists (old behavior)
await url_get(card_url2, timeout=10) logger.info("calling the card url")
resp = await url_get(card_url, timeout=timeout)
async with results_lock:
successes.append(x)
except ConnectionError as e: except ConnectionError as e:
logger.error(f"Connection error for player {player_id} image2: {e}") if cxn_error:
async with results_lock: raise e
errors.append((x, e)) cxn_error = True
errors.append((x, e))
except ValueError as e: except ValueError as e:
async with results_lock: errors.append((x, e))
errors.append((x, e))
except Exception as e: except Exception as e:
logger.error( logger.error(
f"S3 upload/update failed for player {player_id} image2: {e}" f"S3 upload/update failed for player {x['player_id']}: {e}"
) )
async with results_lock: errors.append((x, f"S3 error: {e}"))
errors.append((x, f"S3 error (image2): {e}")) continue
else: # Handle image2 (dual-position players)
async with results_lock: if x["image2"] is not None:
# Determine second card type
card_type2 = "pitching" if "pitching" in x["image2"] else "batting"
# Generate card URL from PD API (forces fresh generation from database)
pd_card_url2 = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type2}card?d={release_date}"
if HTML_CARDS:
card_url2 = f"{pd_card_url2}&html=true"
else:
card_url2 = pd_card_url2
if "sombaseball" in x["image2"]:
errors.append((x, f"Bad card url: {x['image2']}"))
else:
try:
if UPLOAD_TO_S3 and not HTML_CARDS:
# Fetch second card image bytes directly from PD API
image_bytes2 = await fetch_card_image(
session, card_url2, timeout=6
)
s3_url2 = upload_card_to_s3(
image_bytes2,
x["player_id"],
card_type2,
release_date,
cardset["id"],
)
uploads.append((x["player_id"], card_type2, s3_url2))
# Update player record with new S3 URL for image2
if UPDATE_PLAYER_URLS:
await db_patch(
"players",
object_id=x["player_id"],
params=[("image2", s3_url2)],
)
url_updates.append(
(x["player_id"], card_type2, s3_url2)
)
logger.info(
f"Updated player {x['player_id']} image2 URL to S3"
)
else:
# Just validate card exists (old behavior)
resp = await url_get(card_url2, timeout=6)
successes.append(x)
except ConnectionError as e:
if cxn_error:
raise e
cxn_error = True
errors.append((x, e))
except ValueError as e:
errors.append((x, e))
except Exception as e:
logger.error(
f"S3 upload/update failed for player {x['player_id']} image2: {e}"
)
errors.append((x, f"S3 error (image2): {e}"))
else:
successes.append(x) successes.append(x)
await report_progress()
# Create persistent aiohttp session shared across all concurrent tasks
async with aiohttp.ClientSession() as session:
tasks = [process_single_card(x) for x in filtered_players]
await asyncio.gather(*tasks, return_exceptions=True)
# Print summary # Print summary
print(f"\n{'=' * 60}") print(f"\n{'=' * 60}")
print("SUMMARY") print("SUMMARY")

View File

@ -10,7 +10,7 @@ import requests
import time import time
from db_calls import db_get from db_calls import db_get
from db_calls_card_creation import PitcherData from db_calls_card_creation import *
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
# Card Creation Constants # Card Creation Constants
@ -533,7 +533,7 @@ def get_pitching_peripherals(season: int):
row_data.append(player_id) row_data.append(player_id)
if len(headers) == 0: if len(headers) == 0:
col_names.append("key_bbref") col_names.append("key_bbref")
except KeyError: except Exception:
pass pass
row_data.append(cell.text) row_data.append(cell.text)
if len(headers) == 0: if len(headers) == 0:
@ -595,21 +595,21 @@ def legal_splits(tot_chances):
def result_string(tba_data, row_num, split_min=None, split_max=None): def result_string(tba_data, row_num, split_min=None, split_max=None):
bold1 = f"{'<b>' if tba_data['bold'] else ''}" bold1 = f'{"<b>" if tba_data["bold"] else ""}'
bold2 = f"{'</b>' if tba_data['bold'] else ''}" bold2 = f'{"</b>" if tba_data["bold"] else ""}'
row_string = f"{'<b> </b>' if int(row_num) < 10 else ''}{row_num}" row_string = f'{"<b> </b>" if int(row_num) < 10 else ""}{row_num}'
if TESTING: if TESTING:
print( print(
f"adding {tba_data['string']} to row {row_num} / " f'adding {tba_data["string"]} to row {row_num} / '
f"split_min: {split_min} / split_max: {split_max}" f"split_min: {split_min} / split_max: {split_max}"
) )
# No splits; standard result # No splits; standard result
if not split_min: if not split_min:
return f"{bold1}{row_string}-{tba_data['string']}{bold2}" return f'{bold1}{row_string}-{tba_data["string"]}{bold2}'
# With splits # With splits
split_nums = f"{split_min if split_min != 20 else ''}{'-' if split_min != 20 else ''}{split_max}" split_nums = f'{split_min if split_min != 20 else ""}{"-" if split_min != 20 else ""}{split_max}'
data_string = ( data_string = (
tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"] tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"]
) )
@ -618,10 +618,10 @@ def result_string(tba_data, row_num, split_min=None, split_max=None):
spaces -= 3 spaces -= 3
elif "SI**" in data_string: elif "SI**" in data_string:
spaces += 1 spaces += 1
elif "DO**" in data_string:
spaces -= 2
elif "DO*" in data_string: elif "DO*" in data_string:
spaces -= 1 spaces -= 1
elif "DO*" in data_string:
spaces -= 2
elif "so" in data_string: elif "so" in data_string:
spaces += 3 spaces += 3
elif "gb" in data_string: elif "gb" in data_string:
@ -638,39 +638,41 @@ def result_string(tba_data, row_num, split_min=None, split_max=None):
row_output = "<b> </b>" row_output = "<b> </b>"
if TESTING: if TESTING:
print(f"row_output: {row_output}") print(f"row_output: {row_output}")
return f"{bold1}{row_output}{data_string}{' ' * spaces}{split_nums}{bold2}" return f'{bold1}{row_output}{data_string}{" " * spaces}{split_nums}{bold2}'
def result_data( def result_data(
tba_data, row_num, tba_data_bottom=None, top_split_max=None, fatigue=False tba_data, row_num, tba_data_bottom=None, top_split_max=None, fatigue=False
): ):
ret_data = {} ret_data = {}
top_bold1 = f"{'<b>' if tba_data['bold'] else ''}" top_bold1 = f'{"<b>" if tba_data["bold"] else ""}'
top_bold2 = f"{'</b>' if tba_data['bold'] else ''}" top_bold2 = f'{"</b>" if tba_data["bold"] else ""}'
bot_bold1 = None bot_bold1 = None
bot_bold2 = None bot_bold2 = None
if tba_data_bottom: if tba_data_bottom:
bot_bold1 = f"{'<b>' if tba_data_bottom['bold'] else ''}" bot_bold1 = f'{"<b>" if tba_data_bottom["bold"] else ""}'
bot_bold2 = f"{'</b>' if tba_data_bottom['bold'] else ''}" bot_bold2 = f'{"</b>" if tba_data_bottom["bold"] else ""}'
if tba_data_bottom is None: if tba_data_bottom is None:
ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}" ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}"
ret_data["splits"] = f"{top_bold1}{top_bold2}" ret_data["splits"] = f"{top_bold1}{top_bold2}"
ret_data["result"] = ( ret_data["result"] = (
f"{top_bold1}{tba_data['string']}{'' if fatigue else ''}{top_bold2}" f"{top_bold1}"
f'{tba_data["string"]}{"" if fatigue else ""}'
f"{top_bold2}"
) )
else: else:
ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}\n" ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}\n"
ret_data["splits"] = ( ret_data["splits"] = (
f"{top_bold1}1{'-' if top_split_max != 1 else ''}" f'{top_bold1}1{"-" if top_split_max != 1 else ""}'
f"{top_split_max if top_split_max != 1 else ''}{top_bold2}\n" f'{top_split_max if top_split_max != 1 else ""}{top_bold2}\n'
f"{bot_bold1}{top_split_max + 1}{'-20' if top_split_max != 19 else ''}{bot_bold2}" f'{bot_bold1}{top_split_max+1}{"-20" if top_split_max != 19 else ""}{bot_bold2}'
) )
ret_data["result"] = ( ret_data["result"] = (
f"{top_bold1}{tba_data['sm-string'] if 'sm-string' in tba_data.keys() else tba_data['string']}" f'{top_bold1}{tba_data["sm-string"] if "sm-string" in tba_data.keys() else tba_data["string"]}'
f"{top_bold2}\n" f"{top_bold2}\n"
f"{bot_bold1}" f"{bot_bold1}"
f"{tba_data_bottom['sm-string'] if 'sm-string' in tba_data_bottom.keys() else tba_data_bottom['string']}" f'{tba_data_bottom["sm-string"] if "sm-string" in tba_data_bottom.keys() else tba_data_bottom["string"]}'
f"{bot_bold2}" f"{bot_bold2}"
) )
@ -686,9 +688,9 @@ def get_of(batter_hand, pitcher_hand, pull_side=True):
if batter_hand == "S": if batter_hand == "S":
if pitcher_hand == "L": if pitcher_hand == "L":
return "lf" if pull_side else "rf" return "rf" if pull_side else "rf"
else: else:
return "rf" if pull_side else "lf" return "lf" if pull_side else "lf"
def get_col(col_num): def get_col(col_num):
@ -727,7 +729,7 @@ def get_position_string(all_pos: list, inc_p: bool):
for x in all_pos: for x in all_pos:
if x.position == "OF": if x.position == "OF":
of_arm = f"{'+' if '-' not in x.arm else ''}{x.arm}" of_arm = f'{"+" if "-" not in x.arm else ""}{x.arm}'
of_error = x.error of_error = x.error
of_innings = x.innings of_innings = x.innings
elif x.position == "CF": elif x.position == "CF":
@ -742,7 +744,7 @@ def get_position_string(all_pos: list, inc_p: bool):
elif x.position == "C": elif x.position == "C":
all_def.append( all_def.append(
( (
f"c-{x.range}({'+' if int(x.arm) >= 0 else ''}{x.arm}) e{x.error} T-{x.overthrow}(pb-{x.pb})", f'c-{x.range}({"+" if int(x.arm) >= 0 else ""}{x.arm}) e{x.error} T-{x.overthrow}(pb-{x.pb})',
x.innings, x.innings,
) )
) )
@ -1077,7 +1079,7 @@ def mlbteam_and_franchise(mlbam_playerid):
p_data["franchise"] = normalize_franchise(data["currentTeam"]["name"]) p_data["franchise"] = normalize_franchise(data["currentTeam"]["name"])
else: else:
logger.error( logger.error(
f"Could not set team for {mlbam_playerid}; received {data['currentTeam']['name']}" f'Could not set team for {mlbam_playerid}; received {data["currentTeam"]["name"]}'
) )
else: else:
logger.error( logger.error(
@ -1220,5 +1222,5 @@ def get_hand(df_data):
else: else:
return "R" return "R"
except Exception: except Exception:
logger.error(f"Error in get_hand for {df_data['Name']}") logger.error(f'Error in get_hand for {df_data["Name"]}')
return "R" return "R"

View File

@ -6,7 +6,6 @@ baseball archetypes with iterative review and refinement.
""" """
import asyncio import asyncio
import copy
import sys import sys
from typing import Literal from typing import Literal
from datetime import datetime from datetime import datetime
@ -180,12 +179,7 @@ class CustomCardCreator:
else: else:
calc = PitcherRatingCalculator(archetype) calc = PitcherRatingCalculator(archetype)
ratings = calc.calculate_ratings(pitchingcard_id=0) # Temp ID ratings = calc.calculate_ratings(pitchingcard_id=0) # Temp ID
card_data = { card_data = {"ratings": ratings}
"ratings": ratings,
"starter_rating": archetype.starter_rating,
"relief_rating": archetype.relief_rating,
"closer_rating": archetype.closer_rating,
}
# Step 4: Review and tweak loop # Step 4: Review and tweak loop
final_data = await self.review_and_tweak( final_data = await self.review_and_tweak(
@ -353,7 +347,7 @@ class CustomCardCreator:
vs_hand = rating["vs_hand"] vs_hand = rating["vs_hand"]
print(f"\nVS {vs_hand}{'HP' if player_type == 'batter' else 'HB'}:") print(f"\nVS {vs_hand}{'HP' if player_type == 'batter' else 'HB'}:")
print( print(
f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {rating['obp'] + rating['slg']:.3f}" f" AVG: {rating['avg']:.3f} OBP: {rating['obp']:.3f} SLG: {rating['slg']:.3f} OPS: {rating['obp']+rating['slg']:.3f}"
) )
# Show hit distribution # Show hit distribution
@ -370,7 +364,7 @@ class CustomCardCreator:
+ rating["bp_single"] + rating["bp_single"]
) )
print( print(
f" Hits: {total_hits:.1f} (HR: {rating['homerun']:.1f} 3B: {rating['triple']:.1f} 2B: {rating['double_pull'] + rating['double_two'] + rating['double_three']:.1f} 1B: {total_hits - rating['homerun'] - rating['bp_homerun'] - rating['triple'] - rating['double_pull'] - rating['double_two'] - rating['double_three']:.1f})" f" Hits: {total_hits:.1f} (HR: {rating['homerun']:.1f} 3B: {rating['triple']:.1f} 2B: {rating['double_pull']+rating['double_two']+rating['double_three']:.1f} 1B: {total_hits - rating['homerun'] - rating['bp_homerun'] - rating['triple'] - rating['double_pull'] - rating['double_two'] - rating['double_three']:.1f})"
) )
# Show walk/strikeout # Show walk/strikeout
@ -395,7 +389,7 @@ class CustomCardCreator:
) )
) )
print( print(
f" Outs: {outs:.1f} (K: {rating['strikeout']:.1f} LD: {rating['lineout']:.1f} FB: {rating['flyout_a'] + rating['flyout_bq'] + rating['flyout_lf_b'] + rating['flyout_rf_b']:.1f} GB: {rating['groundout_a'] + rating['groundout_b'] + rating['groundout_c']:.1f})" f" Outs: {outs:.1f} (K: {rating['strikeout']:.1f} LD: {rating['lineout']:.1f} FB: {rating['flyout_a']+rating['flyout_bq']+rating['flyout_lf_b']+rating['flyout_rf_b']:.1f} GB: {rating['groundout_a']+rating['groundout_b']+rating['groundout_c']:.1f})"
) )
# Calculate and display total OPS # Calculate and display total OPS
@ -426,68 +420,10 @@ class CustomCardCreator:
print("-" * 70) print("-" * 70)
print("\nAdjust key percentages (press Enter to keep current value):\n") print("\nAdjust key percentages (press Enter to keep current value):\n")
def prompt_float(label: str, current: float) -> float: # TODO: Implement percentage tweaking
val = input(f" {label} [{current:.3f}]: ").strip() # For now, return unchanged
if not val: print("(Feature coming soon - manual adjustments available in option 3)")
return current return card_data
try:
return float(val)
except ValueError:
print(" Invalid value, keeping current.")
return current
def prompt_int(label: str, current: int) -> int:
val = input(f" {label} [{current}]: ").strip()
if not val:
return current
try:
return int(val)
except ValueError:
print(" Invalid value, keeping current.")
return current
arch = copy.copy(archetype)
print("--- vs RHP/RHB ---")
arch.avg_vs_r = prompt_float("AVG vs R", arch.avg_vs_r)
arch.obp_vs_r = prompt_float("OBP vs R", arch.obp_vs_r)
arch.slg_vs_r = prompt_float("SLG vs R", arch.slg_vs_r)
arch.bb_pct_vs_r = prompt_float("BB% vs R", arch.bb_pct_vs_r)
arch.k_pct_vs_r = prompt_float("K% vs R", arch.k_pct_vs_r)
print("\n--- vs LHP/LHB ---")
arch.avg_vs_l = prompt_float("AVG vs L", arch.avg_vs_l)
arch.obp_vs_l = prompt_float("OBP vs L", arch.obp_vs_l)
arch.slg_vs_l = prompt_float("SLG vs L", arch.slg_vs_l)
arch.bb_pct_vs_l = prompt_float("BB% vs L", arch.bb_pct_vs_l)
arch.k_pct_vs_l = prompt_float("K% vs L", arch.k_pct_vs_l)
print("\n--- Power Profile ---")
arch.hr_per_hit = prompt_float("HR/Hit", arch.hr_per_hit)
arch.triple_per_hit = prompt_float("3B/Hit", arch.triple_per_hit)
arch.double_per_hit = prompt_float("2B/Hit", arch.double_per_hit)
print("\n--- Batted Ball Profile ---")
arch.gb_pct = prompt_float("GB%", arch.gb_pct)
arch.fb_pct = prompt_float("FB%", arch.fb_pct)
arch.ld_pct = prompt_float("LD%", arch.ld_pct)
if player_type == "batter":
print("\n--- Baserunning ---")
arch.speed_rating = prompt_int("Speed (1-10)", arch.speed_rating) # type: ignore[arg-type]
arch.steal_jump = prompt_int("Jump (1-10)", arch.steal_jump) # type: ignore[arg-type]
arch.xbt_pct = prompt_float("XBT%", arch.xbt_pct) # type: ignore[union-attr]
# Recalculate card ratings with the modified archetype
if player_type == "batter":
calc = BatterRatingCalculator(arch) # type: ignore[arg-type]
ratings = calc.calculate_ratings(battingcard_id=0)
baserunning = calc.calculate_baserunning()
return {"ratings": ratings, "baserunning": baserunning}
else:
calc_p = PitcherRatingCalculator(arch) # type: ignore[arg-type]
ratings = calc_p.calculate_ratings(pitchingcard_id=0)
return {"ratings": ratings}
async def manual_adjustments( async def manual_adjustments(
self, player_type: Literal["batter", "pitcher"], card_data: dict self, player_type: Literal["batter", "pitcher"], card_data: dict
@ -498,99 +434,10 @@ class CustomCardCreator:
print("-" * 70) print("-" * 70)
print("\nDirectly edit D20 chances (must sum to 108):\n") print("\nDirectly edit D20 chances (must sum to 108):\n")
D20_FIELDS = [ # TODO: Implement manual adjustments
"homerun", # For now, return unchanged
"bp_homerun", print("(Feature coming soon)")
"triple", return card_data
"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",
]
# Choose which split to edit
print("Which split to edit?")
for i, rating in enumerate(card_data["ratings"]):
vs = rating["vs_hand"]
print(f" {i + 1}. vs {vs}{'HP' if player_type == 'batter' else 'HB'}")
while True:
choice = input("\nSelect split (1-2): ").strip()
try:
idx = int(choice) - 1
if 0 <= idx < len(card_data["ratings"]):
break
else:
print("Invalid choice.")
except ValueError:
print("Invalid input.")
result = copy.deepcopy(card_data)
rating = result["ratings"][idx]
while True:
vs = rating["vs_hand"]
print(
f"\n--- VS {vs}{'HP' if player_type == 'batter' else 'HB'} D20 Chances ---"
)
total = 0.0
for i, field in enumerate(D20_FIELDS, 1):
val = rating[field]
print(f" {i:2d}. {field:<20s}: {val:.2f}")
total += val
print(f"\n Total: {total:.2f} (target: 108.00)")
user_input = input(
"\nEnter field number and new value (e.g. '1 3.5'), or 'done': "
).strip()
if user_input.lower() in ("done", "q", ""):
break
parts = user_input.split()
if len(parts) != 2:
print(" Enter a field number and a value separated by a space.")
continue
try:
field_idx = int(parts[0]) - 1
new_val = float(parts[1])
except ValueError:
print(" Invalid input.")
continue
if not (0 <= field_idx < len(D20_FIELDS)):
print(f" Field number must be between 1 and {len(D20_FIELDS)}.")
continue
if new_val < 0:
print(" Value cannot be negative.")
continue
rating[D20_FIELDS[field_idx]] = new_val
total = sum(rating[f] for f in D20_FIELDS)
if abs(total - 108.0) > 0.01:
print(
f"\nWarning: Total is {total:.2f} (expected 108.00). "
"Ratings saved but card probabilities may be incorrect."
)
return result
async def create_database_records( async def create_database_records(
self, self,
@ -733,9 +580,9 @@ class CustomCardCreator:
"name_first": player_info["name_first"], "name_first": player_info["name_first"],
"name_last": player_info["name_last"], "name_last": player_info["name_last"],
"hand": player_info["hand"], "hand": player_info["hand"],
"starter_rating": card_data["starter_rating"], "starter_rating": 5, # TODO: Get from archetype
"relief_rating": card_data["relief_rating"], "relief_rating": 5, # TODO: Get from archetype
"closer_rating": card_data["closer_rating"], "closer_rating": None, # TODO: Get from archetype
} }
] ]
} }

View File

@ -1,18 +1,10 @@
import os
import aiohttp import aiohttp
import pybaseball as pb import pybaseball as pb
from dotenv import load_dotenv
from typing import Literal, Optional from typing import Literal
from exceptions import logger from exceptions import logger
load_dotenv() AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"}
_token = os.environ.get("PD_API_TOKEN")
if not _token:
raise EnvironmentError("PD_API_TOKEN environment variable is required")
AUTH_TOKEN = {"Authorization": f"Bearer {_token}"}
DB_URL = "https://pd.manticorum.com/api" DB_URL = "https://pd.manticorum.com/api"
master_debug = True master_debug = True
alt_database = None alt_database = None
@ -33,7 +25,7 @@ def param_char(other_params):
def get_req_url( def get_req_url(
endpoint: str, api_ver: int = 2, object_id: int = None, params: list = None endpoint: str, api_ver: int = 2, object_id: int = None, params: list = None
): ):
req_url = f"{DB_URL}/v{api_ver}/{endpoint}{'/' if object_id is not None else ''}{object_id if object_id is not None else ''}" req_url = f'{DB_URL}/v{api_ver}/{endpoint}{"/" if object_id is not None else ""}{object_id if object_id is not None else ""}'
if params: if params:
other_params = False other_params = False
@ -47,11 +39,11 @@ def get_req_url(
def log_return_value(log_string: str): def log_return_value(log_string: str):
if master_debug: if master_debug:
logger.info( logger.info(
f"return: {log_string[:1200]}{' [ S N I P P E D ]' if len(log_string) > 1200 else ''}\n" f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
) )
else: else:
logger.debug( logger.debug(
f"return: {log_string[:1200]}{' [ S N I P P E D ]' if len(log_string) > 1200 else ''}\n" f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
) )
@ -61,15 +53,13 @@ async def db_get(
object_id: int = None, object_id: int = None,
params: list = None, params: list = None,
none_okay: bool = True, none_okay: bool = True,
timeout: int = 30, timeout: int = 3,
) -> Optional[dict]: ):
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params) req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
log_string = f"get:\n{endpoint} id: {object_id} params: {params}" log_string = f"get:\n{endpoint} id: {object_id} params: {params}"
logger.info(log_string) if master_debug else logger.debug(log_string) logger.info(log_string) if master_debug else logger.debug(log_string)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
) as session:
async with session.get(req_url) as r: async with session.get(req_url) as r:
logger.info(f"session info: {r}") logger.info(f"session info: {r}")
if r.status == 200: if r.status == 200:
@ -86,13 +76,11 @@ async def db_get(
raise ValueError(f"DB: {e}") raise ValueError(f"DB: {e}")
async def url_get(url: str, timeout: int = 30) -> dict: async def url_get(url: str, timeout: int = 3):
log_string = f"get:\n{url}" log_string = f"get:\n{url}"
logger.info(log_string) if master_debug else logger.debug(log_string) logger.info(log_string) if master_debug else logger.debug(log_string)
async with aiohttp.ClientSession( async with aiohttp.ClientSession() as session:
timeout=aiohttp.ClientTimeout(total=timeout)
) as session:
async with session.get(url) as r: async with session.get(url) as r:
if r.status == 200: if r.status == 200:
log_string = "200 received" log_string = "200 received"
@ -105,15 +93,13 @@ async def url_get(url: str, timeout: int = 30) -> dict:
async def db_patch( async def db_patch(
endpoint: str, object_id: int, params: list, api_ver: int = 2, timeout: int = 30 endpoint: str, object_id: int, params: list, api_ver: int = 2, timeout: int = 3
) -> dict: ):
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params) req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
log_string = f"patch:\n{endpoint} {params}" log_string = f"patch:\n{endpoint} {params}"
logger.info(log_string) if master_debug else logger.debug(log_string) logger.info(log_string) if master_debug else logger.debug(log_string)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
) as session:
async with session.patch(req_url) as r: async with session.patch(req_url) as r:
if r.status == 200: if r.status == 200:
js = await r.json() js = await r.json()
@ -126,15 +112,13 @@ async def db_patch(
async def db_post( async def db_post(
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 30 endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3
) -> dict: ):
req_url = get_req_url(endpoint, api_ver=api_ver) req_url = get_req_url(endpoint, api_ver=api_ver)
log_string = f"post:\n{endpoint} payload: {payload}\ntype: {type(payload)}" log_string = f"post:\n{endpoint} payload: {payload}\ntype: {type(payload)}"
logger.info(log_string) if master_debug else logger.debug(log_string) logger.info(log_string) if master_debug else logger.debug(log_string)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
) as session:
async with session.post(req_url, json=payload) as r: async with session.post(req_url, json=payload) as r:
if r.status == 200: if r.status == 200:
js = await r.json() js = await r.json()
@ -147,15 +131,13 @@ async def db_post(
async def db_put( async def db_put(
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 30 endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3
) -> dict: ):
req_url = get_req_url(endpoint, api_ver=api_ver) req_url = get_req_url(endpoint, api_ver=api_ver)
log_string = f"put:\n{endpoint} payload: {payload}\ntype: {type(payload)}" log_string = f"put:\n{endpoint} payload: {payload}\ntype: {type(payload)}"
logger.info(log_string) if master_debug else logger.debug(log_string) logger.info(log_string) if master_debug else logger.debug(log_string)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
) as session:
async with session.put(req_url, json=payload) as r: async with session.put(req_url, json=payload) as r:
if r.status == 200: if r.status == 200:
js = await r.json() js = await r.json()
@ -167,14 +149,12 @@ async def db_put(
raise ValueError(f"DB: {e}") raise ValueError(f"DB: {e}")
async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3) -> dict: async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3):
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id) req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id)
log_string = f"delete:\n{endpoint} {object_id}" log_string = f"delete:\n{endpoint} {object_id}"
logger.info(log_string) if master_debug else logger.debug(log_string) logger.info(log_string) if master_debug else logger.debug(log_string)
async with aiohttp.ClientSession( async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
) as session:
async with session.delete(req_url) as r: async with session.delete(req_url) as r:
if r.status == 200: if r.status == 200:
js = await r.json() js = await r.json()
@ -203,4 +183,4 @@ def get_player_data(
def player_desc(this_player) -> str: def player_desc(this_player) -> str:
if this_player["p_name"] in this_player["description"]: if this_player["p_name"] in this_player["description"]:
return this_player["description"] return this_player["description"]
return f"{this_player['description']} {this_player['p_name']}" return f'{this_player["description"]} {this_player["p_name"]}'

View File

@ -404,35 +404,17 @@ pd-cards upload s3 --cardset <name> [OPTIONS]
| `--upload/--no-upload` | | `True` | Upload to S3 | | `--upload/--no-upload` | | `True` | Upload to S3 |
| `--update-urls/--no-update-urls` | | `True` | Update player URLs in database | | `--update-urls/--no-update-urls` | | `True` | Update player URLs in database |
| `--dry-run` | `-n` | `False` | Preview without uploading | | `--dry-run` | `-n` | `False` | Preview without uploading |
| `--concurrency` | `-j` | `8` | Number of parallel uploads |
| `--api-url` | | `https://pd.manticorum.com/api` | API base URL for card rendering |
**Prerequisites:** AWS CLI configured with credentials (`~/.aws/credentials`) **Prerequisites:** AWS CLI configured with credentials (`~/.aws/credentials`)
**S3 URL Structure:** `cards/cardset-{id:03d}/player-{player_id}/{batting|pitching}card.png?d={date}` **S3 URL Structure:** `cards/cardset-{id:03d}/player-{player_id}/{batting|pitching}card.png?d={date}`
**Local Rendering:** For high-concurrency local rendering, start the Paper Dynasty API server locally and point uploads at it:
```bash
# Terminal 1: Start local API server (from database repo)
cd /mnt/NV2/Development/paper-dynasty/database
DATABASE_TYPE=postgresql POSTGRES_HOST=10.10.0.42 POSTGRES_DB=paperdynasty_dev \
POSTGRES_USER=sba_admin POSTGRES_PASSWORD=<pw> POSTGRES_PORT=5432 \
API_TOKEN=your-api-token-here \
uvicorn app.main:app --host 0.0.0.0 --port 8000
# Terminal 2: Upload with local rendering
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
```
**Examples:** **Examples:**
```bash ```bash
pd-cards upload s3 --cardset "2005 Live" --dry-run pd-cards upload s3 --cardset "2005 Live" --dry-run
pd-cards upload s3 --cardset "2005 Live" --limit 10 pd-cards upload s3 --cardset "2005 Live" --limit 10
pd-cards upload s3 --cardset "2005 Live" --start-id 5000 pd-cards upload s3 --cardset "2005 Live" --start-id 5000
pd-cards upload s3 --cardset "2005 Live" --skip-pitchers pd-cards upload s3 --cardset "2005 Live" --skip-pitchers
pd-cards upload s3 --cardset "2005 Live" --concurrency 16
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
``` ```
--- ---

View File

@ -1,475 +0,0 @@
# Refractor Phase 2 — Design Validation Spec
## Purpose
This document captures the design validation test cases that must be verified before and during
Phase 2 (rating boosts) of the Refractor card progression system. Phase 1 — tracking,
milestone evaluation, and tier state persistence — is implemented. Phase 2 adds the rating boost
application logic (`apply_evolution_boosts`), rarity upgrade at T4, and variant hash creation.
**When to reference this document:**
- Before beginning Phase 2 implementation: review all cases to understand the design constraints
and edge cases the implementation must handle.
- During implementation: use each test case as an acceptance gate before the corresponding
feature is considered complete.
- During code review: each case documents the "risk if failed" so reviewers can assess whether
a proposed implementation correctly handles that scenario.
- After Phase 2 ships: run the cases as a regression checklist before any future change to the
boost logic, rarity assignment, or milestone evaluator.
## Background: Rating Model
Batter cards have 22 outcome columns summing to exactly 108 chances (derived from the D20
probability system: 2d6 x 3 columns x 6 rows). Each Refractor tier (T1 through T4) awards a
1.0-chance budget — a flat shift from out columns to positive-outcome columns. The total
accumulated budget across all four tiers is 4.0 chances, equal to approximately 3.7% of the
108-chance total (4 / 108 ≈ 0.037).
**Rarity naming cross-reference:** The PRD chapters (`prd-evolution/`) use the player-facing
display names. The codebase and this spec use the internal names from `rarity_thresholds.py`.
They map as follows:
| PRD / Display Name | Codebase Name | ID |
|---|---|---|
| Replacement | Common | 5 |
| Reserve | Bronze | 4 |
| Starter | Silver | 3 |
| All-Star | Gold | 2 |
| MVP | Diamond | 1 |
| Hall of Fame | HoF | 99 |
All rarity references in this spec use the codebase names.
Rarity IDs in the codebase (from `rarity_thresholds.py`):
| Rarity Name | ID |
|---|---|
| Common | 5 |
| Bronze | 4 |
| Silver | 3 |
| Gold | 2 |
| Diamond | 1 |
| Hall of Fame | 99 |
The special value `99` for Hall of Fame means a naive `rarity_id + 1` increment is incorrect;
the upgrade logic must use an ordered rarity ladder, not arithmetic.
---
## Test Cases
---
### T4-1: 108-sum preservation under batter and pitcher boosts
**Status:** Shipped — Phase 2 complete
> **Updated 2026-04-08:** Profile-based boost distribution was not implemented. The shipped
> implementation uses `apply_batter_boost()` (fixed column deltas) and `apply_pitcher_boost()`
> (TB-budget priority algorithm) in `database/app/services/refractor_boost.py`. There is no
> `apply_evolution_boosts(card_ratings, boost_tier, player_profile)` function and no
> `pd_cards/evo/boost_profiles.py` module. See `docs/prd-evolution/05-rating-boosts.md`
> section 5.3 for the shipped algorithm details.
**Scenario:**
`apply_batter_boost(ratings_dict)` applies fixed deltas (+0.50 to `homerun`, `double_pull`,
`single_one`, `walk`; -1.50 from `strikeout`, -0.50 from `groundout_a`) per tier. The 22-column
sum must equal exactly 108 after every application.
`apply_pitcher_boost(ratings_dict, tb_budget=1.5)` drains a 1.5 TB-unit budget by converting
hit-allowed chances into strikeouts in priority order. The 18 variable outcome columns must sum
to their pre-boost total (the conversion is chance-for-chance; only column identity changes,
not the total).
The edge case: a batter card where `strikeout = 0` and `groundout_a = 0`. The negative funding
columns are both at zero, so no reduction can occur. The shipped implementation handles this by
scaling the positive deltas to zero (`scale = 0`), leaving all columns unchanged. The 108-sum
is preserved exactly. A warning is logged.
Verify:
- After each of T1, T2, T3, T4 boost applications, `sum(all batter outcome columns) == 108`.
- After each pitcher boost, `sum(pitcher outcome columns) + sum(xcheck columns) == 108`.
- A batter card with `strikeout = 0` and `groundout_a = 0` does not raise an error, does not
produce any column below 0, and leaves the sum at exactly 108.
- No column value falls below 0 under any input.
**Expected Outcome:**
Sum remains 108 after every boost under non-truncation conditions. Under truncation conditions
(funding columns already at or near zero), the positive deltas are scaled proportionally to the
amount actually reduced — the 108-sum is preserved exactly (not approximately). The original
spec's statement that "truncated points are lost, not redistributed" does not reflect the
shipped behavior: positive deltas ARE scaled down to match what was taken, ensuring the sum
invariant holds in all cases. No column value falls below 0.
**Risk If Failed:**
A broken 108-sum produces invalid game probabilities. The D20 engine derives per-outcome
probabilities from `column / 108`. If the sum drifts above or below 108, every outcome
probability on that card is subtly wrong for every future game that uses it. This error silently
corrupts game results without any visible failure.
**Files Involved:**
- `docs/prd-evolution/05-rating-boosts.md` — section 5.3 (shipped algorithm), section 5.1 (cap behavior)
- `database/app/services/refractor_boost.py``apply_batter_boost`, `apply_pitcher_boost` (shipped)
- `database/tests/test_refractor_boost.py` — existing test coverage for these functions
---
### T4-2: D20 probability shift at T4
**Status:** Pending — Phase 2
**Scenario:**
Take a representative Bronze-rarity batter (e.g., a player with total OPS near 0.730,
`homerun` ≈ 1.2, `single_one` ≈ 4.0, `walk` ≈ 3.0 in the base ratings). Apply all four
tier boosts cumulatively, distributing the total 4.0-chance budget across positive-outcome
columns (HR, singles, walk) with equal reductions from out columns. Calculate the resulting
absolute and relative probability change per D20 roll outcome.
Design target: the full T4 evolution shifts approximately 3.7% of all outcomes from outs to
positive results (4.0 / 108 = 0.037). The shift should be perceptible to a player reviewing
their card stats but should not fundamentally alter the card's tier or role. A Bronze batter
does not become a Gold batter through evolution — they become an evolved Bronze batter.
Worked example for validation reference:
- Pre-evolution: `homerun = 1.2` → probability per D20 = 1.2 / 108 ≈ 1.11%
- Post T4 with +0.5 to homerun per tier (4 tiers × 0.5 = +2.0 total): `homerun = 3.2`
→ probability per D20 = 3.2 / 108 ≈ 2.96% — an increase of ~1.85 percentage points
- Across all positive outcomes: total shift = 4.0 / 108 ≈ 3.7%
**Expected Outcome:**
The cumulative 4.0-chance shift produces a ~3.7% total movement from negative to positive
outcomes. No single outcome column increases by more than 2.5 chances across the full T4
journey under any profile. The card remains recognizably Bronze — it does not cross the Gold
OPS threshold (0.900 for 2024/2025 thresholds; confirmed in `rarity_thresholds.py`
`BATTER_THRESHOLDS_2024.gold` and `BATTER_THRESHOLDS_2025.gold`) unless it was already near
the boundary. Note: 0.700 is the Bronze floor (`bronze` field), not the Gold threshold.
**Risk If Failed:**
If the shift is too large, evolution becomes a rarity bypass — players grind low-rarity cards
to simulate an upgrade they cannot earn through pack pulls. If the shift is too small, the
system feels unrewarding and players lose motivation to complete tiers. Either miscalibration
undermines the core design intent.
**Files Involved:**
- `docs/prd-evolution/05-rating-boosts.md` — section 5.2 (boost budgets), section 5.3 (shipped algorithm)
- `rarity_thresholds.py` — OPS boundary values used to assess whether evolution crosses a rarity
threshold as a side effect (it should not for mid-range cards)
- `database/app/services/refractor_boost.py``apply_batter_boost`, `apply_pitcher_boost` (shipped)
---
### T4-3: T4 rarity upgrade — pipeline collision risk
**Status:** Pending — Phase 2
**Scenario:**
The Refractor T4 rarity upgrade (`player.rarity_id` incremented by one ladder step) and the
live-series `post_player_updates()` rarity assignment (OPS-threshold-based, in
`batters/creation.py`) both write to the same `rarity_id` field on the player record. A
collision occurs when both run against the same player:
1. Player completes Refractor T4. Evolution system upgrades rarity: Bronze (4) → Silver (3).
`evolution_card_state.final_rarity_id = 3` is written as an audit record.
2. Live-series update runs two weeks later. `post_player_updates()` recalculates OPS → maps to
Bronze (4) → writes `rarity_id = 4` to the player record.
3. The T4 rarity upgrade is silently overwritten. The player's card reverts to Bronze. The
`evolution_card_state` record still shows `final_rarity_id = 3` but the live card is Bronze.
This is a conflict between two independent systems both writing to the same field without
awareness of each other. The current live-series pipeline has no concept of evolution state.
Proposed resolution strategies (document and evaluate; do not implement during Phase 2 spec):
- **Guard clause in `post_player_updates()`:** Before writing `rarity_id`, check
`evolution_card_state.final_rarity_id` for the player. If an evolution upgrade is on record,
apply `max(ops_rarity, final_rarity_id_ladder_position)` — never downgrade past the T4 result.
- **Separate evolution rarity field:** Add `evolution_rarity_bump` (int, default 0) to the
card model. The game engine resolves effective rarity as `base_rarity + bump`. Live-series
updates only touch `base_rarity`; the bump is immutable once T4 is reached.
- **Deferred rarity upgrade:** T4 does not write `rarity_id` immediately. Instead, it sets a
flag on `evolution_card_state`. `post_player_updates()` checks the flag and applies the bump
after its own rarity calculation, ensuring the evolution upgrade layers on top of the current
OPS-derived rarity rather than competing with it.
**Expected Outcome:**
Phase 2 must implement one of these strategies (or an alternative that provides equivalent
protection). The collision scenario must be explicitly tested: evolve a Bronze card to T4,
run a live-series update that maps the same player to Bronze, confirm the displayed rarity is
Silver or higher — not Bronze.
**Risk If Failed:**
Live-series updates silently revert T4 rarity upgrades. Players invest significant game time
reaching T4, receive the visual rarity upgrade, then lose it after the next live-series run
with no explanation. This is one of the highest-trust violations the system can produce — a
reward that disappears invisibly.
**Files Involved:**
- `batters/creation.py``post_player_updates()` (lines ~304480)
- `pitchers/creation.py` — equivalent `post_player_updates()` for pitchers
- `docs/prd-evolution/05-rating-boosts.md` — section 5.4 (rarity upgrade at T4), note on live
series interaction
- Phase 2: `pd_cards/evo/tier_completion.py` (to be created) — T4 completion handler
- Database: `evolution_card_state` table, `final_rarity_id` column
---
### T4-4: T4 rarity cap for HoF cards
**Status:** Pending — Phase 2
**Scenario:**
A player card currently at Hall of Fame rarity (`rarity_id = 99`) completes Refractor T4. The
design specifies: HoF cards receive the T4 rating boost deltas (1.0 chance shift) but do not
receive a rarity upgrade. The rarity stays at 99.
The implementation must handle this without producing an invalid rarity value. The rarity ID
sequence in `rarity_thresholds.py` is non-contiguous — the IDs are:
```
5 (Common) → 4 (Bronze) → 3 (Silver) → 2 (Gold) → 1 (Diamond) → 99 (Hall of Fame)
```
A naive `rarity_id + 1` would produce `100`, which is not a valid rarity. A lookup-table
approach on the ordered ladder must be used instead. At `99` (HoF), the ladder returns `99`
(no-op). Additionally, Diamond (1) cards that complete T4 should upgrade to HoF (99), not to
`rarity_id = 0` or any other invalid value.
**Expected Outcome:**
- `rarity_id = 99` (HoF): T4 boost applied, rarity unchanged at 99.
- `rarity_id = 1` (Diamond): T4 boost applied, rarity upgrades to 99 (HoF).
- `rarity_id = 2` (Gold): T4 boost applied, rarity upgrades to 1 (Diamond).
- `rarity_id = 3` (Silver): T4 boost applied, rarity upgrades to 2 (Gold).
- `rarity_id = 4` (Bronze): T4 boost applied, rarity upgrades to 3 (Silver).
- `rarity_id = 5` (Common): T4 boost applied, rarity upgrades to 4 (Bronze).
- No card ever receives `rarity_id` outside the set {1, 2, 3, 4, 5, 99}.
**Risk If Failed:**
An invalid rarity ID (e.g., 0, 100, or None) propagates into the game engine and Discord bot
display layer. Cards with invalid rarities may render incorrectly, break sort/filter operations
in pack-opening UX, or cause exceptions in code paths that switch on rarity values.
**Files Involved:**
- `rarity_thresholds.py` — authoritative rarity ID definitions
- `docs/prd-evolution/05-rating-boosts.md` — section 5.4 (HoF cap behavior)
- Phase 2: `pd_cards/evo/tier_completion.py` — rarity ladder lookup, T4 completion handler
- Database: `evolution_card_state.final_rarity_id`
---
### T4-5: RP T1 achievability in realistic timeframe
**Status:** Pending — Phase 2
**Scenario:**
The Relief Pitcher track formula is `IP + K` with a T1 threshold of 3. The design intent is
"almost any active reliever hits this" in approximately 2 appearances (from `04-milestones.md`
section 4.2). The scenario to validate: a reliever who throws 1.2 IP (4 outs) with 1 K in an
appearance scores `1.33 + 1 = 2.33` — below T1. This reliever needs another appearance before
reaching T1.
The validation question is whether this is a blocking problem. If typical active RP usage
(5+ team game appearances) reliably produces T1 within a few sessions of play, the design is
sound. If a reliever can appear 45 times and still not reach T1 due to short, low-strikeout
outings (e.g., a pure groundball closer who throws 1.0 IP / 0 K per outing), the threshold
may be too high for the RP role to feel rewarding.
Reference calibration data from Season 10 (via `evo_milestone_simulator.py`): ~94% of all
relievers reached T1 under the IP+K formula with the threshold of 3. However, this is based on
a full or near-full season of data. The question is whether early-season RP usage (first 35
team games) produces T1 reliably.
Worked example for a pure-groundball closer:
- 5 appearances × (1.0 IP + 0 K) = 5.0 — reaches T1 (threshold 3) after appearance 3
- 5 appearances × (0.2 IP + 0 K) = 1.0 — does not reach T1 after 5 appearances
The second case (mop-up reliever with minimal usage) is expected to not reach T1 quickly, and
the design accepts this. What is NOT acceptable: a dedicated closer or setup man with 2+ IP per
session failing to reach T1 after 5+ appearances.
**Expected Outcome:**
A reliever averaging 1.0+ IP per appearance reaches T1 after 3 appearances. A reliever
averaging 0.5+ IP per appearance reaches T1 after 56 appearances. A reliever with fewer than
3 total appearances in a season is not expected to reach T1 — this is acceptable. The ~94%
Season 10 T1 rate confirms the threshold is calibrated correctly for active relievers.
**Risk If Failed:**
If active relievers (regular bullpen roles) cannot reach T1 within 510 team games, the
Refractor system is effectively dead for RP cards from launch. Players who pick up RP cards
expecting progression will see no reward for multiple play sessions, creating a negative first
impression of the entire system.
**Files Involved:**
- `docs/prd-evolution/04-milestones.md` — section 4.2 (RP track thresholds and design intent),
section 4.3 (Season 10 calibration data)
- `scripts/evo_milestone_simulator.py``formula_rp_ip_k`, `simulate_tiers` — re-run against
current season data to validate T1 achievability in early-season usage windows
- Database: `evolution_track` table — threshold values (admin-tunable, no code change required
if recalibration is needed)
---
### T4-6: SP/RP T4 parity with batters
**Status:** Pending — Phase 2
**Scenario:**
The T4 thresholds are:
| Position | T4 Threshold | Formula |
|---|---|---|
| Batter | 896 | PA + (TB x 2) |
| Starting Pitcher | 240 | IP + K |
| Relief Pitcher | 70 | IP + K |
These were calibrated against Season 10 production data using `evo_milestone_simulator.py`.
The calibration target was approximately 3% of active players reaching T4 over a full season
across all position types. The validation here is that this parity holds: one position type
does not trivially farm Superfractors while another cannot reach T2 without extraordinary
performance.
The specific risk: SP T4 requires 240 IP+K across the full season. Top Season 10 SPs (Harang:
163, deGrom: 143) were on pace for T4 at the time of measurement but had not crossed 240 yet.
If the final-season data shows a spike (e.g., 1015% of SPs reaching T4 vs. 3% of batters),
the SP threshold needs adjustment. Conversely, if no reliever reaches T4 in a full season
where 94% reach T1, the RP T4 threshold of 70 may be achievable only by top closers in
extreme usage scenarios.
Validation requires re-running `evo_milestone_simulator.py --season <current>` with the final
season data for all three position types and comparing T4 reach percentages. Accepted tolerance:
T4 reach rate within 2x across position types (e.g., if batters are at 3%, SP and RP should be
between 1.5% and 6%).
**Expected Outcome:**
All three position types produce T4 rates between 1% and 6% over a full season of active play.
No position type produces T4 rates above 10% (trivially farmable) or below 0.5% (effectively
unachievable). SP and RP T4 rates should be comparable because their thresholds were designed
together with the same 3% target in mind.
**Risk If Failed:**
If SP is easy (T4 in half a season) while RP is hard (T4 only for elite closers), then SP card
owners extract disproportionate value from the system. The Refractor system's balance premise
— "same tier, same reward, regardless of position" — breaks down, undermining player confidence
in the fairness of the progression.
**Files Involved:**
- `docs/prd-evolution/04-milestones.md` — section 4.3 (Season 10 calibration table)
- `scripts/evo_milestone_simulator.py` — primary validation tool; run with `--all-formulas
--pitchers-only` and `--batters-only` flags against final season data
- Database: `evolution_track` table — thresholds are admin-tunable; recalibration does not
require a code deployment
---
### T4-7: Cross-season stat accumulation — design confirmation
**Status:** Pending — Phase 2
**Scenario:**
The milestone evaluator (Phase 1, already implemented) queries `BattingSeasonStats` and
`PitchingSeasonStats` and SUMs the formula metric across all rows for a given
`(player_id, team_id)` pair, regardless of season number. This means a player's Refractor
progress is cumulative across seasons: if a player reaches 400 batter points in Season 10 and
another 400 in Season 11, their total is 800 — within range of T4 (threshold: 896).
This design must be confirmed as intentional before Phase 2 is implemented, because it has
significant downstream implications:
1. **Progress does not reset between seasons.** A player who earns a card across multiple
seasons continues progressing the same Refractor state. Season boundaries are invisible to
the evaluator.
2. **New teams start from zero.** If a player trades away a card and acquires a new copy of the
same player, the new card's `evolution_card_state` row starts at T0. The stat accumulation
query is scoped to `(player_id, team_id)`, so historical stats from the previous owner are
not inherited.
3. **Live-series stat updates do not retroactively change progress.** The evaluator reads
finalized season stat rows. If a player's Season 10 stats are adjusted via a data correction,
the evaluator will pick up the change on the next evaluation run — progress could shift
backward if a data correction removes a game's stats.
4. **The "full season" targets in the design docs (e.g., "T4 requires ~120 games") assume
cumulative multi-season play, not a single season.** At ~7.5 batter points per game, T4 of
896 requires approximately 120 in-game appearances. A player who plays 40 games per season
across three seasons reaches T4 in their third season.
This is the confirmed intended design per `04-milestones.md`: "Cumulative within a season —
progress never resets mid-season." The document does not explicitly state "cumulative across
seasons," but the evaluator implementation (SUM across all rows, no season filter) makes this
behavior implicit. This test case exists to surface that ambiguity and require an explicit
design decision before Phase 2 ships.
**Expected Outcome:**
Before Phase 2 implementation begins, the design intent must be explicitly confirmed in writing
(update `04-milestones.md` section 4.1 with a cross-season statement) or the evaluator query
must be updated to add a season boundary. The options are:
- **Option A (current behavior — accumulate across seasons):** Document explicitly. The
Refractor journey can span multiple seasons. Long-term card holders are rewarded for loyalty.
- **Option B (reset per season):** Add a season filter to the evaluator query. Refractor
progress resets at season start. T4 is achievable within a single full season. Cards earned
mid-season have a natural catch-up disadvantage.
This spec takes no position on which option is correct. It records that the choice exists,
that the current implementation defaults to Option A, and that Phase 2 must not be built on
an unexamined assumption about which option is in effect.
**Risk If Failed:**
If Option A is unintentional and players discover their Refractor progress carries over across
seasons before it is documented as a feature, they will optimize around it in ways the design
did not anticipate (e.g., holding cards across seasons purely to farm Refractor tiers). If
Option B is unintentional and progress resets each season without warning, players who invested
heavily in T3 at season end will be angry when their progress disappears.
**Files Involved:**
- `docs/prd-evolution/04-milestones.md` — section 4.1 (design principles) — **requires update
to state the cross-season policy explicitly**
- Phase 1 (implemented): `pd_cards/evo/evaluator.py` — stat accumulation query; inspect the
WHERE clause for any season filter
- Database: `BattingSeasonStats`, `PitchingSeasonStats` — confirm schema includes `season`
column and whether the evaluator query filters on it
- Database: `evolution_card_state` — confirm there is no season-reset logic in the state
management layer
---
## Summary Status
| ID | Title | Status |
|---|---|---|
| T4-1 | 108-sum preservation under batter and pitcher boosts | Shipped — Phase 2 complete |
| T4-2 | D20 probability shift at T4 | Pending — Phase 2 |
| T4-3 | T4 rarity upgrade — pipeline collision risk | Pending — Phase 2 |
| T4-4 | T4 rarity cap for HoF cards | Pending — Phase 2 |
| T4-5 | RP T1 achievability in realistic timeframe | Pending — Phase 2 |
| T4-6 | SP/RP T4 parity with batters | Pending — Phase 2 |
| T4-7 | Cross-season stat accumulation — design confirmation | Pending — Phase 2 |
All cases are unblocked pending Phase 2 implementation. T4-7 requires a design decision before
any Phase 2 code is written. T4-3 requires a resolution strategy to be selected before the T4
completion handler is implemented.

View File

@ -1,810 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Paper Dynasty — Card Cosmetics Explorer</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;700&family=Source+Sans+3:wght@400;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0e0e12;
color: #ccc;
display: flex;
height: 100vh;
overflow: hidden;
}
/* ── Controls Panel ── */
#controls {
width: 340px;
min-width: 340px;
background: #16161e;
border-right: 1px solid #2a2a3a;
overflow-y: auto;
padding: 16px;
}
#controls h1 {
font-size: 16px;
color: #fff;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
#controls .subtitle {
font-size: 11px;
color: #666;
margin-bottom: 16px;
}
.control-group {
margin-bottom: 18px;
}
.control-group h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 8px;
border-bottom: 1px solid #2a2a3a;
padding-bottom: 4px;
}
.control-group label {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s;
}
.control-group label:hover {
background: #1e1e2e;
}
.control-group label.active-option {
background: #1e1e2e;
color: #fff;
}
input[type="radio"] {
accent-color: #6c8aff;
}
input[type="color"] {
width: 28px;
height: 22px;
border: 1px solid #444;
border-radius: 3px;
background: none;
cursor: pointer;
padding: 0;
}
.color-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-size: 13px;
}
.presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.preset-btn {
padding: 5px 10px;
font-size: 11px;
background: #1e1e2e;
color: #aaa;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.preset-btn:hover {
background: #2a2a3e;
color: #fff;
border-color: #555;
}
/* ── Preview Area ── */
#preview-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
background: #111118;
overflow: hidden;
}
#card-wrapper {
width: 100%;
max-width: 900px;
aspect-ratio: 2 / 1;
position: relative;
}
/* ── The Card ── */
#fullCard {
width: 1200px;
height: 600px;
transform-origin: top left;
position: absolute;
top: 0;
left: 0;
font-family: "Open Sans", sans-serif;
font-weight: 400;
overflow: hidden;
background: #fff;
transition: box-shadow 0.3s, border 0.3s, outline 0.3s;
}
/* Card internals — matching the real template */
.row-wrapper {
width: 100%;
flex-grow: 1;
display: flex;
flex-direction: row;
}
.vline { border-left: 3px solid black; height: 100%; }
.header-text { font-size: 25px; text-align: left; }
.column-num {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
font-weight: 700;
font-size: 20px;
color: #fff;
}
.border-bot { border-bottom: 3px solid black; }
.border-right-thick { border-right: 5px solid black; }
.border-right-thin { border-right: 3px solid black; }
.blue-gradient {
background-image: linear-gradient(to right, rgba(0,156,224,1), rgba(0,156,224,0.5), rgba(0,156,224,1));
}
.red-gradient {
background-image: linear-gradient(to right, rgba(211,49,21,1), rgba(211,49,21,0.5), rgba(211,49,21,1));
}
.result {
font-family: 'Source Sans 3', sans-serif;
font-size: 26px;
line-height: 1.3;
}
.result-row {
display: flex;
width: 200px;
padding: 0 4px;
}
.result-2d6 { width: 35px; text-align: right; font-weight: 700; }
.result-col { flex: 1; padding-left: 4px; }
.result-d20 { width: 65px; text-align: right; }
.center { display: block; margin-left: auto; margin-right: auto; }
#header {
transition: background 0.3s;
}
/* Rarity badge SVG area */
.rarity-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 100%;
}
.rarity-badge-img {
height: 50px;
padding: 4px 12px;
border-radius: 6px;
font-size: 18px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 1px;
text-transform: uppercase;
}
.evo-badge {
font-size: 24px;
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
font-weight: 700;
transition: all 0.3s;
}
/* Gradient bar overrides */
.gold-gradient {
background-image: linear-gradient(to right, rgba(218,165,32,1), rgba(218,165,32,0.5), rgba(218,165,32,1)) !important;
}
.dark-gradient {
background-image: linear-gradient(to right, rgba(40,40,50,1), rgba(40,40,50,0.6), rgba(40,40,50,1)) !important;
color: #ccc;
}
/* Holographic frame animation */
@keyframes holoShift {
0% { border-color: #ff0000; box-shadow: 0 0 12px #ff000066; }
16% { border-color: #ff8800; box-shadow: 0 0 12px #ff880066; }
33% { border-color: #ffff00; box-shadow: 0 0 12px #ffff0066; }
50% { border-color: #00ff44; box-shadow: 0 0 12px #00ff4466; }
66% { border-color: #0088ff; box-shadow: 0 0 12px #0088ff66; }
83% { border-color: #aa00ff; box-shadow: 0 0 12px #aa00ff66; }
100% { border-color: #ff0000; box-shadow: 0 0 12px #ff000066; }
}
@keyframes subtlePulse {
0%, 100% { box-shadow: 0 0 8px 2px var(--glow-color); }
50% { box-shadow: 0 0 16px 6px var(--glow-color); }
}
@keyframes strongPulse {
0%, 100% { box-shadow: 0 0 12px 4px var(--glow-color); }
50% { box-shadow: 0 0 28px 12px var(--glow-color); }
}
/* ── Prompt Output ── */
#prompt-area {
width: 100%;
max-width: 900px;
margin-top: 16px;
}
#prompt-output {
background: #1a1a24;
border: 1px solid #2a2a3a;
border-radius: 6px;
padding: 12px 16px;
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
color: #aaa;
line-height: 1.5;
min-height: 48px;
position: relative;
}
#copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 10px;
font-size: 11px;
background: #2a2a3e;
color: #888;
border: 1px solid #444;
border-radius: 3px;
cursor: pointer;
}
#copy-btn:hover { background: #3a3a4e; color: #ccc; }
#prompt-area { position: relative; }
</style>
</head>
<body>
<!-- ══════════ CONTROLS ══════════ -->
<div id="controls">
<h1>Card Cosmetics Explorer</h1>
<p class="subtitle">Paper Dynasty — Evolution Visual System</p>
<div class="presets">
<button class="preset-btn" onclick="applyPreset('default')">Default</button>
<button class="preset-btn" onclick="applyPreset('prestige')">Prestige Gold</button>
<button class="preset-btn" onclick="applyPreset('dark')">Dark Mode</button>
<button class="preset-btn" onclick="applyPreset('midnight')">Midnight</button>
<button class="preset-btn" onclick="applyPreset('holo')">Holographic</button>
<button class="preset-btn" onclick="applyPreset('evolved')">Fully Evolved</button>
</div>
<div class="control-group">
<h3>Frame</h3>
<label><input type="radio" name="frame" value="none" checked onchange="update()"> None</label>
<label><input type="radio" name="frame" value="gold" onchange="update()"> Gold Frame</label>
<label><input type="radio" name="frame" value="diamond" onchange="update()"> Diamond Frame</label>
<label><input type="radio" name="frame" value="team" onchange="update()"> Team Color Frame</label>
<label><input type="radio" name="frame" value="holo" onchange="update()"> Holographic Frame</label>
</div>
<div class="control-group">
<h3>Header Background</h3>
<label><input type="radio" name="headerBg" value="default" checked onchange="update()"> Default (white)</label>
<label><input type="radio" name="headerBg" value="dark" onchange="update()"> Dark Mode</label>
<label><input type="radio" name="headerBg" value="gold" onchange="update()"> Metallic Gold</label>
<label><input type="radio" name="headerBg" value="team" onchange="update()"> Team Color</label>
</div>
<div class="control-group">
<h3>Column Backgrounds</h3>
<label><input type="radio" name="colBg" value="default" checked onchange="update()"> Default (blue / salmon)</label>
<label><input type="radio" name="colBg" value="dark" onchange="update()"> Dark Mode</label>
<label><input type="radio" name="colBg" value="midnight" onchange="update()"> Midnight</label>
<label><input type="radio" name="colBg" value="cream" onchange="update()"> Cream</label>
<label><input type="radio" name="colBg" value="team" onchange="update()"> Team Themed</label>
</div>
<div class="control-group">
<h3>Gradient Bars</h3>
<label><input type="radio" name="gradBars" value="default" checked onchange="update()"> Default (blue / red)</label>
<label><input type="radio" name="gradBars" value="gold" onchange="update()"> Gold / Gold</label>
<label><input type="radio" name="gradBars" value="dark" onchange="update()"> Dark</label>
<label><input type="radio" name="gradBars" value="match" onchange="update()"> Match Column Bg</label>
</div>
<div class="control-group">
<h3>Evolution Badge</h3>
<label><input type="radio" name="evoBadge" value="none" checked onchange="update()"> None</label>
<label><input type="radio" name="evoBadge" value="t1" onchange="update()"> T1 — Initiate</label>
<label><input type="radio" name="evoBadge" value="t2" onchange="update()"> T2 — Rising</label>
<label><input type="radio" name="evoBadge" value="t3" onchange="update()"> T3 — Ascendant</label>
<label><input type="radio" name="evoBadge" value="t4" onchange="update()"> T4 — Evolved</label>
</div>
<div class="control-group">
<h3>Rarity Glow</h3>
<label><input type="radio" name="rarityGlow" value="none" checked onchange="update()"> None</label>
<label><input type="radio" name="rarityGlow" value="subtle" onchange="update()"> Subtle Pulse</label>
<label><input type="radio" name="rarityGlow" value="strong" onchange="update()"> Strong Pulse</label>
</div>
<div class="control-group">
<h3>Team Color</h3>
<div class="color-row">
<input type="color" id="teamColor" value="#003831" onchange="update()">
<span style="font-size: 13px;">Team primary color</span>
</div>
<div style="display: flex; gap: 4px; padding: 4px 8px; flex-wrap: wrap; margin-top: 4px;">
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#003831')">OAK</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#C41E3A')">STL</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#003278')">LAD</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#132448')">NYY</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#BD3039')">LAA</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#002D62')">HOU</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#E81828')">CIN</button>
<button class="preset-btn" style="font-size:10px;padding:3px 6px" onclick="setTeamColor('#0E3386')">NYM</button>
</div>
</div>
</div>
<!-- ══════════ PREVIEW ══════════ -->
<div id="preview-area">
<div id="card-wrapper">
<div id="fullCard">
<!-- HEADER -->
<div id="header" class="row-wrapper header-text border-bot" style="height: 65px;">
<div id="headerLeft" style="width: 477px; height: auto;">
<div class="row-wrapper" style="height: 100%;">
<div style="width: 29px; height: auto; font-size: 30px; margin-left: 6px; display:flex; align-items:center;">
<b>R</b>
</div>
<div class="vline"></div>
<div class="header-text" style="padding-left: 5px; width: 442px;">
<div style="height: 50%; font-variant: small-caps; font-size: 27px; padding-top: 4px;"><b>Mike Trout</b></div>
<div style="height: 50%; padding-left: 18px; font-size: 18px;">CF &nbsp; LF &nbsp; RF</div>
</div>
</div>
</div>
<div id="headerMiddle" style="width: 246px; height: auto; display: flex; align-items: center; justify-content: center;">
<div class="rarity-badge">
<div class="rarity-badge-img" id="rarityBadge" style="background: linear-gradient(135deg, #1a5276, #2e86c1); color: #fff;">
ALL-STAR
</div>
<div class="evo-badge" id="evoBadge"></div>
</div>
</div>
<div id="headerRight" style="width: 477px; height: auto; text-align: right; position: relative;">
<div style="position: absolute; left: 228px; width: 320px; top: 8px;">stealing <b>A-12</b></div>
<div style="position: absolute; left: 563px; width: 150px; top: 8px;">running <b>14</b></div>
<div style="position: absolute; left: 443px; top: 35px; width: 120px;">bunting <b>B</b></div>
<div style="position: absolute; left: 583px; top: 35px; width: 130px;">hit &amp; run <b>A</b></div>
<div style="position: absolute; left: 283px; top: 42px; width: 140px; font-size: 14px;">2025 Live</div>
</div>
</div>
<!-- RESULT HEADERS -->
<div id="allResults" class="result">
<div id="resultHeader" class="row-wrapper border-bot" style="height: 30px;">
<div class="row-wrapper border-right-thick" style="width: 600px;">
<div id="gradL1" class="column-num border-right-thin blue-gradient" style="width: 200px;"><b>1</b></div>
<div id="gradL2" class="column-num border-right-thin blue-gradient" style="width: 200px;"><b>2</b></div>
<div id="gradL3" class="column-num blue-gradient" style="width: 200px;"><b>3</b></div>
</div>
<div class="row-wrapper" style="width: 600px;">
<div id="gradR1" class="column-num border-right-thin red-gradient" style="width: 200px;"><b>1</b></div>
<div id="gradR2" class="column-num border-right-thin red-gradient" style="width: 200px;"><b>2</b></div>
<div id="gradR3" class="column-num red-gradient" style="width: 200px;"><b>3</b></div>
</div>
</div>
<!-- RESULT BODY -->
<div id="resultWrapper" class="row-wrapper" style="height: 505px;">
<div id="vlSide" class="row-wrapper border-right-thick" style="width: 600px; background-color: #ACE6FF;">
<div class="border-right-thin" style="width: 200px;" id="vlCol1"></div>
<div class="border-right-thin" style="width: 200px;" id="vlCol2"></div>
<div style="width: 200px;" id="vlCol3"></div>
</div>
<div id="vrSide" class="row-wrapper" style="width: 600px; background-color: #EAA49C;">
<div class="border-right-thin" style="width: 200px;" id="vrCol1"></div>
<div class="border-right-thin" style="width: 200px;" id="vrCol2"></div>
<div style="width: 200px;" id="vrCol3"></div>
</div>
</div>
</div>
</div>
</div>
<div id="prompt-area">
<div id="prompt-output">
<span id="prompt-text">Default card — no cosmetics applied.</span>
<button id="copy-btn" onclick="copyPrompt()">Copy</button>
</div>
</div>
</div>
<script>
// ── Fake card data ──
const vlData = [
// col1, col2, col3
[
{d:'2',res:'HR',r:'1-2'},{d:'3',res:'3B',r:'3'},{d:'4',res:'DO**',r:'4-5'},
{d:'5',res:'DO*',r:'6'},{d:'6',res:'DO',r:'7-8'},{d:'7',res:'SI**',r:'9'},
{d:'8',res:'SI*',r:'10-11'},{d:'9',res:'SI',r:'12'},{d:'10',res:'W',r:'13-14'},
{d:'11',res:'HBP',r:'15'},{d:'12',res:'K',r:'16-20'},
],
[
{d:'2',res:'HR',r:'1'},{d:'3',res:'DO**',r:'2-3'},{d:'4',res:'DO*',r:'4-5'},
{d:'5',res:'DO',r:'6-7'},{d:'6',res:'SI**',r:'8-9'},{d:'7',res:'SI*',r:'10'},
{d:'8',res:'SI',r:'11-12'},{d:'9',res:'W',r:'13-14'},{d:'10',res:'W',r:'15'},
{d:'11',res:'K',r:'16-18'},{d:'12',res:'K',r:'19-20'},
],
[
{d:'2',res:'HR',r:'1'},{d:'3',res:'3B',r:'2'},{d:'4',res:'DO*',r:'3-4'},
{d:'5',res:'SI**',r:'5-7'},{d:'6',res:'SI*',r:'8-9'},{d:'7',res:'SI',r:'10-11'},
{d:'8',res:'W',r:'12-13'},{d:'9',res:'K',r:'14-15'},{d:'10',res:'LO',r:'16'},
{d:'11',res:'FO(a)',r:'17-18'},{d:'12',res:'GO(b)',r:'19-20'},
]
];
const vrData = [
[
{d:'2',res:'HR',r:'1'},{d:'3',res:'DO**',r:'2-3'},{d:'4',res:'DO*',r:'4'},
{d:'5',res:'DO',r:'5-6'},{d:'6',res:'SI*',r:'7-8'},{d:'7',res:'SI',r:'9-10'},
{d:'8',res:'W',r:'11-12'},{d:'9',res:'K',r:'13-15'},{d:'10',res:'K',r:'16-17'},
{d:'11',res:'FO(b)',r:'18-19'},{d:'12',res:'GO(a)',r:'20'},
],
[
{d:'2',res:'HR',r:'1'},{d:'3',res:'3B',r:'2'},{d:'4',res:'DO**',r:'3-4'},
{d:'5',res:'DO',r:'5-6'},{d:'6',res:'SI**',r:'7'},{d:'7',res:'SI*',r:'8-9'},
{d:'8',res:'SI',r:'10-11'},{d:'9',res:'W',r:'12-13'},{d:'10',res:'K',r:'14-16'},
{d:'11',res:'PO',r:'17-18'},{d:'12',res:'GO(c)',r:'19-20'},
],
[
{d:'2',res:'HR',r:'1-2'},{d:'3',res:'DO*',r:'3-4'},{d:'4',res:'DO',r:'5-6'},
{d:'5',res:'SI**',r:'7'},{d:'6',res:'SI*',r:'8-9'},{d:'7',res:'SI',r:'10-11'},
{d:'8',res:'W',r:'12'},{d:'9',res:'HBP',r:'13'},{d:'10',res:'K',r:'14-16'},
{d:'11',res:'FO(a)',r:'17-18'},{d:'12',res:'GO(b)',r:'19-20'},
]
];
function renderColumn(el, data, textColor) {
el.innerHTML = data.map(r =>
`<div style="display:flex;width:200px;padding:0 4px;color:${textColor}">
<div style="width:35px;text-align:right;font-weight:700">${r.d}</div>
<div style="flex:1;padding-left:6px">${r.res}</div>
<div style="width:65px;text-align:right">${r.r}</div>
</div>`
).join('');
}
function initColumns() {
renderColumn(document.getElementById('vlCol1'), vlData[0], '#000');
renderColumn(document.getElementById('vlCol2'), vlData[1], '#000');
renderColumn(document.getElementById('vlCol3'), vlData[2], '#000');
renderColumn(document.getElementById('vrCol1'), vrData[0], '#000');
renderColumn(document.getElementById('vrCol2'), vrData[1], '#000');
renderColumn(document.getElementById('vrCol3'), vrData[2], '#000');
}
// ── State ──
const DEFAULTS = {
frame: 'none', headerBg: 'default', colBg: 'default',
gradBars: 'default', evoBadge: 'none', rarityGlow: 'none',
teamColor: '#003831'
};
function getState() {
return {
frame: document.querySelector('input[name="frame"]:checked').value,
headerBg: document.querySelector('input[name="headerBg"]:checked').value,
colBg: document.querySelector('input[name="colBg"]:checked').value,
gradBars: document.querySelector('input[name="gradBars"]:checked').value,
evoBadge: document.querySelector('input[name="evoBadge"]:checked').value,
rarityGlow: document.querySelector('input[name="rarityGlow"]:checked').value,
teamColor: document.getElementById('teamColor').value,
};
}
function setTeamColor(c) {
document.getElementById('teamColor').value = c;
update();
}
// ── Rarity color for glow ──
const RARITY_COLOR = '#2e86c1'; // All-Star blue
// ── Update ──
function update() {
const s = getState();
const card = document.getElementById('fullCard');
const header = document.getElementById('header');
const vlSide = document.getElementById('vlSide');
const vrSide = document.getElementById('vrSide');
const badge = document.getElementById('evoBadge');
const gradLs = [document.getElementById('gradL1'), document.getElementById('gradL2'), document.getElementById('gradL3')];
const gradRs = [document.getElementById('gradR1'), document.getElementById('gradR2'), document.getElementById('gradR3')];
// Reset
card.style.border = 'none';
card.style.outline = 'none';
card.style.boxShadow = 'none';
card.style.animation = 'none';
card.style.setProperty('--glow-color', 'transparent');
// ── Frame ──
if (s.frame === 'gold') {
card.style.border = '6px solid #d4a017';
card.style.boxShadow = '0 0 18px 4px rgba(212,160,23,0.4)';
} else if (s.frame === 'diamond') {
card.style.border = '4px solid transparent';
card.style.backgroundClip = 'padding-box';
card.style.outline = '4px solid';
card.style.outlineColor = '#b8d4e3';
card.style.boxShadow = '0 0 12px 3px rgba(184,212,227,0.5), inset 0 0 8px rgba(184,212,227,0.2)';
} else if (s.frame === 'team') {
card.style.border = `6px solid ${s.teamColor}`;
card.style.boxShadow = `0 0 14px 3px ${s.teamColor}66`;
} else if (s.frame === 'holo') {
card.style.border = '5px solid #ff0000';
card.style.animation = 'holoShift 3s linear infinite';
}
// ── Rarity Glow (layered on top of frame) ──
if (s.rarityGlow !== 'none') {
card.style.setProperty('--glow-color', RARITY_COLOR + '88');
if (s.frame === 'none') {
// Glow is the only border effect
if (s.rarityGlow === 'subtle') {
card.style.animation = 'subtlePulse 2.5s ease-in-out infinite';
} else {
card.style.animation = 'strongPulse 2s ease-in-out infinite';
}
} else if (s.frame !== 'holo') {
// Combine glow with existing frame shadow
const existingShadow = card.style.boxShadow || '';
const glowShadow = s.rarityGlow === 'subtle'
? `0 0 16px 6px ${RARITY_COLOR}44`
: `0 0 28px 12px ${RARITY_COLOR}66`;
card.style.boxShadow = existingShadow ? `${existingShadow}, ${glowShadow}` : glowShadow;
}
}
// ── Header Bg ──
const headerTextEls = header.querySelectorAll('b, div');
let headerTextColor = '#000';
if (s.headerBg === 'default') {
header.style.background = '#fff';
} else if (s.headerBg === 'dark') {
header.style.background = '#1a1a2e';
headerTextColor = '#e0e0e0';
} else if (s.headerBg === 'gold') {
header.style.background = 'linear-gradient(135deg, #d4a017, #f0d060, #d4a017)';
headerTextColor = '#1a1000';
} else if (s.headerBg === 'team') {
header.style.background = s.teamColor;
headerTextColor = isLightColor(s.teamColor) ? '#111' : '#f0f0f0';
}
header.style.color = headerTextColor;
document.getElementById('headerRight').style.color = headerTextColor;
// ── Column Backgrounds ──
let vlBg, vrBg, colTextColor = '#000';
if (s.colBg === 'default') { vlBg = '#ACE6FF'; vrBg = '#EAA49C'; }
else if (s.colBg === 'dark') { vlBg = '#1a1a2e'; vrBg = '#2d1b1b'; colTextColor = '#d0d0d0'; }
else if (s.colBg === 'midnight') { vlBg = '#0d1b2a'; vrBg = '#1b0d0d'; colTextColor = '#c8c8c8'; }
else if (s.colBg === 'cream') { vlBg = '#FFF8DC'; vrBg = '#FFE4C4'; }
else if (s.colBg === 'team') {
vlBg = s.teamColor + '30';
vrBg = s.teamColor + '50';
colTextColor = '#111';
}
vlSide.style.backgroundColor = vlBg;
vrSide.style.backgroundColor = vrBg;
// Re-render columns with correct text color
renderColumn(document.getElementById('vlCol1'), vlData[0], colTextColor);
renderColumn(document.getElementById('vlCol2'), vlData[1], colTextColor);
renderColumn(document.getElementById('vlCol3'), vlData[2], colTextColor);
renderColumn(document.getElementById('vrCol1'), vrData[0], colTextColor);
renderColumn(document.getElementById('vrCol2'), vrData[1], colTextColor);
renderColumn(document.getElementById('vrCol3'), vrData[2], colTextColor);
// ── Gradient Bars ──
const gradClasses = { default: ['blue-gradient','red-gradient'], gold: ['gold-gradient','gold-gradient'], dark: ['dark-gradient','dark-gradient'] };
let lClass, rClass;
if (s.gradBars === 'match') {
// Generate inline gradient from column bg
gradLs.forEach(el => {
el.className = 'column-num border-right-thin';
el.style.backgroundImage = `linear-gradient(to right, ${vlBg}, ${adjustAlpha(vlBg, 0.5)}, ${vlBg})`;
el.style.color = colTextColor;
});
gradRs.forEach(el => {
el.className = 'column-num border-right-thin';
el.style.backgroundImage = `linear-gradient(to right, ${vrBg}, ${adjustAlpha(vrBg, 0.5)}, ${vrBg})`;
el.style.color = colTextColor;
});
// Fix last in each group (no right border)
gradLs[2].className = 'column-num';
gradRs[2].className = 'column-num';
} else {
[lClass, rClass] = gradClasses[s.gradBars] || gradClasses.default;
gradLs.forEach((el, i) => {
el.className = `column-num ${lClass}` + (i < 2 ? ' border-right-thin' : '');
el.style.backgroundImage = '';
el.style.color = s.gradBars === 'dark' ? '#ccc' : '#fff';
});
gradRs.forEach((el, i) => {
el.className = `column-num ${rClass}` + (i < 2 ? ' border-right-thin' : '');
el.style.backgroundImage = '';
el.style.color = s.gradBars === 'dark' ? '#ccc' : '#fff';
});
}
// ── Evolution Badge ──
const evoBadges = {
none: { display: 'none' },
t1: { display: 'flex', text: '🌱', bg: '#1a6b1a', color: '#90ee90', shadow: 'none' },
t2: { display: 'flex', text: '⭐', bg: '#2070b0', color: '#50a0e8', shadow: 'none' },
t3: { display: 'flex', text: '💎', bg: '#a82020', color: '#e85050', shadow: '0 0 10px #e8505066' },
t4: { display: 'flex', text: '👑', bg: '#6b2d8e', color: '#a060d0', shadow: '0 0 14px #a060d088' },
};
const eb = evoBadges[s.evoBadge];
badge.style.display = eb.display;
if (eb.display !== 'none') {
badge.textContent = eb.text;
badge.style.background = eb.bg;
badge.style.color = eb.color;
badge.style.boxShadow = eb.shadow;
}
// ── Scale card ──
scaleCard();
// ── Prompt ──
updatePrompt(s);
}
function adjustAlpha(color, alpha) {
// For hex colors with alpha suffix, just return with adjusted alpha
if (color.startsWith('#') && color.length <= 7) {
const r = parseInt(color.slice(1,3), 16);
const g = parseInt(color.slice(3,5), 16);
const b = parseInt(color.slice(5,7), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
return color;
}
function isLightColor(hex) {
const r = parseInt(hex.slice(1,3), 16);
const g = parseInt(hex.slice(3,5), 16);
const b = parseInt(hex.slice(5,7), 16);
return (r * 299 + g * 587 + b * 114) / 1000 > 128;
}
function scaleCard() {
const wrapper = document.getElementById('card-wrapper');
const card = document.getElementById('fullCard');
const ww = wrapper.clientWidth;
const scale = ww / 1200;
card.style.transform = `scale(${scale})`;
wrapper.style.height = `${600 * scale}px`;
}
// ── Presets ──
function applyPreset(name) {
const presets = {
default: { frame:'none', headerBg:'default', colBg:'default', gradBars:'default', evoBadge:'none', rarityGlow:'none' },
prestige: { frame:'gold', headerBg:'gold', colBg:'cream', gradBars:'gold', evoBadge:'t4', rarityGlow:'subtle' },
dark: { frame:'none', headerBg:'dark', colBg:'dark', gradBars:'dark', evoBadge:'none', rarityGlow:'none' },
midnight: { frame:'diamond', headerBg:'dark', colBg:'midnight', gradBars:'dark', evoBadge:'t3', rarityGlow:'subtle' },
holo: { frame:'holo', headerBg:'default', colBg:'default', gradBars:'default', evoBadge:'t2', rarityGlow:'strong' },
evolved: { frame:'gold', headerBg:'dark', colBg:'midnight', gradBars:'gold', evoBadge:'t4', rarityGlow:'strong' },
};
const p = presets[name];
if (!p) return;
Object.entries(p).forEach(([k, v]) => {
const radio = document.querySelector(`input[name="${k}"][value="${v}"]`);
if (radio) radio.checked = true;
});
update();
}
// ── Prompt ──
function updatePrompt(s) {
const parts = [];
const labels = {
frame: { gold: 'Gold Frame (6px gold border + glow)', diamond: 'Diamond Frame (shimmer border)', team: 'Team Color Frame', holo: 'Holographic Frame (animated rainbow)' },
headerBg: { dark: 'Dark Mode header (#1a1a2e)', gold: 'Metallic Gold header gradient', team: 'Team-colored header' },
colBg: { dark: 'Dark Mode columns (#1a1a2e / #2d1b1b, light text)', midnight: 'Midnight columns (#0d1b2a / #1b0d0d, light text)', cream: 'Cream columns (#FFF8DC / #FFE4C4)', team: 'Team-themed columns (team color at 20%/30% opacity)' },
gradBars: { gold: 'Gold gradient bars', dark: 'Dark charcoal gradient bars', match: 'Gradient bars matching column backgrounds' },
evoBadge: { t1: 'T1 Initiate badge (🌱 green)', t2: 'T2 Rising badge (⭐ blue)', t3: 'T3 Ascendant badge (💎 red, glow)', t4: 'T4 Evolved badge (👑 purple, strong glow)' },
rarityGlow: { subtle: 'Subtle rarity pulse glow', strong: 'Strong rarity pulse glow' },
};
for (const [key, map] of Object.entries(labels)) {
if (s[key] !== 'none' && s[key] !== 'default' && map[s[key]]) {
parts.push(map[s[key]]);
}
}
if ((s.frame === 'team' || s.headerBg === 'team' || s.colBg === 'team') && s.teamColor !== DEFAULTS.teamColor) {
parts.push(`Team color: ${s.teamColor}`);
}
const text = parts.length > 0
? `Apply these cosmetics to the card template: ${parts.join('; ')}.`
: 'Default card — no cosmetics applied.';
document.getElementById('prompt-text').textContent = text;
}
function copyPrompt() {
const text = document.getElementById('prompt-text').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 1500);
});
}
// ── Init ──
window.addEventListener('resize', scaleCard);
initColumns();
update();
</script>
</body>
</html>

View File

@ -1,269 +0,0 @@
# 5. Rating Boost Mechanics
[< Back to Index](README.md) | [Next: Database Schema >](06-database.md)
---
## 5.1 Rating Model Overview
The card rating system is built on the `battingcardratings` and `pitchingcardratings` models.
Each model defines outcome columns whose values represent chances out of a **108-chance total**
(derived from the D20 probability system: 2d6 × 3 columns × 6 rows = 108 total chances).
**Batter ratings** have **22 outcome columns** summing to 108:
| Category | Columns |
|---|---|
| Hits | `homerun`, `bp_homerun`, `triple`, `double_three`, `double_two`, `double_pull`, `single_two`, `single_one`, `single_center`, `bp_single` |
| On-base | `hbp`, `walk` |
| Outs | `strikeout`, `lineout`, `popout`, `flyout_a`, `flyout_bq`, `flyout_lf_b`, `flyout_rf_b`, `groundout_a`, `groundout_b`, `groundout_c` |
**Pitcher ratings** have **18 outcome columns + 9 x-check fields** summing to 108:
| Category | Columns |
|---|---|
| Hits allowed | `homerun`, `bp_homerun`, `triple`, `double_three`, `double_two`, `double_cf`, `single_two`, `single_one`, `single_center`, `bp_single` |
| On-base | `hbp`, `walk` |
| Outs | `strikeout`, `flyout_lf_b`, `flyout_cf_b`, `flyout_rf_b`, `groundout_a`, `groundout_b` |
| X-checks | `xcheck_p` (1), `xcheck_c` (3), `xcheck_1b` (2), `xcheck_2b` (6), `xcheck_3b` (3), `xcheck_ss` (7), `xcheck_lf` (2), `xcheck_cf` (3), `xcheck_rf` (2) — always sum to 29 |
**Key differences:** Batters have `double_pull`, pitchers have `double_cf`. Batters have
`lineout`, `popout`, `flyout_a`, `flyout_bq`, `groundout_c` — pitchers do not. Pitchers have
`flyout_cf_b` and x-check fields — batters do not.
Evolution boosts apply **flat deltas to individual result columns** within these models. The
108-sum constraint must be maintained: any increase to a positive outcome column requires an
equal decrease to a negative outcome column.
### Rating Cap Enforcement
All boosts are subject to the existing hard caps on individual stat columns. If applying a delta
would push a value past its cap, the delta is **truncated** to the cap value.
**Key caps (from existing card creation system):**
| Stat | Cap | Direction | Example |
|---|---|---|---|
| Hold rating (pitcher) | -5 | Lower is better | A pitcher at -4 hold can only receive -1 more |
| Result columns | 0 floor | Cannot go negative | A 0.1 strikeout column can only lose 0.1 |
**Truncated points are lost, not redistributed.** If a boost would push a stat past its cap, the
delta is truncated and the excess is simply discarded. This is an intentional soft penalty for
cards that are already near their ceiling — they're being penalized because they're already that
good. Lower-rated cards have more headroom and benefit more from the same flat delta.
## 5.2 Boost Budgets Per Tier
Rating boosts are defined as **flat deltas to specific result columns** within the 108-sum model.
The budget per tier is the total number of chances that can be shifted from negative outcomes
(outs) to positive outcomes (hits, on-base).
| Tier | Batter Budget | Pitcher TB Budget | Approx Impact |
|------|--------------|-------------------|---------------|
| T1 | 2.0 chances net (+2.0 pos, -2.0 neg) | 1.5 TB units | Fixed deltas / priority drain |
| T2 | 2.0 chances net | 1.5 TB units | Same — consistent per-tier reward |
| T3 | 2.0 chances net | 1.5 TB units | Same — consistent per-tier reward |
| T4 | 2.0 chances net | 1.5 TB units | Same — plus rarity upgrade |
| **Total** | **8.0 chances net** | **6.0 TB units** | **~7.4% of chances shifted (batter)** |
Every tier provides the same fixed boost. T4 is distinguished not by a larger delta but by the
rarity upgrade, which is the real capstone reward.
**Flat delta design rationale:** All cards receive the same absolute boost regardless of rarity.
A Replacement card (where `homerun` might be 0.3) gains much more relative value from a fixed
+0.50 HR boost than a Hall of Fame card (where `homerun` might be 5.0). This intentionally
incentivizes using lower-rated cards and prevents elite cards from becoming god-tier. Cards
already near column caps receive even less due to truncation.
**Example — T1 batter boost:**
```
homerun: +0.50 (from 2.0 → 2.50)
double_pull: +0.50 (from 3.5 → 4.00)
single_one: +0.50 (from 4.0 → 4.50)
walk: +0.50 (from 3.0 → 3.50)
strikeout: -1.50 (from 15.0 → 13.50)
groundout_a: -0.50 (from 8.0 → 7.50)
Net: +2.0 / -2.0 = 0, sum stays at 108
```
## 5.3 Shipped Boost Distribution
> **Updated 2026-04-08 to reflect shipped implementation.**
> The original spec described profile-based boost distribution (power hitter, contact hitter,
> patient hitter profiles). The implementation uses a simpler, more predictable approach:
> fixed deltas for batters and a TB-budget priority algorithm for pitchers. Profile detection
> was not implemented.
### 5.3.1 Batter Boost — Fixed Column Deltas
Every batter receives identical fixed deltas per tier regardless of their profile. There is no
player-style detection. The implementation is in `apply_batter_boost()` in
`database/app/services/refractor_boost.py`.
**Positive deltas (applied each tier):**
| Column | Delta |
|---|---|
| `homerun` | +0.50 |
| `double_pull` | +0.50 |
| `single_one` | +0.50 |
| `walk` | +0.50 |
**Negative deltas (funding source):**
| Column | Delta |
|---|---|
| `strikeout` | -1.50 |
| `groundout_a` | -0.50 |
**0-floor truncation behavior:** If `strikeout` or `groundout_a` cannot supply their full
requested reduction (because the column is already near zero), the positive deltas are scaled
proportionally so the 108-sum invariant is always preserved. Specifically:
1. Negative deltas are applied first, each capped at the column's current value (0 floor).
2. The total amount actually reduced is computed.
3. Positive deltas are scaled by `actually_reduced / total_requested_addition` so that
additions always equal reductions.
4. A warning is logged when truncation occurs.
This differs from the original spec's statement that "truncated points are lost, not
redistributed." In the shipped implementation, positive deltas are scaled down to match what
was actually taken — the 108-sum is always exactly preserved.
### 5.3.2 Pitcher Boost — TB-Budget Priority Algorithm
Pitchers use a total-bases budget approach instead of fixed column deltas. Each tier awards a
**1.5 TB-unit budget**. The algorithm converts hit-allowed chances into strikeouts, iterating
through outcome types in priority order (most damaging hits first) until the budget is exhausted.
The implementation is in `apply_pitcher_boost()` in `database/app/services/refractor_boost.py`.
**Priority order and TB cost per chance:**
| Priority | Column | TB Cost |
|---|---|---|
| 1 | `double_cf` | 2 |
| 2 | `double_three` | 2 |
| 3 | `double_two` | 2 |
| 4 | `single_center` | 1 |
| 5 | `single_two` | 1 |
| 6 | `single_one` | 1 |
| 7 | `bp_single` | 1 |
| 8 | `walk` | 1 |
| 9 | `homerun` | 4 |
| 10 | `bp_homerun` | 4 |
| 11 | `triple` | 3 |
| 12 | `hbp` | 1 |
**Algorithm per tier:**
1. Start with `remaining = 1.5` TB budget.
2. Iterate priority list in order. Skip columns already at 0.
3. For each column: compute `chances_to_take = min(column_value, remaining / tb_cost)`.
4. Reduce the column by `chances_to_take`; add `chances_to_take` to `strikeout`.
5. Reduce `remaining` by `chances_to_take * tb_cost`.
6. Stop when `remaining <= 0` or the priority list is exhausted.
X-check columns (`xcheck_p` through `xcheck_rf`, always summing to 29) are never touched by
the boost algorithm.
**Budget not fully spent:** If all priority columns are already at zero before the budget is
exhausted (extremely rare), the remaining budget is discarded and a warning is logged.
**No separate SP vs. RP logic:** The same algorithm applies to both starting pitchers and
relief pitchers. Card type (`sp` vs. `rp`) determines how the card is used in the game engine
but does not change the boost formula.
### 5.3.3 Function Signatures (Shipped)
The boost logic lives in the **database repo** (`database/app/services/refractor_boost.py`),
not in card-creation. The functions called per tier-up are:
```python
# Batter
apply_batter_boost(ratings_dict: dict) -> dict
# Pitcher (sp or rp)
apply_pitcher_boost(ratings_dict: dict, tb_budget: float = 1.5) -> dict
```
Both functions accept a dict of outcome column values and return a new dict with updated values
(all other keys passed through unchanged). They are pure functions — no DB access.
The orchestration function that applies the correct boost, creates the variant card row, updates
`RefractorCardState`, and writes the audit record is:
```python
apply_tier_boost(
player_id: int,
team_id: int,
new_tier: int,
card_type: str, # 'batter', 'sp', or 'rp'
...injectable test stubs...
) -> dict # {'variant_created': int, 'boost_deltas': dict}
```
The `card-creation` repo does not contain boost application code. The `pd_cards/evo/` package
referenced in the original spec was not created; the boost logic was implemented directly in the
database API service layer.
## 5.4 Rarity Upgrade at T4
When a card completes T4, the card's rarity is upgraded by one tier (if below HoF):
- The `player.rarity_id` field is incremented by one step (e.g., Sta -> All)
- The card's base rating recalculation is skipped; only the T4 boost deltas are applied on top of the
accumulated evolved ratings
- The card cost field is NOT automatically recalculated (rarity upgrade is a gameplay reward, not
a market event; admin can manually adjust if needed)
- The rarity change is recorded in `evolution_card_state.final_rarity_id` for audit purposes
- **HoF cards cannot upgrade further** — they receive the T4 boost deltas but no rarity change
**Live series interaction:** If a card's rarity changes due to a live series update (e.g.,
Reserve → All-Star after a hot streak), the evolution rarity upgrade stacks on top of the
*current* rarity at the time T4 completes. The evolution system does not track or care about
historical rarity — it simply increments whatever the current rarity is by one step.
## 5.5 Variant System Usage (Hash-Based)
The existing `battingcard.variant` and `pitchingcard.variant` fields (integer, UNIQUE with player)
are currently always 0. The evolution system uses variant to store evolved versions, with the
variant number derived from a **deterministic hash** of all inputs that affect the card:
```python
import hashlib, json
def compute_variant_hash(player_id: int, refractor_tier: int,
cosmetics: list[str] | None) -> int:
"""Compute a stable variant number from refractor + cosmetic state."""
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) # 32-bit unsigned integer from first 8 hex chars
return result if result != 0 else 1 # variant=0 is reserved for base cards
```
- `variant = 0`: Base card (standard, shared across all teams)
- `variant = <hash>`: Evolution/cosmetic-specific card with boosted ratings and custom image
**Key property: two teams with the same player_id, same evolution tier, and same cosmetics
produce the same variant hash.** This means they share the same ratings rows and the same
rendered S3 image — no duplication. If either team changes any input (buys a cosmetic), the
hash changes, creating a new variant.
Each tier completion or cosmetic change computes the new variant hash, checks if a `battingcard`
row with that variant exists (reuse if so), and creates one if not. The `card` table instance
points to its current variant via `card.variant`.
Evolved rating rows coexist with the base card in the same `battingcardratings`/`pitchingcardratings`
tables, keyed by `(battingcard_id, vs_hand)` where `battingcard_id` points to the variant row.
No new columns needed on the ratings table itself.
**Image storage:** Each variant's rendered card image URL is stored on `battingcard.image_url`
and `pitchingcard.image_url` (new nullable columns). The bot's display logic checks `card.variant`:
if set, look up the variant's `battingcard.image_url`; if null, fall back to `player.image`.
Images are rendered once via the existing Playwright pipeline (with cosmetic CSS applied) and
uploaded to S3 at a predictable path: `cards/cardset-{id}/player-{player_id}/v{variant}/battingcard.png`.
The 5-6 second render cost is paid once per variant creation, not on every display.

View File

@ -1,356 +0,0 @@
# Phase 0 — Render Pipeline Optimization: Project Plan
**Version:** 1.1
**Date:** 2026-03-13
**PRD Reference:** `docs/prd-evolution/02-architecture.md` § Card Render Pipeline Optimization, `13-implementation.md` § Phase 0
**Status:** Complete — deployed to dev (PR #94), client-side concurrent uploads merged via PR #28 (card-creation repo)
---
## Overview
Phase 0 is independent of Card Evolution and benefits all existing card workflows immediately. The goal is to reduce per-card render time and full cardset uploads significantly by eliminating browser spawn overhead, CDN dependencies, and sequential processing.
**Bottlenecks addressed:**
1. New Chromium process spawned per render request (~1.0-1.5s overhead)
2. Google Fonts CDN fetched over network on every render (~0.3-0.5s) — no persistent cache since browser is destroyed after each render
3. Upload pipeline is fully sequential — one card at a time, blocking S3 upload via synchronous boto3
**Results:**
| Metric | Before | Target | Actual |
|--------|--------|--------|--------|
| Per-card render (fresh) | ~2.0s (benchmark avg) | <1.0s | **~0.98s avg** (range 0.63-1.44s, **~51% reduction**) |
| Per-card render (cached) | N/A | — | **~0.1s** |
| External dependencies during render | Google Fonts CDN | None | **None** |
| Chromium processes per 800-card run | 800 | 1 | **1** |
| 800-card upload (sequential, estimated) | ~27 min | ~8-13 min | ~13 min (estimated at 0.98s/card) |
| 800-card upload (concurrent 8x, estimated) | N/A | ~2-4 min | ~2-3 min (estimated) |
**Benchmark details (7 fresh renders on dev, 2026-03-13):**
| Player | Type | Time |
|--------|------|------|
| Michael Young (12726) | Batting | 0.96s |
| Darin Erstad (12729) | Batting | 0.78s |
| Wilson Valdez (12746) | Batting | 1.44s |
| Player 12750 | Batting | 0.76s |
| Jarrod Washburn (12880) | Pitching | 0.63s |
| Ryan Drese (12879) | Pitching | 1.25s |
| Player 12890 | Pitching | 1.07s |
**Average: 0.98s** — meets the <1s target. Occasional spikes to ~1.4s from Chromium GC pressure. Pitching cards tend to render slightly faster due to less template data.
**Optimization breakdown:**
- Persistent browser (WP-02): eliminated ~1.0s spawn overhead
- Variable font deduplication (WP-01 fix): eliminated ~163KB redundant base64 parsing, saved ~0.4s
- Remaining ~0.98s is Playwright page creation, HTML parsing, and PNG screenshot — not reducible without GPU acceleration or a different rendering approach
---
## Work Packages (6 WPs)
### WP-00: Baseline Benchmarks
**Repo:** `database` + `card-creation`
**Complexity:** XS
**Dependencies:** None
Capture before-metrics so we can measure improvement.
#### Tasks
1. Time 10 sequential card renders via the API (curl with timing)
2. Time a small batch S3 upload (e.g., 20 cards) via `pd-cards upload`
3. Record results in a benchmark log
#### Tests
- [ ] Benchmark script or documented curl commands exist and are repeatable
#### Acceptance Criteria
1. Baseline numbers recorded for per-card render time
2. Baseline numbers recorded for batch upload time
3. Methodology is repeatable for post-optimization comparison
---
### WP-01: Self-Hosted Fonts
**Repo:** `database`
**Complexity:** S
**Dependencies:** None (can run in parallel with WP-02)
Replace Google Fonts CDN with locally embedded WOFF2 fonts. Eliminates ~0.3-0.5s network round-trip per render and removes external dependency.
#### Current State
- `storage/templates/player_card.html` lines 5-7: `<link>` tags to `fonts.googleapis.com`
- `storage/templates/style.html`: References `"Open Sans"` and `"Source Sans 3"` font-families
- Two fonts used: Open Sans (300, 400, 700) and Source Sans 3 (400, 700)
#### Implementation
1. Download WOFF2 files for both fonts (5 files total: Open Sans 300/400/700, Source Sans 3 400/700)
2. Base64-encode each WOFF2 file
3. Add `@font-face` declarations with base64 data URIs to `style.html`
4. Remove the three `<link>` tags from `player_card.html`
5. Visual diff: render the same card before/after and verify identical output
#### Files
- **Create:** `database/storage/fonts/` directory with raw WOFF2 files (source archive, not deployed)
- **Modify:** `database/storage/templates/style.html` — add `@font-face` declarations
- **Modify:** `database/storage/templates/player_card.html` — remove `<link>` tags (lines 5-7)
#### Tests
- [ ] Unit: `style.html` contains no `fonts.googleapis.com` references
- [ ] Unit: `player_card.html` contains no `<link>` to external font CDNs
- [ ] Unit: `@font-face` declarations present for all 5 font variants
- [ ] Visual: rendered card is pixel-identical to pre-change output (manual check)
#### Acceptance Criteria
1. No external network requests during card render
2. All 5 font weights render correctly
3. Card appearance unchanged
---
### WP-02: Persistent Browser Instance
**Repo:** `database`
**Complexity:** M
**Dependencies:** None (can run in parallel with WP-01)
Replace per-request Chromium launch/teardown with a persistent browser that lives for the lifetime of the API process. Eliminates ~1.0-1.5s spawn overhead per render.
#### Current State
- `app/routers_v2/players.py` lines 801-826: `async with async_playwright() as p:` block creates and destroys a browser per request
- No browser reuse, no connection pooling
#### Implementation
1. Add module-level `_browser` and `_playwright` globals to `players.py`
2. Implement `get_browser()` — lazy-init with `is_connected()` auto-reconnect
3. Implement `shutdown_browser()` — clean teardown for API shutdown
4. Replace the `async with async_playwright()` block with page-per-request pattern:
```python
browser = await get_browser()
page = await browser.new_page(viewport={"width": 1280, "height": 720})
try:
await page.set_content(html_string)
await page.screenshot(path=file_path, type="png", clip={...})
finally:
await page.close()
```
5. Ensure page is always closed in `finally` block to prevent memory leaks
#### Files
- **Modify:** `database/app/routers_v2/players.py` — persistent browser, page-per-request
#### Tests
- [ ] Unit: `get_browser()` returns a connected browser
- [ ] Unit: `get_browser()` returns same instance on second call
- [ ] Unit: `get_browser()` relaunches if browser disconnected
- [ ] Integration: render 10 cards sequentially, no browser leaks (page count returns to 0 between renders)
- [ ] Integration: concurrent renders (4 simultaneous requests) complete without errors
- [ ] Integration: `shutdown_browser()` cleanly closes browser and playwright
#### Acceptance Criteria
1. Only 1 Chromium process running regardless of render count
2. Page count returns to 0 between renders (no leaks)
3. Auto-reconnect works if browser crashes
4. ~~Per-card render time drops to ~1.0-1.5s~~ **Actual: ~0.98s avg fresh render (from ~2.0s baseline) — target met**
---
### WP-03: FastAPI Lifespan Hooks
**Repo:** `database`
**Complexity:** S
**Dependencies:** WP-02
Wire `get_browser()` and `shutdown_browser()` into FastAPI's lifespan so the browser warms up on startup and cleans up on shutdown.
#### Current State
- `app/main.py` line 54: plain `FastAPI(...)` constructor with no lifespan
- Only middleware is the DB session handler (lines 97-105)
#### Implementation
1. Add `@asynccontextmanager` lifespan function that calls `get_browser()` on startup and `shutdown_browser()` on shutdown
2. Pass `lifespan=lifespan` to `FastAPI()` constructor
3. Verify existing middleware is unaffected
#### Files
- **Modify:** `database/app/main.py` — add lifespan hook, pass to FastAPI constructor
- **Modify:** `database/app/routers_v2/players.py` — export `get_browser`/`shutdown_browser` (if not already importable)
#### Tests
- [ ] Integration: browser is connected immediately after API startup (before any render request)
- [ ] Integration: browser is closed after API shutdown (no orphan processes)
- [ ] Integration: existing DB middleware still functions correctly
- [ ] Integration: API health endpoint still responds
#### Acceptance Criteria
1. Browser pre-warmed on startup — first render request has no cold-start penalty
2. Clean shutdown — no orphan Chromium processes after API stop
3. No regression in existing API behavior
---
### WP-04: Concurrent Upload Pipeline
**Repo:** `card-creation`
**Complexity:** M
**Dependencies:** WP-02 (persistent browser must be deployed for concurrent renders to work)
Replace the sequential upload loop with semaphore-bounded `asyncio.gather` for parallel card fetching, rendering, and S3 upload.
#### Current State
- `pd_cards/core/upload.py` `upload_cards_to_s3()` (lines 109-333): sequential `for x in all_players:` loop
- `fetch_card_image` timeout hardcoded to 6s (line 28)
- `upload_card_to_s3()` uses synchronous `boto3.put_object` — blocks the event loop
- Single `aiohttp.ClientSession` is reused (good)
#### Implementation
1. Wrap per-card processing in an `async def process_card(player)` coroutine
2. Add `asyncio.Semaphore(concurrency)` guard (default concurrency=8)
3. Replace sequential loop with `asyncio.gather(*[process_card(p) for p in all_players], return_exceptions=True)`
4. Offload synchronous `upload_card_to_s3()` to thread pool via `asyncio.get_event_loop().run_in_executor(None, upload_card_to_s3, ...)`
5. Increase `fetch_card_image` timeout from 6s to 10s
6. Add error handling: individual card failures logged but don't abort the batch
7. Add progress reporting: log completion count every N cards (not every start)
8. Add `--concurrency` CLI argument to `pd-cards upload` command
#### Files
- **Modify:** `pd_cards/core/upload.py` — concurrent pipeline, timeout increase
- **Modify:** `pd_cards/cli/upload.py` (or wherever CLI args are defined) — add `--concurrency` flag
#### Tests
- [ ] Unit: semaphore limits concurrent tasks to specified count
- [ ] Unit: individual card failure doesn't abort batch (return_exceptions=True)
- [ ] Unit: progress logging fires at correct intervals
- [ ] Integration: 20-card concurrent upload completes successfully
- [ ] Integration: S3 URLs are correct after concurrent upload
- [ ] Integration: `--concurrency 1` behaves like sequential (regression safety)
#### Acceptance Criteria
1. Default concurrency of 8 parallel card processes
2. Individual failures logged, don't abort batch
3. `fetch_card_image` timeout is 10s
4. 800-card upload estimated at ~3-4 minutes with 8x concurrency (with WP-01 + WP-02 deployed)
5. `--concurrency` flag available on CLI
---
### WP-05: Legacy Upload Script Update
**Repo:** `card-creation`
**Complexity:** S
**Dependencies:** WP-04
Apply the same concurrency pattern to `check_cards_and_upload.py` for users who still use the legacy script.
#### Current State
- `check_cards_and_upload.py` lines 150-293: identical sequential pattern to `pd_cards/core/upload.py`
- Module-level boto3 client (line 27)
#### Implementation
1. Refactor the sequential loop to use `asyncio.gather` + `Semaphore` (same pattern as WP-04)
2. Offload synchronous S3 calls to thread pool
3. Increase fetch timeout to 10s
4. Add progress reporting
#### Files
- **Modify:** `check_cards_and_upload.py`
#### Tests
- [ ] Integration: legacy script uploads 10 cards concurrently without errors
- [ ] Integration: S3 URLs match expected format
#### Acceptance Criteria
1. Same concurrency behavior as WP-04
2. No regression in existing functionality
---
## WP Summary
| WP | Title | Repo | Size | Dependencies | Tests |
|----|-------|------|------|-------------|-------|
| WP-00 | Baseline Benchmarks | both | XS | — | 1 |
| WP-01 | Self-Hosted Fonts | database | S | — | 4 |
| WP-02 | Persistent Browser Instance | database | M | — | 6 |
| WP-03 | FastAPI Lifespan Hooks | database | S | WP-02 | 4 |
| WP-04 | Concurrent Upload Pipeline | card-creation | M | WP-02 | 6 |
| WP-05 | Legacy Upload Script Update | card-creation | S | WP-04 | 2 |
**Total: 6 WPs, ~23 tests**
---
## Dependency Graph
```
WP-00 (benchmarks)
|
v
WP-01 (fonts) ──────┐
├──> WP-03 (lifespan) ──> Deploy to dev ──> WP-04 (concurrent upload)
WP-02 (browser) ────┘ |
v
WP-05 (legacy script)
|
v
Re-run benchmarks
```
**Parallelization:**
- WP-00, WP-01, WP-02 can all start immediately in parallel
- WP-03 needs WP-02
- WP-04 needs WP-02 deployed (persistent browser must be running server-side for concurrent fetches to work)
- WP-05 needs WP-04 (reuse the pattern)
---
## Risks
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Base64-embedded fonts bloat template HTML | Medium | Low | WOFF2 files are small (~20-40KB each). Total ~150KB base64 added to template. Acceptable since template is loaded once into Playwright, not transmitted to clients. |
| Persistent browser memory leak | Medium | Medium | Always close pages in `finally` block. Monitor RSS after sustained renders. Add `is_connected()` check for crash recovery. |
| Concurrent renders overload API server | Low | High | Semaphore bounds concurrency. Start at 8, tune based on server RAM (~100MB per page). 8 pages = ~800MB, well within 16GB. |
| Synchronous boto3 blocks event loop under concurrency | Medium | Medium | Use `run_in_executor` to offload to thread pool. Consider `aioboto3` if thread pool proves insufficient. |
| Visual regression from font change | Low | High | Visual diff test before/after. Render same card with both approaches and compare pixel output. |
---
## Open Questions
None — Phase 0 is straightforward infrastructure optimization with no design decisions pending.
---
## Follow-On: Local High-Concurrency Rendering (2026-03-14)
After Phase 0 was deployed, a follow-on improvement was implemented: **configurable API URL** for card rendering. This enables running the Paper Dynasty API server locally on the workstation and pointing upload scripts at `localhost` for dramatically higher concurrency.
### Changes
- `pd_cards/core/upload.py``upload_cards_to_s3()`, `refresh_card_images()`, `check_card_images()` accept `api_url` parameter (defaults to production)
- `pd_cards/commands/upload.py``--api-url` CLI option on `upload s3` command
- `check_cards_and_upload.py``PD_API_URL` env var override (legacy script)
### Expected Performance
| Scenario | Per-card | 800 cards |
|----------|----------|-----------|
| Remote server, 8x concurrency (current) | ~0.98s render + network | ~2-3 min |
| Local server, 32x concurrency | ~0.98s render, 32 parallel | ~30-45 sec |
### Usage
```bash
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
```
---
## Notes
- Phase 0 is a prerequisite for Phase 4 (Animated Cosmetics) which needs the persistent browser for efficient multi-frame APNG capture
- The persistent browser also benefits Phase 2/3 variant rendering
- GPU acceleration was evaluated and rejected — see PRD `02-architecture.md` § Optimization 4
- Consider `aioboto3` as a future enhancement if `run_in_executor` thread pool becomes a bottleneck

File diff suppressed because it is too large Load Diff

View File

@ -1,247 +0,0 @@
# Refractor Tier Visual Spec — Cherry-Pick Reference
Approved effects from `wip/refractor-card-art` mockup (`docs/refractor-tier-mockup.html`).
This document is the handoff reference for applying these visuals to the production card renderer.
---
## 1. Tier Diamond Indicator
A 4-quadrant diamond icon centered at the intersection of the left/right column headers.
Replaces all per-tier emoji badges.
### Positioning & Structure
- **Position**: `left: 600px; top: 78.5px` (centered between column header top/bottom borders)
- **Size**: `19px × 19px` (tips fit within header row bounds)
- **Rotation**: `transform: translate(-50%, -50%) rotate(45deg)`
- **Layout**: CSS Grid `2×2`, `gap: 2px`
- **Background (gap color)**: `rgba(0,0,0,0.75)` with `border-radius: 2px`
- **Base shadow**: `0 0 0 1.5px rgba(0,0,0,0.7), 0 2px 5px rgba(0,0,0,0.5)`
- **z-index**: 20
### Fill Order (Baseball Base Path)
Quadrants fill progressively following the base path: **1st → 2nd → 3rd → Home**.
| Tier | Quadrants Filled | Visual |
|------|-----------------|--------|
| T0 (Base) | 0 — no diamond shown | — |
| T1 | 1st base (right) | ◇ with right quadrant filled |
| T2 | 1st + 2nd (right + top) | ◇ with two quadrants |
| T3 | 1st + 2nd + 3rd (right + top + left) | ◇ with three quadrants |
| T4 | All 4 (full diamond) | ◆ fully filled |
### Grid-to-Visual Mapping (after 45° rotation)
The CSS grid positions map to visual positions as follows:
| Grid Slot | Visual Position | Base |
|-----------|----------------|------|
| div 1 (top-left) | TOP | 2nd base |
| div 2 (top-right) | RIGHT | 1st base |
| div 3 (bottom-left) | LEFT | 3rd base |
| div 4 (bottom-right) | BOTTOM | Home plate |
**Render order in HTML**: `[2nd, 1st, 3rd, home]` (matches grid slot order above).
### Quadrant Fill Styling
Unfilled quads: `background: rgba(0,0,0,0.3)` (dark placeholder).
Filled quads use a gradient + inset shadow for depth:
```css
/* Standard filled quad */
.diamond-quad.filled {
background: linear-gradient(135deg, {highlight} 0%, {color} 50%, {color-darkened-75%} 100%);
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);
}
```
### Approved Effect: Metallic Sheen + Pulse Glow
The approved diamond effect combines metallic inset highlights with an animated glow pulse.
**Metallic gradient** (replaces standard gradient on filled quads):
```css
background: linear-gradient(135deg,
rgba(255,255,255,0.9) 0%,
{highlight} 20%,
{color} 50%,
{color-darkened-60%} 80%,
{highlight} 100%);
```
**Metallic inset shadows** (boosted highlights):
```css
.diamond-quad.metallic.filled {
box-shadow:
inset 0 1px 3px rgba(255,255,255,0.7),
inset 0 -1px 2px rgba(0,0,0,0.5),
inset 1px 0 3px rgba(255,255,255,0.3),
inset -1px 0 2px rgba(0,0,0,0.2);
}
```
**Glow pulse animation** (tight diameter, applied to `.tier-diamond` container):
```css
@keyframes diamond-glow-pulse {
0% { 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 var(--diamond-glow-color);
}
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 var(--diamond-glow-color),
0 0 24px 8px var(--diamond-glow-color);
}
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 var(--diamond-glow-color);
}
}
.tier-diamond.diamond-glow {
animation: diamond-glow-pulse 2s ease-in-out infinite;
}
```
Metallic sheen and glow pulse are **independent** effects. In production, apply metallic sheen to filled diamonds across all tiers. Apply glow pulse selectively by tier (T4 always gets it; T1T3 do not in the approved configuration).
---
## 2. Tier Diamond Colors
| Tier | Color (body) | Highlight (bright edge) | Glow Color | Intent |
|------|-------------|------------------------|------------|--------|
| T1 | `#1a6b1a` | `#40b040` | `#1a6b1a` | Green |
| T2 | `#2070b0` | `#50a0e8` | `#2070b0` | Blue |
| T3 | `#a82020` | `#e85050` | `#a82020` | Red |
| T4 | `#6b2d8e` | `#a060d0` | `#6b2d8e` | Purple |
Progression: warm → hot → regal → transcendent.
---
## 3. T3 Gold Shimmer Sweep (Header Animation)
A single narrow gold stripe sweeps left-to-right across the card header.
- **Duration**: 2.5s loop, ease-in-out
- **Gradient**: 105° diagonal, peak opacity 0.38
- **Key colors**: `rgba(255,240,140,0.18)``rgba(255,220,80,0.38)``rgba(255,200,60,0.30)`
- **Scope**: Header only (`.card-header` has `overflow: hidden`)
- **z-index**: 5
```css
@keyframes t3-shimmer {
0% { transform: translateX(-130%); }
100% { transform: translateX(230%); }
}
```
### Playwright APNG Capture
For static card rendering, the shimmer position is driven by `--anim-progress` (0.01.0) instead of CSS animation. Playwright captures 8 frames to produce an APNG.
---
## 4. T4 Superfractor — Layered Animation System
T4 stacks four independent effect layers for a premium look qualitatively different from T3.
### Layer 1: Prismatic Rainbow Header Sweep
- Seamless loop using a 200%-wide element with two mirrored rainbow bands
- `translateX(-50%)` over 6s linear = continuous wrap
- Colors: red → gold → green → blue → violet → pink, all at ~0.28 opacity
- z-index: 1 (behind header text at z-index: 2)
- Header children get `position: relative; z-index: 2` to sit above rainbow
### Layer 2+3: Gold/Teal Dual Glow Pulse
- Applied via `::before` on the card element
- Gold and teal in opposition: when gold brightens, teal dims and vice versa
- 2s ease-in-out loop
- Inset box-shadows (`45px 12px` gold, `80px 5px` teal)
- z-index: 4
```css
@keyframes t4-dual-pulse {
0% { box-shadow: inset 0 0 45px 12px rgba(201,169,78,0.40),
inset 0 0 80px 5px rgba(45,212,191,0.08); }
50% { box-shadow: inset 0 0 45px 12px rgba(201,169,78,0.08),
inset 0 0 80px 5px rgba(45,212,191,0.38); }
100% { /* same as 0% */ }
}
```
### Layer 4: Column Bar Shimmer
- White highlight (`rgba(255,255,255,0.28)`) sweeps across each column header bar
- 1.6s ease-in-out loop, staggered by -0.25s per bar for a ripple effect
- 6 bars total (3 left group, 3 right group)
---
## 5. T4b Variant — Full-Card Rainbow
Same as T4 but the prismatic rainbow covers the entire card height (not just header).
- Applied via `::after` on `.pd-card` instead of `.card-header::after`
- Slightly reduced opacity (0.180.22 vs 0.280.32)
- z-index: 6 (above content)
- Dual glow pulse uses a separate `.dual-pulse-overlay` div at 2.8s (slightly slower)
- Column bar shimmer identical to T4
**Status**: Experimental variant. May or may not ship — kept as an option.
---
## 6. Corner Accents (T4 Only)
L-shaped corner brackets on all four card corners.
- **Color**: `#c9a94e` (gold)
- **Size**: 35px arms, 3px thick
- **Implementation**: Four absolutely-positioned divs with two-sided borders each
- **z-index**: 6
---
## 7. Implementation Notes for Production
### What to port
1. **Diamond indicator CSS** (`.tier-diamond`, `.diamond-quad`, keyframes) → add to card template stylesheet
2. **Diamond HTML generation** → add to Playwright card renderer (4 divs in a grid)
3. **Metallic effect** → always apply metallic class to filled diamonds; apply glow animation (`diamond-glow` class) for T4 only
4. **T3 shimmer** → APNG capture with `--anim-progress` variable (8 frames)
5. **T4 layered effects** → APNG capture with `--anim-progress` driving all 4 layers
6. **Diamond colors** → store in tier config or derive from tier level
7. **Corner accents** → T4 only, simple border divs
### What NOT to port
- The mockup control panel UI (sliders, dropdowns, color pickers)
- The `diamondEffect` dropdown with 5 options (we chose metallic — hardcode it)
- The separate `diamondGlow` toggle (hardcode glow ON for T4, OFF for T1T3)
- Border preset / header type controls (these are already in production tier configs)
- T4b full-card rainbow (unless explicitly promoted later)
### Database/API considerations
The diamond fill count is already derivable from the tier level — no new database fields needed:
- `refractor_tier = 1``diamondFill = 1`, color = green
- `refractor_tier = 2``diamondFill = 2`, color = blue
- `refractor_tier = 3``diamondFill = 3`, color = red
- `refractor_tier = 4``diamondFill = 4`, color = purple
Diamond colors are purely visual (CSS) — they don't need to be stored.

View File

@ -5,7 +5,6 @@ Commands for uploading card images to AWS S3.
""" """
import asyncio import asyncio
import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@ -41,27 +40,14 @@ def s3(
dry_run: bool = typer.Option( dry_run: bool = typer.Option(
False, "--dry-run", "-n", help="Preview without uploading" False, "--dry-run", "-n", help="Preview without uploading"
), ),
concurrency: int = typer.Option(
8, "--concurrency", "-j", help="Number of parallel uploads (default: 8)"
),
api_url: str = typer.Option(
"https://pd.manticorum.com/api",
"--api-url",
help="API base URL for card rendering (use http://localhost:8000/api for local server)",
),
): ):
""" """
Upload card images to AWS S3. Upload card images to AWS S3.
Fetches card images from Paper Dynasty API and uploads to S3 bucket. Fetches card images from Paper Dynasty API and uploads to S3 bucket.
Cards are processed concurrently; use --concurrency to tune parallelism.
For high-concurrency local rendering, start the API server locally and use:
pd-cards upload s3 --cardset "2005 Live" --api-url http://localhost:8000/api --concurrency 32
Example: Example:
pd-cards upload s3 --cardset "2005 Live" --limit 10 pd-cards upload s3 --cardset "2005 Live" --limit 10
pd-cards upload s3 --cardset "2005 Live" --concurrency 16
""" """
console.print() console.print()
console.print("=" * 70) console.print("=" * 70)
@ -79,10 +65,8 @@ def s3(
console.print("Skipping: Batting cards") console.print("Skipping: Batting cards")
if skip_pitchers: if skip_pitchers:
console.print("Skipping: Pitching cards") console.print("Skipping: Pitching cards")
console.print(f"API URL: {api_url}")
console.print(f"Upload to S3: {upload and not dry_run}") console.print(f"Upload to S3: {upload and not dry_run}")
console.print(f"Update URLs: {update_urls and not dry_run}") console.print(f"Update URLs: {update_urls and not dry_run}")
console.print(f"Concurrency: {concurrency} parallel tasks")
console.print() console.print()
if dry_run: if dry_run:
@ -92,53 +76,39 @@ def s3(
raise typer.Exit(0) raise typer.Exit(0)
try: try:
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from pd_cards.core.upload import upload_cards_to_s3 import check_cards_and_upload as ccu
def progress_callback(_count: int, label: str) -> None: # Configure the module's globals
console.print(f" Progress: {label}") ccu.CARDSET_NAME = cardset
ccu.START_ID = start_id
ccu.TEST_COUNT = limit if limit else 9999
ccu.HTML_CARDS = html
ccu.SKIP_BATS = skip_batters
ccu.SKIP_ARMS = skip_pitchers
ccu.UPLOAD_TO_S3 = upload
ccu.UPDATE_PLAYER_URLS = update_urls
# Re-initialize S3 client if uploading
if upload:
import boto3
ccu.s3_client = boto3.client("s3", region_name=ccu.AWS_REGION)
else:
ccu.s3_client = None
console.print("[bold]Starting S3 upload...[/bold]") console.print("[bold]Starting S3 upload...[/bold]")
console.print() console.print()
result = asyncio.run( asyncio.run(ccu.main([]))
upload_cards_to_s3(
cardset_name=cardset,
start_id=start_id,
limit=limit,
html_cards=html,
skip_batters=skip_batters,
skip_pitchers=skip_pitchers,
upload=upload,
update_urls=update_urls,
on_progress=progress_callback,
concurrency=concurrency,
api_url=api_url,
)
)
success_count = len(result["successes"])
error_count = len(result["errors"])
upload_count = len(result["uploads"])
url_update_count = len(result["url_updates"])
console.print() console.print()
console.print("=" * 70) console.print("=" * 70)
console.print("[bold green]✓ S3 UPLOAD COMPLETE[/bold green]") console.print("[bold green]✓ S3 UPLOAD COMPLETE[/bold green]")
console.print("=" * 70) console.print("=" * 70)
console.print(f" Successes: {success_count}")
console.print(f" S3 uploads: {upload_count}")
console.print(f" URL updates: {url_update_count}")
if error_count:
console.print(f" [red]Errors: {error_count}[/red]")
for player, err in result["errors"][:10]:
console.print(
f" - player {player.get('player_id', '?')} "
f"({player.get('p_name', '?')}): {err}"
)
if error_count > 10:
console.print(f" ... and {error_count - 10} more (see logs)")
except ImportError as e: except ImportError as e:
console.print(f"[red]Error importing modules: {e}[/red]") console.print(f"[red]Error importing modules: {e}[/red]")

View File

@ -4,7 +4,6 @@ Card image upload and management core logic.
Business logic for uploading card images to AWS S3 and managing card URLs. Business logic for uploading card images to AWS S3 and managing card URLs.
""" """
import asyncio
import datetime import datetime
from typing import Optional from typing import Optional
import urllib.parse import urllib.parse
@ -26,7 +25,7 @@ def get_s3_base_url(
return f"https://{bucket}.s3.{region}.amazonaws.com" return f"https://{bucket}.s3.{region}.amazonaws.com"
async def fetch_card_image(session, card_url: str, timeout: int = 10) -> bytes: async def fetch_card_image(session, card_url: str, timeout: int = 6) -> bytes:
""" """
Fetch card image from URL and return raw bytes. Fetch card image from URL and return raw bytes.
@ -107,9 +106,6 @@ def upload_card_to_s3(
raise raise
DEFAULT_PD_API_URL = "https://pd.manticorum.com/api"
async def upload_cards_to_s3( async def upload_cards_to_s3(
cardset_name: str, cardset_name: str,
start_id: Optional[int] = None, start_id: Optional[int] = None,
@ -122,18 +118,9 @@ async def upload_cards_to_s3(
bucket: str = DEFAULT_AWS_BUCKET, bucket: str = DEFAULT_AWS_BUCKET,
region: str = DEFAULT_AWS_REGION, region: str = DEFAULT_AWS_REGION,
on_progress: callable = None, on_progress: callable = None,
concurrency: int = 8,
api_url: str = DEFAULT_PD_API_URL,
) -> dict: ) -> dict:
""" """
Upload card images to S3 for a cardset using concurrent async tasks. Upload card images to S3 for a cardset.
Cards are fetched and uploaded in parallel, bounded by ``concurrency``
semaphore slots. boto3 S3 calls (synchronous) are offloaded to a thread
pool via ``loop.run_in_executor`` so they do not block the event loop.
Individual card failures are collected and do NOT abort the batch;
a summary is logged once all tasks complete.
Args: Args:
cardset_name: Name of the cardset to process cardset_name: Name of the cardset to process
@ -147,7 +134,6 @@ async def upload_cards_to_s3(
bucket: S3 bucket name bucket: S3 bucket name
region: AWS region region: AWS region
on_progress: Callback function for progress updates on_progress: Callback function for progress updates
concurrency: Number of parallel card-processing tasks (default 8)
Returns: Returns:
Dict with counts of errors, successes, uploads, url_updates Dict with counts of errors, successes, uploads, url_updates
@ -179,225 +165,163 @@ async def upload_cards_to_s3(
timestamp = int(now.timestamp()) timestamp = int(now.timestamp())
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}" release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
# PD API base URL for card generation (configurable for local rendering) # PD API base URL for card generation
PD_API_URL = api_url PD_API_URL = "https://pd.manticorum.com/api"
logger.info(f"Using API URL: {PD_API_URL}")
# Initialize S3 client if uploading (boto3 client is thread-safe for reads; # Initialize S3 client if uploading
# we will call it from a thread pool so we create it once here)
s3_client = boto3.client("s3", region_name=region) if upload else None s3_client = boto3.client("s3", region_name=region) if upload else None
# Build the filtered list of players to process, respecting start_id / limit
max_count = limit or 9999
filtered_players = []
for x in all_players:
if len(filtered_players) >= max_count:
break
if "pitching" in x["image"] and skip_pitchers:
continue
if "batting" in x["image"] and skip_batters:
continue
if start_id is not None and start_id > x["player_id"]:
continue
filtered_players.append(x)
total = len(filtered_players)
logger.info(f"Processing {total} cards with concurrency={concurrency}")
# Shared mutable state protected by a lock
errors = [] errors = []
successes = [] successes = []
uploads = [] uploads = []
url_updates = [] url_updates = []
completed = 0 cxn_error = False
progress_lock = asyncio.Lock() count = 0
results_lock = asyncio.Lock() max_count = limit or 9999
loop = asyncio.get_running_loop() async with aiohttp.ClientSession() as session:
semaphore = asyncio.Semaphore(concurrency) for x in all_players:
# Apply filters
async def report_progress(): if "pitching" in x["image"] and skip_pitchers:
"""Increment the completed counter and log every 20 completions.""" continue
nonlocal completed if "batting" in x["image"] and skip_batters:
async with progress_lock: continue
completed += 1 if start_id is not None and start_id > x["player_id"]:
if completed % 20 == 0 or completed == total: continue
logger.info(f"Progress: {completed}/{total} cards processed")
if on_progress:
on_progress(completed, f"{completed}/{total}")
async def process_single_card(x: dict) -> None:
"""
Process one player entry: fetch card image(s), upload to S3, and
optionally patch the player record with the new S3 URL.
Both the primary card (image) and the secondary card for two-way
players (image2) are handled here. Errors are appended to the
shared ``errors`` list rather than re-raised so the batch continues.
"""
async with semaphore:
player_id = x["player_id"]
# --- primary card ---
if "sombaseball" in x["image"]: if "sombaseball" in x["image"]:
async with results_lock: errors.append((x, f"Bad card url: {x['image']}"))
errors.append((x, f"Bad card url: {x['image']}")) continue
await report_progress() if count >= max_count:
return break
count += 1
if on_progress and count % 20 == 0:
on_progress(count, x["p_name"])
# Determine card type from existing image URL
card_type = "pitching" if "pitching" in x["image"] else "batting" card_type = "pitching" if "pitching" in x["image"] else "batting"
pd_card_url = (
f"{PD_API_URL}/v2/players/{player_id}/{card_type}card?d={release_date}" # Generate card URL from PD API (forces fresh generation from database)
) pd_card_url = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type}card?d={release_date}"
if html_cards: if html_cards:
card_url = f"{pd_card_url}&html=true" card_url = f"{pd_card_url}&html=true"
timeout = 2 timeout = 2
else: else:
card_url = pd_card_url card_url = pd_card_url
timeout = 10 timeout = 6
primary_ok = False
try: try:
if upload and not html_cards: if upload and not html_cards:
# Fetch card image bytes directly
image_bytes = await fetch_card_image( image_bytes = await fetch_card_image(
session, card_url, timeout=timeout session, card_url, timeout=timeout
) )
# boto3 is synchronous — offload to thread pool s3_url = upload_card_to_s3(
s3_url = await loop.run_in_executor(
None,
upload_card_to_s3,
s3_client, s3_client,
image_bytes, image_bytes,
player_id, x["player_id"],
card_type, card_type,
release_date, release_date,
cardset["id"], cardset["id"],
bucket, bucket,
region, region,
) )
async with results_lock: uploads.append((x["player_id"], card_type, s3_url))
uploads.append((player_id, card_type, s3_url))
# Update player record with new S3 URL
if update_urls: if update_urls:
await db_patch( await db_patch(
"players", "players",
object_id=player_id, object_id=x["player_id"],
params=[("image", s3_url)], params=[("image", s3_url)],
) )
async with results_lock: url_updates.append((x["player_id"], card_type, s3_url))
url_updates.append((player_id, card_type, s3_url)) logger.info(f"Updated player {x['player_id']} image URL to S3")
logger.info(f"Updated player {player_id} image URL to S3")
else: else:
# Just validate card exists
logger.info(f"Validating card URL: {card_url}") logger.info(f"Validating card URL: {card_url}")
await url_get(card_url, timeout=timeout) await url_get(card_url, timeout=timeout)
primary_ok = True
except ConnectionError as e: except ConnectionError as e:
logger.error(f"Connection error for player {player_id}: {e}") if cxn_error:
async with results_lock: raise e
errors.append((x, e)) cxn_error = True
errors.append((x, e))
except ValueError as e: except ValueError as e:
async with results_lock: errors.append((x, e))
errors.append((x, e))
except Exception as e: except Exception as e:
logger.error(f"S3 upload/update failed for player {player_id}: {e}") logger.error(
async with results_lock: f"S3 upload/update failed for player {x['player_id']}: {e}"
errors.append((x, f"S3 error: {e}")) )
errors.append((x, f"S3 error: {e}"))
continue
if not primary_ok: # Handle image2 (dual-position players)
await report_progress()
return
# --- secondary card (two-way players) ---
if x["image2"] is not None: if x["image2"] is not None:
if "sombaseball" in x["image2"]:
async with results_lock:
errors.append((x, f"Bad card url: {x['image2']}"))
await report_progress()
return
card_type2 = "pitching" if "pitching" in x["image2"] else "batting" card_type2 = "pitching" if "pitching" in x["image2"] else "batting"
pd_card_url2 = f"{PD_API_URL}/v2/players/{player_id}/{card_type2}card?d={release_date}" pd_card_url2 = f"{PD_API_URL}/v2/players/{x['player_id']}/{card_type2}card?d={release_date}"
card_url2 = f"{pd_card_url2}&html=true" if html_cards else pd_card_url2
try: if html_cards:
if upload and not html_cards: card_url2 = f"{pd_card_url2}&html=true"
image_bytes2 = await fetch_card_image( else:
session, card_url2, timeout=10 card_url2 = pd_card_url2
)
s3_url2 = await loop.run_in_executor(
None,
upload_card_to_s3,
s3_client,
image_bytes2,
player_id,
card_type2,
release_date,
cardset["id"],
bucket,
region,
)
async with results_lock:
uploads.append((player_id, card_type2, s3_url2))
if update_urls: if "sombaseball" in x["image2"]:
await db_patch( errors.append((x, f"Bad card url: {x['image2']}"))
"players", else:
object_id=player_id, try:
params=[("image2", s3_url2)], if upload and not html_cards:
image_bytes2 = await fetch_card_image(
session, card_url2, timeout=6
) )
async with results_lock: s3_url2 = upload_card_to_s3(
url_updates.append((player_id, card_type2, s3_url2)) s3_client,
logger.info(f"Updated player {player_id} image2 URL to S3") image_bytes2,
else: x["player_id"],
await url_get(card_url2, timeout=10) card_type2,
release_date,
cardset["id"],
bucket,
region,
)
uploads.append((x["player_id"], card_type2, s3_url2))
if update_urls:
await db_patch(
"players",
object_id=x["player_id"],
params=[("image2", s3_url2)],
)
url_updates.append(
(x["player_id"], card_type2, s3_url2)
)
logger.info(
f"Updated player {x['player_id']} image2 URL to S3"
)
else:
await url_get(card_url2, timeout=6)
async with results_lock:
successes.append(x) successes.append(x)
except ConnectionError as e: except ConnectionError as e:
logger.error(f"Connection error for player {player_id} image2: {e}") if cxn_error:
async with results_lock: raise e
cxn_error = True
errors.append((x, e)) errors.append((x, e))
except ValueError as e: except ValueError as e:
async with results_lock:
errors.append((x, e)) errors.append((x, e))
except Exception as e: except Exception as e:
logger.error( logger.error(
f"S3 upload/update failed for player {player_id} image2: {e}" f"S3 upload/update failed for player {x['player_id']} image2: {e}"
) )
async with results_lock:
errors.append((x, f"S3 error (image2): {e}")) errors.append((x, f"S3 error (image2): {e}"))
else: else:
async with results_lock: successes.append(x)
successes.append(x)
await report_progress()
async with aiohttp.ClientSession() as session:
tasks = [process_single_card(x) for x in filtered_players]
await asyncio.gather(*tasks, return_exceptions=True)
# Log final summary
success_count = len(successes)
error_count = len(errors)
logger.info(
f"Upload complete: {success_count} succeeded, {error_count} failed "
f"out of {total} cards"
)
if error_count:
for player, err in errors:
logger.warning(
f" Failed: player {player.get('player_id', '?')} "
f"({player.get('p_name', '?')}): {err}"
)
return { return {
"errors": errors, "errors": errors,
@ -414,7 +338,6 @@ async def refresh_card_images(
limit: Optional[int] = None, limit: Optional[int] = None,
html_cards: bool = False, html_cards: bool = False,
on_progress: callable = None, on_progress: callable = None,
api_url: str = DEFAULT_PD_API_URL,
) -> dict: ) -> dict:
""" """
Refresh card images for a cardset by triggering regeneration. Refresh card images for a cardset by triggering regeneration.
@ -434,7 +357,7 @@ async def refresh_card_images(
raise ValueError(f'Cardset "{cardset_name}" not found') raise ValueError(f'Cardset "{cardset_name}" not found')
cardset = c_query["cardsets"][0] cardset = c_query["cardsets"][0]
CARD_BASE_URL = f"{api_url}/v2/players" CARD_BASE_URL = "https://pd.manticorum.com/api/v2/players"
# Get all players # Get all players
p_query = await db_get( p_query = await db_get(
@ -547,10 +470,7 @@ async def refresh_card_images(
async def check_card_images( async def check_card_images(
cardset_name: str, cardset_name: str, limit: Optional[int] = None, on_progress: callable = None
limit: Optional[int] = None,
on_progress: callable = None,
api_url: str = DEFAULT_PD_API_URL,
) -> dict: ) -> dict:
""" """
Check and validate card images without uploading. Check and validate card images without uploading.
@ -586,7 +506,7 @@ async def check_card_images(
now = datetime.datetime.now() now = datetime.datetime.now()
timestamp = int(now.timestamp()) timestamp = int(now.timestamp())
release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}" release_date = f"{now.year}-{now.month}-{now.day}-{timestamp}"
PD_API_URL = api_url PD_API_URL = "https://pd.manticorum.com/api"
errors = [] errors = []
successes = [] successes = []

View File

@ -1,7 +1,7 @@
import datetime import datetime
import urllib.parse import urllib.parse
import pandas as pd import pandas as pd
from typing import Any, Dict from typing import Dict
from creation_helpers import ( from creation_helpers import (
get_all_pybaseball_ids, get_all_pybaseball_ids,
@ -196,8 +196,8 @@ async def create_new_players(
{ {
"p_name": f"{f_name} {l_name}", "p_name": f"{f_name} {l_name}",
"cost": NEW_PLAYER_COST, "cost": NEW_PLAYER_COST,
"image": f"{card_base_url}/{df_data['player_id']}/" "image": f'{card_base_url}/{df_data["player_id"]}/'
f"pitchingcard{urllib.parse.quote('?d=')}{release_dir}", f'pitchingcard{urllib.parse.quote("?d=")}{release_dir}',
"mlbclub": CLUB_LIST[df_data["Tm_vL"]], "mlbclub": CLUB_LIST[df_data["Tm_vL"]],
"franchise": FRANCHISE_LIST[df_data["Tm_vL"]], "franchise": FRANCHISE_LIST[df_data["Tm_vL"]],
"cardset_id": cardset["id"], "cardset_id": cardset["id"],
@ -268,7 +268,7 @@ async def calculate_pitching_cards(
def create_pitching_card(df_data): def create_pitching_card(df_data):
logger.info( logger.info(
f"Creating pitching card for {df_data['name_first']} {df_data['name_last']} / fg ID: {df_data['key_fangraphs']}" f'Creating pitching card for {df_data["name_first"]} {df_data["name_last"]} / fg ID: {df_data["key_fangraphs"]}'
) )
pow_data = cde.pow_ratings( pow_data = cde.pow_ratings(
float(df_data["Inn_def"]), df_data["GS"], df_data["G"] float(df_data["Inn_def"]), df_data["GS"], df_data["G"]
@ -298,13 +298,11 @@ async def calculate_pitching_cards(
int(df_data["GF"]), int(df_data["SV"]), int(df_data["G"]) int(df_data["GF"]), int(df_data["SV"]), int(df_data["G"])
), ),
"hand": df_data["pitch_hand"], "hand": df_data["pitch_hand"],
"batting": f"#1W{df_data['pitch_hand']}-C", "batting": f'#1W{df_data["pitch_hand"]}-C',
} }
) )
except Exception: except Exception as e:
logger.exception( logger.error(f'Skipping fg ID {df_data["key_fangraphs"]} due to: {e}')
f"Skipping fg ID {df_data['key_fangraphs']} due to exception"
)
print("Calculating pitching cards...") print("Calculating pitching cards...")
pitching_stats.apply(create_pitching_card, axis=1) pitching_stats.apply(create_pitching_card, axis=1)
@ -335,7 +333,7 @@ async def create_position(
def create_pit_position(df_data): def create_pit_position(df_data):
if df_data["key_bbref"] in df_p.index: if df_data["key_bbref"] in df_p.index:
logger.debug(f"Running P stats for {df_data['p_name']}") logger.debug(f'Running P stats for {df_data["p_name"]}')
pit_positions.append( pit_positions.append(
{ {
"player_id": int(df_data["player_id"]), "player_id": int(df_data["player_id"]),
@ -357,7 +355,7 @@ async def create_position(
try: try:
pit_positions.append( pit_positions.append(
{ {
"player_id": int(float(df_data["player_id"])), "player_id": int(df_data["key_bbref"]),
"position": "P", "position": "P",
"innings": 1, "innings": 1,
"range": 5, "range": 5,
@ -366,7 +364,7 @@ async def create_position(
) )
except Exception: except Exception:
logger.error( logger.error(
f"Could not create pitcher position for {df_data['key_bbref']}" f'Could not create pitcher position for {df_data["key_bbref"]}'
) )
print("Calculating pitcher fielding lines now...") print("Calculating pitcher fielding lines now...")
@ -388,7 +386,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers:
pitching_ratings.extend(cpi.get_pitcher_ratings(df_data)) pitching_ratings.extend(cpi.get_pitcher_ratings(df_data))
except Exception: except Exception:
logger.error( logger.error(
f"Could not create a pitching card for {df_data['key_fangraphs']}" f'Could not create a pitching card for {df_data["key_fangraphs"]}'
) )
print("Calculating card ratings...") print("Calculating card ratings...")
@ -402,7 +400,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers:
async def post_player_updates( async def post_player_updates(
cardset: Dict[str, Any], cardset: Dict[str, any],
player_description: str, player_description: str,
card_base_url: str, card_base_url: str,
release_dir: str, release_dir: str,
@ -527,8 +525,8 @@ async def post_player_updates(
[ [
( (
"image", "image",
f"{card_base_url}/{df_data['player_id']}/pitchingcard" f'{card_base_url}/{df_data["player_id"]}/pitchingcard'
f"{urllib.parse.quote('?d=')}{release_dir}", f'{urllib.parse.quote("?d=")}{release_dir}',
) )
] ]
) )

View File

@ -23,8 +23,6 @@ dependencies = [
"pydantic>=2.9.0", "pydantic>=2.9.0",
# AWS # AWS
"boto3>=1.35.0", "boto3>=1.35.0",
# Environment
"python-dotenv>=1.0.0",
# Scraping # Scraping
"beautifulsoup4>=4.12.0", "beautifulsoup4>=4.12.0",
"lxml>=5.0.0", "lxml>=5.0.0",

View File

@ -23,9 +23,9 @@ multidict==6.1.0
numpy==2.1.2 numpy==2.1.2
packaging==24.1 packaging==24.1
pandas==2.2.3 pandas==2.2.3
peewee==3.19.0 peewee
pillow==11.0.0 pillow==11.0.0
polars==1.36.1 polars
pluggy==1.5.0 pluggy==1.5.0
propcache==0.2.0 propcache==0.2.0
# pyarrow==17.0.0 # pyarrow==17.0.0

View File

@ -53,30 +53,21 @@ PROMO_INCLUSION_RETRO_IDS = [
# 'haraa001', # Aaron Harang (SP) # 'haraa001', # Aaron Harang (SP)
# 'hofft001', # Trevor Hoffman (RP) # 'hofft001', # Trevor Hoffman (RP)
] ]
MIN_PA_VL = 20 # 1 for PotM MIN_PA_VL = 20 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM
MIN_PA_VR = 40 # 1 for PotM MIN_PA_VR = 40 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM
MIN_TBF_VL = 20 MIN_TBF_VL = MIN_PA_VL
MIN_TBF_VR = 40 MIN_TBF_VR = MIN_PA_VR
CARDSET_ID = 27 # 27: 2005 Live, 28: 2005 Promos CARDSET_ID = (
27 if "live" in PLAYER_DESCRIPTION.lower() else 28
) # 27: 2005 Live, 28: 2005 Promos
# Per-Update Parameters # Per-Update Parameters
SEASON_PCT = 81 / 162 # Through end of July (~half season)
START_DATE = 20050403 # YYYYMMDD format - 2005 Opening Day START_DATE = 20050403 # YYYYMMDD format - 2005 Opening Day
# END_DATE = 20050531 # YYYYMMDD format - May PotM # END_DATE = 20050531 # YYYYMMDD format - May PotM
END_DATE = 20050731 # End of July 2005 END_DATE = 20050731 # End of July 2005
SEASON_END_DATE = 20051002 # 2005 regular season end date (used to derive SEASON_PCT)
SEASON_PCT = min(
(
datetime.datetime.strptime(str(END_DATE), "%Y%m%d")
- datetime.datetime.strptime(str(START_DATE), "%Y%m%d")
).days
/ (
datetime.datetime.strptime(str(SEASON_END_DATE), "%Y%m%d")
- datetime.datetime.strptime(str(START_DATE), "%Y%m%d")
).days,
1.0,
)
POST_DATA = True POST_DATA = True
LAST_WEEK_RATIO = 0.0 LAST_WEEK_RATIO = 0.0 if PLAYER_DESCRIPTION == "Live" else 0.0
LAST_TWOWEEKS_RATIO = 0.0 LAST_TWOWEEKS_RATIO = 0.0
LAST_MONTH_RATIO = 0.0 LAST_MONTH_RATIO = 0.0
@ -1438,7 +1429,7 @@ def calc_pitching_cards(ps: pd.DataFrame, season_pct: float) -> pd.DataFrame:
"closer_rating": [ "closer_rating": [
cpi.closer_rating(int(row["GF"]), int(row["SV"]), int(row["G"])) cpi.closer_rating(int(row["GF"]), int(row["SV"]), int(row["G"]))
], ],
"batting": [f"#1W{row['pitch_hand'].upper()}-C"], "batting": [f'#1W{row["pitch_hand"].upper()}-C'],
} }
) )
return y.loc[0] return y.loc[0]
@ -1607,7 +1598,7 @@ def calc_positions(bs: pd.DataFrame) -> pd.DataFrame:
]: ]:
if row["key_bbref"] in pos_df.index: if row["key_bbref"] in pos_df.index:
logger.info( logger.info(
f"Running {position} stats for {row['use_name']} {row['last_name']}" f'Running {position} stats for {row["use_name"]} {row["last_name"]}'
) )
try: try:
if "bis_runs_total" in pos_df.columns: if "bis_runs_total" in pos_df.columns:
@ -1874,8 +1865,8 @@ async def get_or_post_players(
def new_player_payload(row, ratings_df: pd.DataFrame): def new_player_payload(row, ratings_df: pd.DataFrame):
return { return {
"p_name": f"{row['use_name']} {row['last_name']}", "p_name": f'{row["use_name"]} {row["last_name"]}',
"cost": f"{ratings_df.loc[row['key_bbref']]['cost']}", "cost": f'{ratings_df.loc[row['key_bbref']]["cost"]}',
"image": "change-me", "image": "change-me",
"mlbclub": CLUB_LIST[row["Tm"]], "mlbclub": CLUB_LIST[row["Tm"]],
"franchise": FRANCHISE_LIST[row["Tm"]], "franchise": FRANCHISE_LIST[row["Tm"]],
@ -1925,11 +1916,11 @@ async def get_or_post_players(
# Update positions for existing players too # Update positions for existing players too
all_pos = get_player_record_pos(def_rat_df, row) all_pos = get_player_record_pos(def_rat_df, row)
patch_params = [ patch_params = [
("cost", f"{bat_rat_df.loc[row['key_bbref']]['cost']}"), ("cost", f'{bat_rat_df.loc[row['key_bbref']]["cost"]}'),
("rarity_id", int(bat_rat_df.loc[row["key_bbref"]]["rarity_id"])), ("rarity_id", int(bat_rat_df.loc[row["key_bbref"]]["rarity_id"])),
( (
"image", "image",
f"{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", f'{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
), ),
] ]
# Add position updates - set all 8 slots to clear any old positions # Add position updates - set all 8 slots to clear any old positions
@ -1973,7 +1964,7 @@ async def get_or_post_players(
params=[ params=[
( (
"image", "image",
f"{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", f'{CARD_BASE_URL}{player_id}/battingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
) )
], ],
) )
@ -2012,11 +2003,11 @@ async def get_or_post_players(
# Determine pitcher positions based on ratings # Determine pitcher positions based on ratings
patch_params = [ patch_params = [
("cost", f"{pit_rat_df.loc[row['key_bbref']]['cost']}"), ("cost", f'{pit_rat_df.loc[row['key_bbref']]["cost"]}'),
("rarity_id", int(pit_rat_df.loc[row["key_bbref"]]["rarity_id"])), ("rarity_id", int(pit_rat_df.loc[row["key_bbref"]]["rarity_id"])),
( (
"image", "image",
f"{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", f'{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
), ),
] ]
@ -2090,7 +2081,7 @@ async def get_or_post_players(
params=[ params=[
( (
"image", "image",
f"{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote('?d=')}{RELEASE_DIRECTORY}", f'{CARD_BASE_URL}{player_id}/pitchingcard{urllib.parse.quote("?d=")}{RELEASE_DIRECTORY}',
) )
], ],
) )
@ -2114,10 +2105,10 @@ async def get_or_post_players(
raise KeyError("Could not get players - not enough stat DFs were supplied") raise KeyError("Could not get players - not enough stat DFs were supplied")
pd.DataFrame(player_deltas[1:], columns=player_deltas[0]).to_csv( pd.DataFrame(player_deltas[1:], columns=player_deltas[0]).to_csv(
f"{'batter' if bstat_df is not None else 'pitcher'}-deltas.csv" f'{"batter" if bstat_df is not None else "pitcher"}-deltas.csv'
) )
pd.DataFrame(new_players[1:], columns=new_players[0]).to_csv( pd.DataFrame(new_players[1:], columns=new_players[0]).to_csv(
f"new-{'batter' if bstat_df is not None else 'pitcher'}s.csv" f'new-{"batter" if bstat_df is not None else "pitcher"}s.csv'
) )
players_df = pd.DataFrame(all_players).set_index("bbref_id") players_df = pd.DataFrame(all_players).set_index("bbref_id")
@ -2289,7 +2280,7 @@ async def post_positions(pos_df: pd.DataFrame, delete_existing: bool = False):
deleted_count += 1 deleted_count += 1
except Exception as e: except Exception as e:
logger.warning( logger.warning(
f"Failed to delete cardposition {pos['id']}: {e}" f'Failed to delete cardposition {pos["id"]}: {e}'
) )
logger.info(f"Deleted {deleted_count} positions for players in current run") logger.info(f"Deleted {deleted_count} positions for players in current run")

View File

@ -96,7 +96,7 @@ def build_c_throw(all_positions, pos_code):
async def fetch_data(data): async def fetch_data(data):
start_time = log_time("start", print_to_console=False) start_time = log_time("start", print_to_console=False)
this_query = await db_get(endpoint=data[0], params=data[1], timeout=120) this_query = await db_get(endpoint=data[0], params=data[1])
log_time("end", print_to_console=False, start_time=start_time) log_time("end", print_to_console=False, start_time=start_time)
return this_query return this_query

View File

@ -1,290 +0,0 @@
#!/bin/bash
# =============================================================================
# WP-00: Paper Dynasty Card Render & Upload Pipeline Benchmark
# Phase 0 - Render Pipeline Optimization
#
# Usage:
# ./scripts/benchmark_render.sh # Run full benchmark (dev API)
# ./scripts/benchmark_render.sh --prod # Run against production API
# ./scripts/benchmark_render.sh --quick # Connectivity check only
#
# Requirements: curl, bc
# =============================================================================
# --- Configuration -----------------------------------------------------------
DEV_API="https://pddev.manticorum.com/api"
PROD_API="https://pd.manticorum.com/api"
API_URL="$DEV_API"
# Player IDs in the 12000-13000 range (2005 Live cardset)
# Mix of batters and pitchers across different teams
PLAYER_IDS=(12785 12790 12800 12810 12820 12830 12840 12850 12860 12870)
RESULTS_FILE="$(dirname "$0")/benchmark_results.txt"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
RUN_LABEL="benchmark-$(date +%s)"
# --- Argument parsing ---------------------------------------------------------
QUICK_MODE=false
for arg in "$@"; do
case "$arg" in
--prod) API_URL="$PROD_API" ;;
--quick) QUICK_MODE=true ;;
--help|-h)
echo "Usage: $0 [--prod] [--quick]"
echo " --prod Use production API instead of dev"
echo " --quick Connectivity check only (1 request)"
exit 0
;;
esac
done
# --- Helpers -----------------------------------------------------------------
hr() { printf '%0.s-' {1..72}; echo; }
# bc-based float arithmetic
fadd() { echo "$1 + $2" | bc -l; }
fdiv() { echo "scale=6; $1 / $2" | bc -l; }
flt() { echo "$1 < $2" | bc -l; } # returns 1 if true
fmt3() { printf "%.3f" "$1"; } # format to 3 decimal places
# Print and simultaneously append to results file
log() { echo "$@" | tee -a "$RESULTS_FILE"; }
# Single card render with timing; sets LAST_HTTP, LAST_TIME, LAST_SIZE
measure_card() {
local player_id="$1"
local card_type="${2:-batting}"
local cache_bust="${RUN_LABEL}-${player_id}"
local url="${API_URL}/v2/players/${player_id}/${card_type}card?d=${cache_bust}"
# -s silent, -o discard body, -w write timing vars separated by |
local result
result=$(curl -s -o /dev/null \
-w "%{http_code}|%{time_total}|%{time_connect}|%{time_starttransfer}|%{size_download}" \
--max-time 30 \
"$url" 2>&1)
LAST_HTTP=$(echo "$result" | cut -d'|' -f1)
LAST_TIME=$(echo "$result" | cut -d'|' -f2)
LAST_CONN=$(echo "$result" | cut -d'|' -f3)
LAST_TTFB=$(echo "$result" | cut -d'|' -f4)
LAST_SIZE=$(echo "$result" | cut -d'|' -f5)
LAST_URL="$url"
}
# =============================================================================
# START
# =============================================================================
# Truncate results file for this run and write header
cat > "$RESULTS_FILE" << EOF
Paper Dynasty Card Render Benchmark
Run timestamp : $TIMESTAMP
API target : $API_URL
Cache-bust tag: $RUN_LABEL
EOF
echo "" >> "$RESULTS_FILE"
echo ""
log "=============================================================="
log " Paper Dynasty Card Render Benchmark - WP-00 / Phase 0"
log " $(date '+%Y-%m-%d %H:%M:%S')"
log " API: $API_URL"
log "=============================================================="
echo ""
# =============================================================================
# SECTION 1: Connectivity Check
# =============================================================================
log "--- Section 1: Connectivity Check ---"
log ""
log "Sending single request to verify API is reachable..."
log " Player : 12785 (batting card)"
log " URL : ${API_URL}/v2/players/12785/battingcard?d=${RUN_LABEL}-probe"
echo ""
measure_card 12785 batting
if [ "$LAST_HTTP" = "200" ]; then
log " HTTP : $LAST_HTTP OK"
log " Total : $(fmt3 $LAST_TIME)s"
log " Connect: $(fmt3 $LAST_CONN)s"
log " TTFB : $(fmt3 $LAST_TTFB)s"
log " Size : ${LAST_SIZE} bytes ($(echo "scale=1; $LAST_SIZE/1024" | bc)KB)"
log ""
log " Connectivity: PASS"
elif [ -z "$LAST_HTTP" ] || [ "$LAST_HTTP" = "000" ]; then
log " ERROR: Could not reach $API_URL (no response / timeout)"
log " Aborting benchmark."
echo ""
exit 1
else
log " HTTP : $LAST_HTTP"
log " WARNING: Unexpected status code. Continuing anyway."
fi
echo ""
if [ "$QUICK_MODE" = true ]; then
log "Quick mode: exiting after connectivity check."
echo ""
exit 0
fi
# =============================================================================
# SECTION 2: Sequential Card Render Benchmark (10 cards)
# =============================================================================
log ""
hr
log "--- Section 2: Sequential Card Render Benchmark ---"
log ""
log "Rendering ${#PLAYER_IDS[@]} cards sequentially with fresh cache busts."
log "Each request forces a full server-side render (bypasses nginx cache)."
log ""
log "$(printf '%-8s %-10s %-10s %-10s %-10s %-8s' 'Player' 'HTTP' 'Total(s)' 'TTFB(s)' 'Connect(s)' 'Size(KB)')"
log "$(printf '%0.s-' {1..62})"
# Accumulators
total_time="0"
min_time=""
max_time=""
success_count=0
fail_count=0
all_times=()
for pid in "${PLAYER_IDS[@]}"; do
measure_card "$pid" batting
size_kb=$(echo "scale=1; $LAST_SIZE/1024" | bc)
row=$(printf '%-8s %-10s %-10s %-10s %-10s %-8s' \
"$pid" \
"$LAST_HTTP" \
"$(fmt3 $LAST_TIME)" \
"$(fmt3 $LAST_TTFB)" \
"$(fmt3 $LAST_CONN)" \
"$size_kb")
if [ "$LAST_HTTP" = "200" ]; then
log "$row"
total_time=$(fadd "$total_time" "$LAST_TIME")
all_times+=("$LAST_TIME")
success_count=$((success_count + 1))
# Track min
if [ -z "$min_time" ] || [ "$(flt $LAST_TIME $min_time)" = "1" ]; then
min_time="$LAST_TIME"
fi
# Track max
if [ -z "$max_time" ] || [ "$(flt $max_time $LAST_TIME)" = "1" ]; then
max_time="$LAST_TIME"
fi
else
log "$row << FAILED"
fail_count=$((fail_count + 1))
fi
done
echo ""
log ""
log "--- Section 2: Results Summary ---"
log ""
if [ "$success_count" -gt 0 ]; then
avg_time=$(fdiv "$total_time" "$success_count")
log " Cards requested : ${#PLAYER_IDS[@]}"
log " Successful : $success_count"
log " Failed : $fail_count"
log " Total wall time : $(fmt3 $total_time)s"
log " Average per card : $(fmt3 $avg_time)s"
log " Minimum : $(fmt3 $min_time)s"
log " Maximum : $(fmt3 $max_time)s"
log ""
# Rough throughput estimate (sequential)
cards_per_min=$(echo "scale=1; 60 / $avg_time" | bc)
log " Sequential throughput: ~${cards_per_min} cards/min"
# Estimate full cardset at ~500 players * 2 cards each = 1000 renders
est_1000=$(echo "scale=0; (1000 * $avg_time) / 1" | bc)
log " Est. full cardset (1000 renders, sequential): ~${est_1000}s (~$(echo "scale=1; $est_1000/60" | bc) min)"
else
log " No successful renders to summarize."
fi
# =============================================================================
# SECTION 3: Upload Pipeline Reference
# =============================================================================
echo ""
log ""
hr
log "--- Section 3: Upload Pipeline Benchmark Commands ---"
log ""
log "The upload pipeline (pd_cards/core/upload.py) fetches rendered PNG cards"
log "and uploads them to S3. It uses a persistent aiohttp session with a 6s"
log "timeout per card."
log ""
log "To time a dry-run batch of 20 cards:"
log ""
log " cd /mnt/NV2/Development/paper-dynasty/card-creation"
log " time pd-cards upload s3 --cardset \"2005 Live\" --limit 20 --dry-run"
log ""
log "To time a real upload batch of 20 cards (writes to S3, updates DB URLs):"
log ""
log " time pd-cards upload s3 --cardset \"2005 Live\" --limit 20"
log ""
log "Notes:"
log " - dry-run validates card URLs exist without uploading"
log " - Remove --limit for full cardset run"
log " - Pipeline is currently sequential (one card at a time per session)"
log " - Each card: fetch PNG (~2-4s render) + S3 put (~0.1-0.5s) = ~2.5-4.5s/card"
log " - Parallelism target (Phase 0 goal): 10-20 concurrent fetches via asyncio"
log ""
# =============================================================================
# SECTION 4: Before/After Comparison Template
# =============================================================================
echo ""
hr
log "--- Section 4: Before/After Comparison Template ---"
log ""
log "Fill in after optimization work is complete."
log ""
log " Metric Before After Delta"
log " $(printf '%0.s-' {1..64})"
if [ "$success_count" -gt 0 ]; then
log " Avg render time (s) $(fmt3 $avg_time) ___._____ ___._____"
log " Min render time (s) $(fmt3 $min_time) ___._____ ___._____"
log " Max render time (s) $(fmt3 $max_time) ___._____ ___._____"
log " Sequential cards/min ${cards_per_min} ___.___ ___.___"
else
log " Avg render time (s) (no data) ___._____ ___._____"
fi
log " Upload batch (20 cards) ___._____s ___._____s ___._____s"
log " Upload cards/min ___.___ ___.___ ___.___"
log " Full cardset time (est) ___._____min ___._____min ___ min saved"
log ""
# =============================================================================
# DONE
# =============================================================================
echo ""
hr
log "Benchmark complete."
log "Results saved to: $RESULTS_FILE"
log ""
# Voice notify
curl -s -X POST http://localhost:8888/notify \
-H 'Content-Type: application/json' \
-d "{\"message\":\"Benchmark complete. Average render time $(fmt3 ${avg_time:-0}) seconds per card\"}" \
> /dev/null 2>&1 || true

View File

@ -1,93 +0,0 @@
Paper Dynasty Card Render Benchmark
Run timestamp : 2026-03-12 23:40:54
API target : https://pddev.manticorum.com/api
Cache-bust tag: benchmark-1773376854
==============================================================
Paper Dynasty Card Render Benchmark - WP-00 / Phase 0
2026-03-12 23:40:54
API: https://pddev.manticorum.com/api
==============================================================
--- Section 1: Connectivity Check ---
Sending single request to verify API is reachable...
Player : 12785 (batting card)
URL : https://pddev.manticorum.com/api/v2/players/12785/battingcard?d=benchmark-1773376854-probe
HTTP : 200 OK
Total : 1.944s
Connect: 0.010s
TTFB : 1.933s
Size : 192175 bytes (187.6KB)
Connectivity: PASS
--- Section 2: Sequential Card Render Benchmark ---
Rendering 10 cards sequentially with fresh cache busts.
Each request forces a full server-side render (bypasses nginx cache).
Player HTTP Total(s) TTFB(s) Connect(s) Size(KB)
--------------------------------------------------------------
12785 200 0.056 0.046 0.008 187.6
12790 200 1.829 1.815 0.008 202.3
12800 200 2.106 2.096 0.008 192.4
12810 200 1.755 1.745 0.009 189.8
12820 200 2.041 2.030 0.009 193.1
12830 200 2.433 2.423 0.009 180.3
12840 200 2.518 2.507 0.009 202.3
12850 200 2.191 2.174 0.009 187.6
12860 200 2.478 2.469 0.009 190.4
12870 200 2.913 2.901 0.009 192.8
--- Section 2: Results Summary ---
Cards requested : 10
Successful : 10
Failed : 0
Total wall time : 20.321s
Average per card : 2.032s
Minimum : 0.056s
Maximum : 2.913s
Sequential throughput: ~29.5 cards/min
Est. full cardset (1000 renders, sequential): ~2032s (~33.8 min)
--- Section 3: Upload Pipeline Benchmark Commands ---
The upload pipeline (pd_cards/core/upload.py) fetches rendered PNG cards
and uploads them to S3. It uses a persistent aiohttp session with a 6s
timeout per card.
To time a dry-run batch of 20 cards:
cd /mnt/NV2/Development/paper-dynasty/card-creation
time pd-cards upload s3 --cardset "2005 Live" --limit 20 --dry-run
To time a real upload batch of 20 cards (writes to S3, updates DB URLs):
time pd-cards upload s3 --cardset "2005 Live" --limit 20
Notes:
- dry-run validates card URLs exist without uploading
- Remove --limit for full cardset run
- Pipeline is currently sequential (one card at a time per session)
- Each card: fetch PNG (~2-4s render) + S3 put (~0.1-0.5s) = ~2.5-4.5s/card
- Parallelism target (Phase 0 goal): 10-20 concurrent fetches via asyncio
--- Section 4: Before/After Comparison Template ---
Fill in after optimization work is complete.
Metric Before After Delta
----------------------------------------------------------------
Avg render time (s) 2.032 ___._____ ___._____
Min render time (s) 0.056 ___._____ ___._____
Max render time (s) 2.913 ___._____ ___._____
Sequential cards/min 29.5 ___.___ ___.___
Upload batch (20 cards) ___._____s ___._____s ___._____s
Upload cards/min ___.___ ___.___ ___.___
Full cardset time (est) ___._____min ___._____min ___ min saved
Benchmark complete.
Results saved to: scripts/benchmark_results.txt

View File

@ -0,0 +1,75 @@
from typing import Literal
import requests
from exceptions import logger, log_exception
AUTH_TOKEN = {
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNucGhwbnV2aGp2cXprY2J3emRrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTgxMTc4NCwiZXhwIjoyMDYxMzg3Nzg0fQ.7dG_y2zU2PajBwTD8vut5GcWf3CSaZePkYW_hMf0fVg",
"apikey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNucGhwbnV2aGp2cXprY2J3emRrIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0NTgxMTc4NCwiZXhwIjoyMDYxMzg3Nzg0fQ.7dG_y2zU2PajBwTD8vut5GcWf3CSaZePkYW_hMf0fVg",
}
DB_URL = "https://cnphpnuvhjvqzkcbwzdk.supabase.co/rest/v1"
def get_req_url(endpoint: str, params: list = None):
req_url = f"{DB_URL}/{endpoint}?"
if params:
other_params = False
for x in params:
req_url += f'{"&" if other_params else "?"}{x[0]}={x[1]}'
other_params = True
return req_url
def log_return_value(log_string: str, log_type: Literal["info", "debug"]):
if log_type == "info":
logger.info(
f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
)
else:
logger.debug(
f'return: {log_string[:1200]}{" [ S N I P P E D ]" if len(log_string) > 1200 else ""}\n'
)
def db_get(
endpoint: str,
params: dict = None,
limit: int = 1000,
offset: int = 0,
none_okay: bool = True,
timeout: int = 3,
):
req_url = f"{DB_URL}/{endpoint}?limit={limit}&offset={offset}"
logger.info(f"HTTP GET: {req_url}, params: {params}")
response = requests.request("GET", req_url, params=params, headers=AUTH_TOKEN)
logger.info(response)
if response.status_code != requests.codes.ok:
log_exception(Exception, response.text)
data = response.json()
if isinstance(data, list) and len(data) == 0:
if none_okay:
return None
else:
log_exception(Exception, "Query returned no results and none_okay = False")
return data
# async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
# async with session.get(req_url) as r:
# logger.info(f'session info: {r}')
# if r.status == 200:
# js = await r.json()
# log_return_value(f'{js}')
# return js
# elif none_okay:
# e = await r.text()
# logger.error(e)
# return None
# else:
# e = await r.text()
# logger.error(e)
# raise ValueError(f'DB: {e}')

View File

@ -170,7 +170,6 @@ class TestDataFetcher:
@patch("automated_data_fetcher.pb.batting_stats_bref") @patch("automated_data_fetcher.pb.batting_stats_bref")
@patch("automated_data_fetcher.pb.pitching_stats_bref") @patch("automated_data_fetcher.pb.pitching_stats_bref")
@pytest.mark.asyncio
async def test_fetch_baseball_reference_data( async def test_fetch_baseball_reference_data(
self, self,
mock_pitching, mock_pitching,
@ -207,7 +206,6 @@ class TestDataFetcher:
@patch("automated_data_fetcher.pb.batting_stats") @patch("automated_data_fetcher.pb.batting_stats")
@patch("automated_data_fetcher.pb.pitching_stats") @patch("automated_data_fetcher.pb.pitching_stats")
@pytest.mark.asyncio
async def test_fetch_fangraphs_data( async def test_fetch_fangraphs_data(
self, self,
mock_pitching, mock_pitching,
@ -233,7 +231,6 @@ class TestDataFetcher:
@patch("automated_data_fetcher.pb.batting_stats_range") @patch("automated_data_fetcher.pb.batting_stats_range")
@patch("automated_data_fetcher.pb.pitching_stats_range") @patch("automated_data_fetcher.pb.pitching_stats_range")
@pytest.mark.asyncio
async def test_fetch_fangraphs_data_with_dates( async def test_fetch_fangraphs_data_with_dates(
self, self,
mock_pitching, mock_pitching,
@ -256,7 +253,6 @@ class TestDataFetcher:
mock_pitching.assert_called_once_with(start_date, end_date) mock_pitching.assert_called_once_with(start_date, end_date)
@patch("automated_data_fetcher.get_all_pybaseball_ids") @patch("automated_data_fetcher.get_all_pybaseball_ids")
@pytest.mark.asyncio
async def test_get_active_players_existing_function(self, mock_get_ids, fetcher): async def test_get_active_players_existing_function(self, mock_get_ids, fetcher):
"""Test getting player IDs using existing function""" """Test getting player IDs using existing function"""
mock_get_ids.return_value = ["12345", "67890", "11111"] mock_get_ids.return_value = ["12345", "67890", "11111"]
@ -268,7 +264,6 @@ class TestDataFetcher:
@patch("automated_data_fetcher.get_all_pybaseball_ids") @patch("automated_data_fetcher.get_all_pybaseball_ids")
@patch("automated_data_fetcher.pb.batting_stats") @patch("automated_data_fetcher.pb.batting_stats")
@pytest.mark.asyncio
async def test_get_active_players_fallback( async def test_get_active_players_fallback(
self, mock_batting, mock_get_ids, fetcher, sample_batting_data self, mock_batting, mock_get_ids, fetcher, sample_batting_data
): ):
@ -284,7 +279,6 @@ class TestDataFetcher:
assert result == expected_ids assert result == expected_ids
@patch("automated_data_fetcher.pb.get_splits") @patch("automated_data_fetcher.pb.get_splits")
@pytest.mark.asyncio
async def test_fetch_player_splits( async def test_fetch_player_splits(
self, mock_get_splits, fetcher, sample_splits_data self, mock_get_splits, fetcher, sample_splits_data
): ):
@ -339,7 +333,6 @@ class TestLiveSeriesDataFetcher:
@patch.object(DataFetcher, "fetch_baseball_reference_data") @patch.object(DataFetcher, "fetch_baseball_reference_data")
@patch.object(DataFetcher, "fetch_fangraphs_data") @patch.object(DataFetcher, "fetch_fangraphs_data")
@pytest.mark.asyncio
async def test_fetch_live_data(self, mock_fg_data, mock_bref_data, live_fetcher): async def test_fetch_live_data(self, mock_fg_data, mock_bref_data, live_fetcher):
"""Test fetching live series data""" """Test fetching live series data"""
# Mock return values # Mock return values
@ -367,7 +360,6 @@ class TestUtilityFunctions:
"""Test cases for utility functions""" """Test cases for utility functions"""
@patch("automated_data_fetcher.DataFetcher") @patch("automated_data_fetcher.DataFetcher")
@pytest.mark.asyncio
async def test_fetch_season_data(self, mock_fetcher_class): async def test_fetch_season_data(self, mock_fetcher_class):
"""Test fetch_season_data function""" """Test fetch_season_data function"""
# Create mock fetcher instance # Create mock fetcher instance
@ -397,7 +389,6 @@ class TestUtilityFunctions:
assert any("AUTOMATED DOWNLOAD COMPLETE" in call for call in print_calls) assert any("AUTOMATED DOWNLOAD COMPLETE" in call for call in print_calls)
@patch("automated_data_fetcher.LiveSeriesDataFetcher") @patch("automated_data_fetcher.LiveSeriesDataFetcher")
@pytest.mark.asyncio
async def test_fetch_live_series_data(self, mock_fetcher_class): async def test_fetch_live_series_data(self, mock_fetcher_class):
"""Test fetch_live_series_data function""" """Test fetch_live_series_data function"""
# Create mock fetcher instance # Create mock fetcher instance
@ -425,7 +416,6 @@ class TestErrorHandling:
return DataFetcher(2023, "Season") return DataFetcher(2023, "Season")
@patch("automated_data_fetcher.pb.pitching_stats_bref") @patch("automated_data_fetcher.pb.pitching_stats_bref")
@pytest.mark.asyncio
async def test_fetch_baseball_reference_data_error(self, mock_pitching, fetcher): async def test_fetch_baseball_reference_data_error(self, mock_pitching, fetcher):
"""Test error handling in Baseball Reference data fetch""" """Test error handling in Baseball Reference data fetch"""
# Mock function to raise an exception # Mock function to raise an exception
@ -435,7 +425,6 @@ class TestErrorHandling:
await fetcher.fetch_baseball_reference_data() await fetcher.fetch_baseball_reference_data()
@patch("automated_data_fetcher.pb.batting_stats") @patch("automated_data_fetcher.pb.batting_stats")
@pytest.mark.asyncio
async def test_fetch_fangraphs_data_error(self, mock_batting, fetcher): async def test_fetch_fangraphs_data_error(self, mock_batting, fetcher):
"""Test error handling in FanGraphs data fetch""" """Test error handling in FanGraphs data fetch"""
# Mock function to raise an exception # Mock function to raise an exception
@ -446,7 +435,6 @@ class TestErrorHandling:
@patch("automated_data_fetcher.get_all_pybaseball_ids") @patch("automated_data_fetcher.get_all_pybaseball_ids")
@patch("automated_data_fetcher.pb.batting_stats") @patch("automated_data_fetcher.pb.batting_stats")
@pytest.mark.asyncio
async def test_get_active_players_complete_failure( async def test_get_active_players_complete_failure(
self, mock_batting, mock_get_ids, fetcher self, mock_batting, mock_get_ids, fetcher
): ):
@ -461,7 +449,6 @@ class TestErrorHandling:
assert result == [] assert result == []
@patch("automated_data_fetcher.pb.get_splits") @patch("automated_data_fetcher.pb.get_splits")
@pytest.mark.asyncio
async def test_fetch_player_splits_individual_errors( async def test_fetch_player_splits_individual_errors(
self, mock_get_splits, fetcher self, mock_get_splits, fetcher
): ):
@ -492,7 +479,6 @@ class TestIntegration:
"""Integration tests that require network access""" """Integration tests that require network access"""
@pytest.mark.skip(reason="Requires network access and may be slow") @pytest.mark.skip(reason="Requires network access and may be slow")
@pytest.mark.asyncio
async def test_real_data_fetch(self): async def test_real_data_fetch(self):
"""Test fetching real data from pybaseball (skip by default)""" """Test fetching real data from pybaseball (skip by default)"""
fetcher = DataFetcher(2022, "Season") # Use a complete season fetcher = DataFetcher(2022, "Season") # Use a complete season

View File

@ -1,4 +1,10 @@
from creation_helpers import mround, sanitize_chance_output from creation_helpers import pd_positions_df, mround, sanitize_chance_output
def test_positions_df():
cardset_19_pos = pd_positions_df(19)
assert True == True
def test_mround(): def test_mround():