Compare commits
89 Commits
ai/paper-d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 802979a851 | |||
|
|
feedfe951f | ||
| f3da58702b | |||
| dee0349e54 | |||
| 49ca6c85f7 | |||
|
|
aaa2eaa252 | ||
|
|
7b40af6547 | ||
| ae73ce6755 | |||
| 0c68fdc081 | |||
|
|
dba7e562c4 | ||
|
|
3ad893c949 | ||
| 411659b580 | |||
|
|
e9ecdec168 | ||
|
|
a4596c63a5 | ||
| f868e5a329 | |||
|
|
4dae64d239 | ||
|
|
9fbd0c25ba | ||
|
|
94150b4e00 | ||
|
|
f2327deb56 | ||
|
|
5d248fcd15 | ||
| 87599a67d5 | |||
| e266f814ca | |||
|
|
f329d74ed8 | ||
| c1f06eb9c7 | |||
| d6b65594b8 | |||
|
|
94fd72344d | ||
|
|
43aff3568f | ||
| eaf4bdbd6c | |||
|
|
6f67cfec9a | ||
|
|
f2c09d09e6 | ||
| 8c00bacf59 | |||
| de9604364c | |||
| aa8306e844 | |||
| d7c6e6da27 | |||
| 4392f6c07f | |||
| 3612b0710b | |||
| 424b7da78d | |||
|
|
82a8dac950 | ||
| 461a469374 | |||
|
|
962b9cf6f1 | ||
| f2f70bfce5 | |||
|
|
50ee2d0446 | ||
| 7286fd2203 | |||
|
|
63a30bd434 | ||
| 770f296938 | |||
|
|
d43927258a | ||
| fd142c27d2 | |||
|
|
df6e96bc76 | ||
| dd42f35674 | |||
|
|
9e48616274 | ||
| deaa43432b | |||
| 3fd07b6d89 | |||
| 55f2eda888 | |||
| a432d37850 | |||
| dde163e2fb | |||
| f485241dd7 | |||
| 6d0497431f | |||
| f5cb72cc26 | |||
| f67d111a66 | |||
| 230f3e79ce | |||
| ecc62a0521 | |||
| 992feba79e | |||
| 57c379a8e0 | |||
| e413fd5cc8 | |||
| 6a6767f5d8 | |||
| 2b955dd8f7 | |||
| 0e66ff71e7 | |||
| b55820eec8 | |||
| b4a3e4b865 | |||
| bb546c6ded | |||
| 5c7c613813 | |||
| cbfcba5e26 | |||
| 006b48e60f | |||
| 5e135ff554 | |||
| 602151fb16 | |||
| 6c20f93901 | |||
|
|
0c0eece972 | ||
|
|
937620e2e9 | ||
|
|
5b8d027d46 | ||
|
|
bd1809261e | ||
|
|
a2e374cd4f | ||
|
|
b52c5418db | ||
|
|
1d96223c78 | ||
|
|
8e24b4e686 | ||
|
|
46fdde3d02 | ||
|
|
09cb942435 | ||
|
|
b39d3283fd | ||
|
|
ee4dae0985 | ||
|
|
e3220bf337 |
@ -1,8 +1,14 @@
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
import pandas as pd
|
||||
|
||||
AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"}
|
||||
# Add project root so we can import db_calls
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from db_calls import AUTH_TOKEN
|
||||
|
||||
PROD_URL = "https://pd.manticorum.com/api"
|
||||
|
||||
|
||||
|
||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
# Paper Dynasty API
|
||||
PD_API_TOKEN=your-bearer-token-here
|
||||
@ -573,7 +573,7 @@ def stealing_line(steal_data: dict):
|
||||
else:
|
||||
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):
|
||||
@ -583,7 +583,7 @@ def running(extra_base_pct: str):
|
||||
xb_pct = float(extra_base_pct.strip("%")) / 80
|
||||
except Exception as e:
|
||||
logger.error(f"calcs_batter running - {e}")
|
||||
xb_pct = 20
|
||||
return 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(
|
||||
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(
|
||||
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"])
|
||||
|
||||
@ -3,7 +3,7 @@ import urllib.parse
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from typing import Dict
|
||||
from typing import Any, Dict
|
||||
from creation_helpers import (
|
||||
get_all_pybaseball_ids,
|
||||
sanitize_name,
|
||||
@ -18,11 +18,11 @@ from creation_helpers import (
|
||||
calculate_rarity_cost_adjustment,
|
||||
DEFAULT_BATTER_OPS,
|
||||
)
|
||||
from db_calls import db_post, db_get, db_put, db_patch
|
||||
from db_calls import db_post, db_get, db_put, db_patch, get_fully_evolved_players
|
||||
from . import calcs_batter as cba
|
||||
from defenders import calcs_defense as cde
|
||||
from exceptions import logger
|
||||
from rarity_thresholds import get_batter_thresholds
|
||||
from rarity_thresholds import get_batter_thresholds, rarity_is_downgrade
|
||||
|
||||
|
||||
async def pd_battingcards_df(cardset_id: int):
|
||||
@ -158,8 +158,8 @@ async def create_new_players(
|
||||
{
|
||||
"p_name": f"{f_name} {l_name}",
|
||||
"cost": NEW_PLAYER_COST,
|
||||
"image": f'{card_base_url}/{df_data["player_id"]}/battingcard'
|
||||
f'{urllib.parse.quote("?d=")}{release_dir}',
|
||||
"image": f"{card_base_url}/{df_data['player_id']}/battingcard"
|
||||
f"{urllib.parse.quote('?d=')}{release_dir}",
|
||||
"mlbclub": CLUB_LIST[df_data["Tm_vL"]],
|
||||
"franchise": FRANCHISE_LIST[df_data["Tm_vL"]],
|
||||
"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(
|
||||
cardset: Dict[str, any],
|
||||
cardset: Dict[str, Any],
|
||||
card_base_url: str,
|
||||
release_dir: str,
|
||||
player_desc: str,
|
||||
@ -384,6 +384,24 @@ async def post_player_updates(
|
||||
)
|
||||
|
||||
player_updates = {} # { <player_id> : [ (param pairs) ] }
|
||||
|
||||
# T4 rarity guard: identify players where OPS-derived rarity would be a
|
||||
# downgrade and check whether any of them have a fully-evolved refractor
|
||||
# state. If so, their current (T4-earned) rarity is the floor and must
|
||||
# not be overwritten. Falls back to empty set if the API endpoint is
|
||||
# unavailable, so the guard degrades safely without blocking the pipeline.
|
||||
downgrade_candidates = player_data[
|
||||
player_data.apply(
|
||||
lambda r: rarity_is_downgrade(r["rarity"], r["new_rarity_id"]), axis=1
|
||||
)
|
||||
]["player_id"].tolist()
|
||||
t4_protected_ids = await get_fully_evolved_players(downgrade_candidates)
|
||||
if t4_protected_ids:
|
||||
logger.info(
|
||||
f"batters.creation.post_player_updates - {len(t4_protected_ids)} player(s) "
|
||||
f"protected from rarity downgrade by T4 refractor floor: {t4_protected_ids}"
|
||||
)
|
||||
|
||||
rarity_group = player_data.query("rarity == new_rarity_id").groupby("rarity")
|
||||
average_ops = rarity_group["total_OPS"].mean().to_dict()
|
||||
|
||||
@ -432,8 +450,8 @@ async def post_player_updates(
|
||||
[
|
||||
(
|
||||
"image",
|
||||
f'{card_base_url}/{df_data["player_id"]}/battingcard'
|
||||
f'{urllib.parse.quote("?d=")}{release_dir}',
|
||||
f"{card_base_url}/{df_data['player_id']}/battingcard"
|
||||
f"{urllib.parse.quote('?d=')}{release_dir}",
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -454,13 +472,27 @@ async def post_player_updates(
|
||||
)
|
||||
|
||||
elif df_data["rarity"] != df_data["new_rarity_id"]:
|
||||
# Calculate adjusted cost for rarity change using lookup table
|
||||
new_cost = calculate_rarity_cost_adjustment(
|
||||
old_rarity=df_data["rarity"],
|
||||
new_rarity=df_data["new_rarity_id"],
|
||||
old_cost=df_data["cost"],
|
||||
)
|
||||
params.extend([("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])])
|
||||
# T4 guard: skip rarity downgrades for fully-evolved cards so that
|
||||
# a T4-earned rarity bump is not silently reverted by the pipeline.
|
||||
if (
|
||||
rarity_is_downgrade(df_data["rarity"], df_data["new_rarity_id"])
|
||||
and df_data.player_id in t4_protected_ids
|
||||
):
|
||||
logger.info(
|
||||
f"batters.creation.post_player_updates - Skipping rarity downgrade "
|
||||
f"for player_id={df_data.player_id}: T4 floor rarity={df_data['rarity']}, "
|
||||
f"OPS rarity={df_data['new_rarity_id']}"
|
||||
)
|
||||
else:
|
||||
# Calculate adjusted cost for rarity change using lookup table
|
||||
new_cost = calculate_rarity_cost_adjustment(
|
||||
old_rarity=df_data["rarity"],
|
||||
new_rarity=df_data["new_rarity_id"],
|
||||
old_cost=df_data["cost"],
|
||||
)
|
||||
params.extend(
|
||||
[("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])]
|
||||
)
|
||||
|
||||
if len(params) > 0:
|
||||
if df_data.player_id not in player_updates.keys():
|
||||
|
||||
@ -10,7 +10,7 @@ import requests
|
||||
import time
|
||||
|
||||
from db_calls import db_get
|
||||
from db_calls_card_creation import *
|
||||
from db_calls_card_creation import PitcherData
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Card Creation Constants
|
||||
@ -533,7 +533,7 @@ def get_pitching_peripherals(season: int):
|
||||
row_data.append(player_id)
|
||||
if len(headers) == 0:
|
||||
col_names.append("key_bbref")
|
||||
except Exception:
|
||||
except KeyError:
|
||||
pass
|
||||
row_data.append(cell.text)
|
||||
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):
|
||||
bold1 = 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}'
|
||||
bold1 = 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}"
|
||||
if TESTING:
|
||||
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}"
|
||||
)
|
||||
|
||||
# No splits; standard result
|
||||
if not split_min:
|
||||
return f'{bold1}{row_string}-{tba_data["string"]}{bold2}'
|
||||
return f"{bold1}{row_string}-{tba_data['string']}{bold2}"
|
||||
|
||||
# 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 = (
|
||||
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
|
||||
elif "SI**" in data_string:
|
||||
spaces += 1
|
||||
elif "DO**" in data_string:
|
||||
spaces -= 2
|
||||
elif "DO*" in data_string:
|
||||
spaces -= 1
|
||||
elif "DO*" in data_string:
|
||||
spaces -= 2
|
||||
elif "so" in data_string:
|
||||
spaces += 3
|
||||
elif "gb" in data_string:
|
||||
@ -638,41 +638,39 @@ def result_string(tba_data, row_num, split_min=None, split_max=None):
|
||||
row_output = "<b> </b>"
|
||||
if TESTING:
|
||||
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(
|
||||
tba_data, row_num, tba_data_bottom=None, top_split_max=None, fatigue=False
|
||||
):
|
||||
ret_data = {}
|
||||
top_bold1 = f'{"<b>" if tba_data["bold"] else ""}'
|
||||
top_bold2 = 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 ''}"
|
||||
bot_bold1 = None
|
||||
bot_bold2 = None
|
||||
if tba_data_bottom:
|
||||
bot_bold1 = f'{"<b>" if tba_data_bottom["bold"] else ""}'
|
||||
bot_bold2 = 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 ''}"
|
||||
|
||||
if tba_data_bottom is None:
|
||||
ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}"
|
||||
ret_data["splits"] = f"{top_bold1}{top_bold2}"
|
||||
ret_data["result"] = (
|
||||
f"{top_bold1}"
|
||||
f'{tba_data["string"]}{" •" if fatigue else ""}'
|
||||
f"{top_bold2}"
|
||||
f"{top_bold1}{tba_data['string']}{' •' if fatigue else ''}{top_bold2}"
|
||||
)
|
||||
else:
|
||||
ret_data["2d6"] = f"{top_bold1}{int(row_num)}-{top_bold2}\n"
|
||||
ret_data["splits"] = (
|
||||
f'{top_bold1}1{"-" if top_split_max != 1 else ""}'
|
||||
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"{top_bold1}1{'-' if top_split_max != 1 else ''}"
|
||||
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}"
|
||||
)
|
||||
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"{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}"
|
||||
)
|
||||
|
||||
@ -688,9 +686,9 @@ def get_of(batter_hand, pitcher_hand, pull_side=True):
|
||||
|
||||
if batter_hand == "S":
|
||||
if pitcher_hand == "L":
|
||||
return "rf" if pull_side else "rf"
|
||||
return "lf" if pull_side else "rf"
|
||||
else:
|
||||
return "lf" if pull_side else "lf"
|
||||
return "rf" if pull_side else "lf"
|
||||
|
||||
|
||||
def get_col(col_num):
|
||||
@ -729,7 +727,7 @@ def get_position_string(all_pos: list, inc_p: bool):
|
||||
|
||||
for x in all_pos:
|
||||
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_innings = x.innings
|
||||
elif x.position == "CF":
|
||||
@ -744,7 +742,7 @@ def get_position_string(all_pos: list, inc_p: bool):
|
||||
elif x.position == "C":
|
||||
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,
|
||||
)
|
||||
)
|
||||
@ -1079,7 +1077,7 @@ def mlbteam_and_franchise(mlbam_playerid):
|
||||
p_data["franchise"] = normalize_franchise(data["currentTeam"]["name"])
|
||||
else:
|
||||
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:
|
||||
logger.error(
|
||||
@ -1222,5 +1220,5 @@ def get_hand(df_data):
|
||||
else:
|
||||
return "R"
|
||||
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"
|
||||
|
||||
@ -6,6 +6,7 @@ baseball archetypes with iterative review and refinement.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import sys
|
||||
from typing import Literal
|
||||
from datetime import datetime
|
||||
@ -179,7 +180,12 @@ class CustomCardCreator:
|
||||
else:
|
||||
calc = PitcherRatingCalculator(archetype)
|
||||
ratings = calc.calculate_ratings(pitchingcard_id=0) # Temp ID
|
||||
card_data = {"ratings": ratings}
|
||||
card_data = {
|
||||
"ratings": ratings,
|
||||
"starter_rating": archetype.starter_rating,
|
||||
"relief_rating": archetype.relief_rating,
|
||||
"closer_rating": archetype.closer_rating,
|
||||
}
|
||||
|
||||
# Step 4: Review and tweak loop
|
||||
final_data = await self.review_and_tweak(
|
||||
@ -347,7 +353,7 @@ class CustomCardCreator:
|
||||
vs_hand = rating["vs_hand"]
|
||||
print(f"\nVS {vs_hand}{'HP' if player_type == 'batter' else 'HB'}:")
|
||||
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
|
||||
@ -364,7 +370,7 @@ class CustomCardCreator:
|
||||
+ rating["bp_single"]
|
||||
)
|
||||
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
|
||||
@ -389,7 +395,7 @@ class CustomCardCreator:
|
||||
)
|
||||
)
|
||||
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
|
||||
@ -420,10 +426,68 @@ class CustomCardCreator:
|
||||
print("-" * 70)
|
||||
print("\nAdjust key percentages (press Enter to keep current value):\n")
|
||||
|
||||
# TODO: Implement percentage tweaking
|
||||
# For now, return unchanged
|
||||
print("(Feature coming soon - manual adjustments available in option 3)")
|
||||
return card_data
|
||||
def prompt_float(label: str, current: float) -> float:
|
||||
val = input(f" {label} [{current:.3f}]: ").strip()
|
||||
if not val:
|
||||
return current
|
||||
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(
|
||||
self, player_type: Literal["batter", "pitcher"], card_data: dict
|
||||
@ -434,10 +498,99 @@ class CustomCardCreator:
|
||||
print("-" * 70)
|
||||
print("\nDirectly edit D20 chances (must sum to 108):\n")
|
||||
|
||||
# TODO: Implement manual adjustments
|
||||
# For now, return unchanged
|
||||
print("(Feature coming soon)")
|
||||
return card_data
|
||||
D20_FIELDS = [
|
||||
"homerun",
|
||||
"bp_homerun",
|
||||
"triple",
|
||||
"double_three",
|
||||
"double_two",
|
||||
"double_pull",
|
||||
"single_two",
|
||||
"single_one",
|
||||
"single_center",
|
||||
"bp_single",
|
||||
"hbp",
|
||||
"walk",
|
||||
"strikeout",
|
||||
"lineout",
|
||||
"popout",
|
||||
"flyout_a",
|
||||
"flyout_bq",
|
||||
"flyout_lf_b",
|
||||
"flyout_rf_b",
|
||||
"groundout_a",
|
||||
"groundout_b",
|
||||
"groundout_c",
|
||||
]
|
||||
|
||||
# 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(
|
||||
self,
|
||||
@ -580,9 +733,9 @@ class CustomCardCreator:
|
||||
"name_first": player_info["name_first"],
|
||||
"name_last": player_info["name_last"],
|
||||
"hand": player_info["hand"],
|
||||
"starter_rating": 5, # TODO: Get from archetype
|
||||
"relief_rating": 5, # TODO: Get from archetype
|
||||
"closer_rating": None, # TODO: Get from archetype
|
||||
"starter_rating": card_data["starter_rating"],
|
||||
"relief_rating": card_data["relief_rating"],
|
||||
"closer_rating": card_data["closer_rating"],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
89
db_calls.py
89
db_calls.py
@ -1,10 +1,18 @@
|
||||
import os
|
||||
|
||||
import aiohttp
|
||||
import pybaseball as pb
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from typing import Literal
|
||||
from typing import Literal, Optional
|
||||
from exceptions import logger
|
||||
|
||||
AUTH_TOKEN = {"Authorization": "Bearer Tp3aO3jhYve5NJF1IqOmJTmk"}
|
||||
load_dotenv()
|
||||
|
||||
_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"
|
||||
master_debug = True
|
||||
alt_database = None
|
||||
@ -25,7 +33,7 @@ def param_char(other_params):
|
||||
def get_req_url(
|
||||
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:
|
||||
other_params = False
|
||||
@ -39,11 +47,11 @@ def get_req_url(
|
||||
def log_return_value(log_string: str):
|
||||
if master_debug:
|
||||
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:
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
@ -53,13 +61,15 @@ async def db_get(
|
||||
object_id: int = None,
|
||||
params: list = None,
|
||||
none_okay: bool = True,
|
||||
timeout: int = 3,
|
||||
):
|
||||
timeout: int = 30,
|
||||
) -> Optional[dict]:
|
||||
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}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with session.get(req_url) as r:
|
||||
logger.info(f"session info: {r}")
|
||||
if r.status == 200:
|
||||
@ -76,11 +86,13 @@ async def db_get(
|
||||
raise ValueError(f"DB: {e}")
|
||||
|
||||
|
||||
async def url_get(url: str, timeout: int = 3):
|
||||
async def url_get(url: str, timeout: int = 30) -> dict:
|
||||
log_string = f"get:\n{url}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with session.get(url) as r:
|
||||
if r.status == 200:
|
||||
log_string = "200 received"
|
||||
@ -93,13 +105,15 @@ async def url_get(url: str, timeout: int = 3):
|
||||
|
||||
|
||||
async def db_patch(
|
||||
endpoint: str, object_id: int, params: list, api_ver: int = 2, timeout: int = 3
|
||||
):
|
||||
endpoint: str, object_id: int, params: list, api_ver: int = 2, timeout: int = 30
|
||||
) -> dict:
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id, params=params)
|
||||
log_string = f"patch:\n{endpoint} {params}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with session.patch(req_url) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -112,13 +126,15 @@ async def db_patch(
|
||||
|
||||
|
||||
async def db_post(
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3
|
||||
):
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 30
|
||||
) -> dict:
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver)
|
||||
log_string = f"post:\n{endpoint} payload: {payload}\ntype: {type(payload)}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with session.post(req_url, json=payload) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -131,13 +147,15 @@ async def db_post(
|
||||
|
||||
|
||||
async def db_put(
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 3
|
||||
):
|
||||
endpoint: str, api_ver: int = 2, payload: dict = None, timeout: int = 30
|
||||
) -> dict:
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver)
|
||||
log_string = f"put:\n{endpoint} payload: {payload}\ntype: {type(payload)}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with session.put(req_url, json=payload) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -149,12 +167,14 @@ async def db_put(
|
||||
raise ValueError(f"DB: {e}")
|
||||
|
||||
|
||||
async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3):
|
||||
async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3) -> dict:
|
||||
req_url = get_req_url(endpoint, api_ver=api_ver, object_id=object_id)
|
||||
log_string = f"delete:\n{endpoint} {object_id}"
|
||||
logger.info(log_string) if master_debug else logger.debug(log_string)
|
||||
|
||||
async with aiohttp.ClientSession(headers=AUTH_TOKEN) as session:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=AUTH_TOKEN, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||
) as session:
|
||||
async with session.delete(req_url) as r:
|
||||
if r.status == 200:
|
||||
js = await r.json()
|
||||
@ -166,6 +186,31 @@ async def db_delete(endpoint: str, object_id: int, api_ver: int = 2, timeout=3):
|
||||
raise ValueError(f"DB: {e}")
|
||||
|
||||
|
||||
async def get_fully_evolved_players(player_ids: list) -> set:
|
||||
"""Return the subset of player_ids that have any fully-evolved (T4) refractor card state.
|
||||
|
||||
Calls GET /api/v2/refractor/fully-evolved with a comma-separated player_ids
|
||||
query parameter. Returns an empty set if the endpoint is unavailable (404,
|
||||
error, or missing field) so the caller degrades safely — no T4 protection is
|
||||
applied rather than blocking the pipeline.
|
||||
|
||||
NOTE: This endpoint does not yet exist in the database API. It must return
|
||||
{"player_ids": [<int>, ...]} listing player IDs with fully_evolved=True.
|
||||
Until it is added, this function always returns an empty set.
|
||||
"""
|
||||
if not player_ids:
|
||||
return set()
|
||||
ids_param = ",".join(str(pid) for pid in player_ids)
|
||||
result = await db_get(
|
||||
"refractor/fully-evolved",
|
||||
params=[("player_ids", ids_param)],
|
||||
none_okay=True,
|
||||
)
|
||||
if result is None or "player_ids" not in result:
|
||||
return set()
|
||||
return set(result["player_ids"])
|
||||
|
||||
|
||||
def get_player_data(
|
||||
player_id: str,
|
||||
id_type: Literal["bbref", "fangraphs"],
|
||||
@ -183,4 +228,4 @@ def get_player_data(
|
||||
def player_desc(this_player) -> str:
|
||||
if this_player["p_name"] in 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']}"
|
||||
|
||||
@ -418,7 +418,7 @@ pd-cards upload s3 --cardset <name> [OPTIONS]
|
||||
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=Tp3aO3jhYve5NJF1IqOmJTmk \
|
||||
API_TOKEN=your-api-token-here \
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Terminal 2: Upload with local rendering
|
||||
|
||||
475
docs/REFRACTOR_PHASE2_VALIDATION_SPEC.md
Normal file
475
docs/REFRACTOR_PHASE2_VALIDATION_SPEC.md
Normal file
@ -0,0 +1,475 @@
|
||||
# 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 ~304–480)
|
||||
- `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 4–5 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 3–5
|
||||
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 5–6 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 5–10 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., 10–15% 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.
|
||||
810
docs/card-cosmetics-playground.html
Normal file
810
docs/card-cosmetics-playground.html
Normal file
@ -0,0 +1,810 @@
|
||||
<!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 LF 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 & 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>
|
||||
269
docs/prd-evolution/05-rating-boosts.md
Normal file
269
docs/prd-evolution/05-rating-boosts.md
Normal file
@ -0,0 +1,269 @@
|
||||
# 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.
|
||||
2237
docs/refractor-tier-mockup.html
Normal file
2237
docs/refractor-tier-mockup.html
Normal file
File diff suppressed because it is too large
Load Diff
247
docs/refractor-visual-spec.md
Normal file
247
docs/refractor-visual-spec.md
Normal file
@ -0,0 +1,247 @@
|
||||
# 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; T1–T3 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.0–1.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.18–0.22 vs 0.28–0.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 T1–T3)
|
||||
- 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.
|
||||
@ -160,13 +160,15 @@ async def get_batting_scouting_dfs(cardset_ids: List[int] = None) -> pd.DataFram
|
||||
api_data = await asyncio.gather(*tasks)
|
||||
log_time(
|
||||
"end",
|
||||
f'Pulled {api_data[0]["count"] + api_data[1]["count"]} batting card ratings and {api_data[2]["count"]} positions',
|
||||
f"Pulled {api_data[0]['count'] + api_data[1]['count']} batting card ratings and {api_data[2]['count']} positions",
|
||||
start_time=start_time,
|
||||
)
|
||||
|
||||
start_time = log_time("start", message="Building base dataframes")
|
||||
|
||||
vl_vals = api_data[0]["ratings"]
|
||||
vl_vals = [
|
||||
x for x in api_data[0]["ratings"] if x["battingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vl_vals:
|
||||
x.update(x["battingcard"])
|
||||
x["player_id"] = x["battingcard"]["player"]["player_id"]
|
||||
@ -177,7 +179,9 @@ async def get_batting_scouting_dfs(cardset_ids: List[int] = None) -> pd.DataFram
|
||||
del x["battingcard"]
|
||||
del x["player"]
|
||||
|
||||
vr_vals = api_data[1]["ratings"]
|
||||
vr_vals = [
|
||||
x for x in api_data[1]["ratings"] if x["battingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vr_vals:
|
||||
x["player_id"] = x["battingcard"]["player"]["player_id"]
|
||||
del x["battingcard"]
|
||||
@ -571,13 +575,15 @@ async def get_pitching_scouting_dfs(cardset_ids: List[int] = None) -> pd.DataFra
|
||||
api_data = await asyncio.gather(*tasks)
|
||||
log_time(
|
||||
"end",
|
||||
f'Pulled {api_data[0]["count"] + api_data[1]["count"]} pitching card ratings and {api_data[2]["count"]} positions',
|
||||
f"Pulled {api_data[0]['count'] + api_data[1]['count']} pitching card ratings and {api_data[2]['count']} positions",
|
||||
start_time=start_time,
|
||||
)
|
||||
|
||||
start_time = log_time("start", message="Building base dataframes")
|
||||
|
||||
vl_vals = api_data[0]["ratings"]
|
||||
vl_vals = [
|
||||
x for x in api_data[0]["ratings"] if x["pitchingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vl_vals:
|
||||
x.update(x["pitchingcard"])
|
||||
x["player_id"] = x["pitchingcard"]["player"]["player_id"]
|
||||
@ -590,7 +596,9 @@ async def get_pitching_scouting_dfs(cardset_ids: List[int] = None) -> pd.DataFra
|
||||
x["closer_rating"] = x["pitchingcard"]["closer_rating"]
|
||||
del x["pitchingcard"], x["player"]
|
||||
|
||||
vr_vals = api_data[1]["ratings"]
|
||||
vr_vals = [
|
||||
x for x in api_data[1]["ratings"] if x["pitchingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vr_vals:
|
||||
x["player_id"] = x["pitchingcard"]["player"]["player_id"]
|
||||
del x["pitchingcard"]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
import urllib.parse
|
||||
import pandas as pd
|
||||
from typing import Dict
|
||||
from typing import Any, Dict
|
||||
|
||||
from creation_helpers import (
|
||||
get_all_pybaseball_ids,
|
||||
@ -17,11 +17,11 @@ from creation_helpers import (
|
||||
DEFAULT_STARTER_OPS,
|
||||
DEFAULT_RELIEVER_OPS,
|
||||
)
|
||||
from db_calls import db_post, db_get, db_put, db_patch
|
||||
from db_calls import db_post, db_get, db_put, db_patch, get_fully_evolved_players
|
||||
from defenders import calcs_defense as cde
|
||||
from . import calcs_pitcher as cpi
|
||||
from exceptions import logger
|
||||
from rarity_thresholds import get_pitcher_thresholds
|
||||
from rarity_thresholds import get_pitcher_thresholds, rarity_is_downgrade
|
||||
|
||||
|
||||
def get_pitching_stats(
|
||||
@ -196,8 +196,8 @@ async def create_new_players(
|
||||
{
|
||||
"p_name": f"{f_name} {l_name}",
|
||||
"cost": NEW_PLAYER_COST,
|
||||
"image": f'{card_base_url}/{df_data["player_id"]}/'
|
||||
f'pitchingcard{urllib.parse.quote("?d=")}{release_dir}',
|
||||
"image": f"{card_base_url}/{df_data['player_id']}/"
|
||||
f"pitchingcard{urllib.parse.quote('?d=')}{release_dir}",
|
||||
"mlbclub": CLUB_LIST[df_data["Tm_vL"]],
|
||||
"franchise": FRANCHISE_LIST[df_data["Tm_vL"]],
|
||||
"cardset_id": cardset["id"],
|
||||
@ -268,7 +268,7 @@ async def calculate_pitching_cards(
|
||||
|
||||
def create_pitching_card(df_data):
|
||||
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(
|
||||
float(df_data["Inn_def"]), df_data["GS"], df_data["G"]
|
||||
@ -298,11 +298,13 @@ async def calculate_pitching_cards(
|
||||
int(df_data["GF"]), int(df_data["SV"]), int(df_data["G"])
|
||||
),
|
||||
"hand": df_data["pitch_hand"],
|
||||
"batting": f'#1W{df_data["pitch_hand"]}-C',
|
||||
"batting": f"#1W{df_data['pitch_hand']}-C",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Skipping fg ID {df_data["key_fangraphs"]} due to: {e}')
|
||||
except Exception:
|
||||
logger.exception(
|
||||
f"Skipping fg ID {df_data['key_fangraphs']} due to exception"
|
||||
)
|
||||
|
||||
print("Calculating pitching cards...")
|
||||
pitching_stats.apply(create_pitching_card, axis=1)
|
||||
@ -333,7 +335,7 @@ async def create_position(
|
||||
|
||||
def create_pit_position(df_data):
|
||||
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(
|
||||
{
|
||||
"player_id": int(df_data["player_id"]),
|
||||
@ -355,7 +357,7 @@ async def create_position(
|
||||
try:
|
||||
pit_positions.append(
|
||||
{
|
||||
"player_id": int(df_data["key_bbref"]),
|
||||
"player_id": int(float(df_data["player_id"])),
|
||||
"position": "P",
|
||||
"innings": 1,
|
||||
"range": 5,
|
||||
@ -364,7 +366,7 @@ async def create_position(
|
||||
)
|
||||
except Exception:
|
||||
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...")
|
||||
@ -386,7 +388,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers:
|
||||
pitching_ratings.extend(cpi.get_pitcher_ratings(df_data))
|
||||
except Exception:
|
||||
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...")
|
||||
@ -400,7 +402,7 @@ async def calculate_pitcher_ratings(pitching_stats: pd.DataFrame, post_pitchers:
|
||||
|
||||
|
||||
async def post_player_updates(
|
||||
cardset: Dict[str, any],
|
||||
cardset: Dict[str, Any],
|
||||
player_description: str,
|
||||
card_base_url: str,
|
||||
release_dir: str,
|
||||
@ -461,6 +463,20 @@ async def post_player_updates(
|
||||
)
|
||||
|
||||
player_updates = {} # { <player_id> : [ (param pairs) ] }
|
||||
|
||||
# T4 rarity guard: same protection as batters — see batters/creation.py.
|
||||
downgrade_candidates = player_data[
|
||||
player_data.apply(
|
||||
lambda r: rarity_is_downgrade(r["rarity"], r["new_rarity_id"]), axis=1
|
||||
)
|
||||
]["player_id"].tolist()
|
||||
t4_protected_ids = await get_fully_evolved_players(downgrade_candidates)
|
||||
if t4_protected_ids:
|
||||
logger.info(
|
||||
f"pitchers.creation.post_player_updates - {len(t4_protected_ids)} player(s) "
|
||||
f"protected from rarity downgrade by T4 refractor floor: {t4_protected_ids}"
|
||||
)
|
||||
|
||||
sp_rarity_group = player_data.query(
|
||||
"rarity == new_rarity_id and starter_rating >= 4"
|
||||
).groupby("rarity")
|
||||
@ -525,8 +541,8 @@ async def post_player_updates(
|
||||
[
|
||||
(
|
||||
"image",
|
||||
f'{card_base_url}/{df_data["player_id"]}/pitchingcard'
|
||||
f'{urllib.parse.quote("?d=")}{release_dir}',
|
||||
f"{card_base_url}/{df_data['player_id']}/pitchingcard"
|
||||
f"{urllib.parse.quote('?d=')}{release_dir}",
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -549,13 +565,27 @@ async def post_player_updates(
|
||||
)
|
||||
|
||||
elif df_data["rarity"] != df_data["new_rarity_id"]:
|
||||
# Calculate adjusted cost for rarity change using lookup table
|
||||
new_cost = calculate_rarity_cost_adjustment(
|
||||
old_rarity=df_data["rarity"],
|
||||
new_rarity=df_data["new_rarity_id"],
|
||||
old_cost=df_data["cost"],
|
||||
)
|
||||
params.extend([("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])])
|
||||
# T4 guard: skip rarity downgrades for fully-evolved cards so that
|
||||
# a T4-earned rarity bump is not silently reverted by the pipeline.
|
||||
if (
|
||||
rarity_is_downgrade(df_data["rarity"], df_data["new_rarity_id"])
|
||||
and df_data.player_id in t4_protected_ids
|
||||
):
|
||||
logger.info(
|
||||
f"pitchers.creation.post_player_updates - Skipping rarity downgrade "
|
||||
f"for player_id={df_data.player_id}: T4 floor rarity={df_data['rarity']}, "
|
||||
f"OPS rarity={df_data['new_rarity_id']}"
|
||||
)
|
||||
else:
|
||||
# Calculate adjusted cost for rarity change using lookup table
|
||||
new_cost = calculate_rarity_cost_adjustment(
|
||||
old_rarity=df_data["rarity"],
|
||||
new_rarity=df_data["new_rarity_id"],
|
||||
old_cost=df_data["cost"],
|
||||
)
|
||||
params.extend(
|
||||
[("cost", new_cost), ("rarity_id", df_data["new_rarity_id"])]
|
||||
)
|
||||
|
||||
if len(params) > 0:
|
||||
if df_data.player_id not in player_updates.keys():
|
||||
|
||||
@ -23,6 +23,8 @@ dependencies = [
|
||||
"pydantic>=2.9.0",
|
||||
# AWS
|
||||
"boto3>=1.35.0",
|
||||
# Environment
|
||||
"python-dotenv>=1.0.0",
|
||||
# Scraping
|
||||
"beautifulsoup4>=4.12.0",
|
||||
"lxml>=5.0.0",
|
||||
|
||||
@ -144,3 +144,57 @@ def get_batter_thresholds(season: int) -> BatterRarityThresholds:
|
||||
return BATTER_THRESHOLDS_2025
|
||||
else:
|
||||
return BATTER_THRESHOLDS_2024
|
||||
|
||||
|
||||
# Ordered from least to most prestigious. Used for ladder comparisons (T4
|
||||
# rarity upgrade, downgrade guard). Do not change the order.
|
||||
RARITY_LADDER: list[int] = [
|
||||
5,
|
||||
4,
|
||||
3,
|
||||
2,
|
||||
1,
|
||||
99,
|
||||
] # Common → Bronze → Silver → Gold → Diamond → HoF
|
||||
|
||||
|
||||
def rarity_is_downgrade(current_rarity_id: int, new_rarity_id: int) -> bool:
|
||||
"""Return True if new_rarity_id is a less prestigious tier than current_rarity_id.
|
||||
|
||||
Uses the RARITY_LADDER ordering. Unknown IDs are treated as position 0
|
||||
(worst), so an unknown current rarity will never trigger a downgrade guard.
|
||||
An unknown new_rarity_id also returns False.
|
||||
"""
|
||||
try:
|
||||
current_pos = RARITY_LADDER.index(current_rarity_id)
|
||||
except ValueError:
|
||||
return False
|
||||
try:
|
||||
new_pos = RARITY_LADDER.index(new_rarity_id)
|
||||
except ValueError:
|
||||
return False
|
||||
return current_pos > new_pos
|
||||
|
||||
|
||||
def next_rarity(current_rarity_id: int) -> int | None:
|
||||
"""Return the next more-prestigious rarity ID, or None if already at HoF.
|
||||
|
||||
Uses the RARITY_LADDER ordering. Returns None when current_rarity_id is
|
||||
Hall of Fame (99) — the T4 rarity upgrade is a no-op at the top tier.
|
||||
Returns None for any unrecognised rarity ID.
|
||||
|
||||
Examples:
|
||||
next_rarity(5) → 4 (Common → Bronze)
|
||||
next_rarity(4) → 3 (Bronze → Silver)
|
||||
next_rarity(3) → 2 (Silver → Gold)
|
||||
next_rarity(2) → 1 (Gold → Diamond)
|
||||
next_rarity(1) → 99 (Diamond → HoF)
|
||||
next_rarity(99) → None (HoF: already at max)
|
||||
"""
|
||||
try:
|
||||
pos = RARITY_LADDER.index(current_rarity_id)
|
||||
except ValueError:
|
||||
return None
|
||||
if pos == len(RARITY_LADDER) - 1:
|
||||
return None # Already at HoF
|
||||
return RARITY_LADDER[pos + 1]
|
||||
|
||||
@ -23,9 +23,9 @@ multidict==6.1.0
|
||||
numpy==2.1.2
|
||||
packaging==24.1
|
||||
pandas==2.2.3
|
||||
peewee
|
||||
peewee==3.19.0
|
||||
pillow==11.0.0
|
||||
polars
|
||||
polars==1.36.1
|
||||
pluggy==1.5.0
|
||||
propcache==0.2.0
|
||||
# pyarrow==17.0.0
|
||||
|
||||
@ -53,21 +53,30 @@ PROMO_INCLUSION_RETRO_IDS = [
|
||||
# 'haraa001', # Aaron Harang (SP)
|
||||
# 'hofft001', # Trevor Hoffman (RP)
|
||||
]
|
||||
MIN_PA_VL = 20 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM
|
||||
MIN_PA_VR = 40 if "live" in PLAYER_DESCRIPTION.lower() else 1 # 1 for PotM
|
||||
MIN_TBF_VL = MIN_PA_VL
|
||||
MIN_TBF_VR = MIN_PA_VR
|
||||
CARDSET_ID = (
|
||||
27 if "live" in PLAYER_DESCRIPTION.lower() else 28
|
||||
) # 27: 2005 Live, 28: 2005 Promos
|
||||
MIN_PA_VL = 20 # 1 for PotM
|
||||
MIN_PA_VR = 40 # 1 for PotM
|
||||
MIN_TBF_VL = 20
|
||||
MIN_TBF_VR = 40
|
||||
CARDSET_ID = 27 # 27: 2005 Live, 28: 2005 Promos
|
||||
|
||||
# Per-Update Parameters
|
||||
SEASON_PCT = 81 / 162 # Through end of July (~half season)
|
||||
START_DATE = 20050403 # YYYYMMDD format - 2005 Opening Day
|
||||
# END_DATE = 20050531 # YYYYMMDD format - May PotM
|
||||
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
|
||||
LAST_WEEK_RATIO = 0.0 if PLAYER_DESCRIPTION == "Live" else 0.0
|
||||
LAST_WEEK_RATIO = 0.0
|
||||
LAST_TWOWEEKS_RATIO = 0.0
|
||||
LAST_MONTH_RATIO = 0.0
|
||||
|
||||
@ -1429,7 +1438,7 @@ def calc_pitching_cards(ps: pd.DataFrame, season_pct: float) -> pd.DataFrame:
|
||||
"closer_rating": [
|
||||
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]
|
||||
@ -1598,7 +1607,7 @@ def calc_positions(bs: pd.DataFrame) -> pd.DataFrame:
|
||||
]:
|
||||
if row["key_bbref"] in pos_df.index:
|
||||
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:
|
||||
if "bis_runs_total" in pos_df.columns:
|
||||
@ -1865,8 +1874,8 @@ async def get_or_post_players(
|
||||
|
||||
def new_player_payload(row, ratings_df: pd.DataFrame):
|
||||
return {
|
||||
"p_name": f'{row["use_name"]} {row["last_name"]}',
|
||||
"cost": f'{ratings_df.loc[row['key_bbref']]["cost"]}',
|
||||
"p_name": f"{row['use_name']} {row['last_name']}",
|
||||
"cost": f"{ratings_df.loc[row['key_bbref']]['cost']}",
|
||||
"image": "change-me",
|
||||
"mlbclub": CLUB_LIST[row["Tm"]],
|
||||
"franchise": FRANCHISE_LIST[row["Tm"]],
|
||||
@ -1916,11 +1925,11 @@ async def get_or_post_players(
|
||||
# Update positions for existing players too
|
||||
all_pos = get_player_record_pos(def_rat_df, row)
|
||||
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"])),
|
||||
(
|
||||
"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
|
||||
@ -1964,7 +1973,7 @@ async def get_or_post_players(
|
||||
params=[
|
||||
(
|
||||
"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}",
|
||||
)
|
||||
],
|
||||
)
|
||||
@ -2003,11 +2012,11 @@ async def get_or_post_players(
|
||||
|
||||
# Determine pitcher positions based on ratings
|
||||
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"])),
|
||||
(
|
||||
"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}",
|
||||
),
|
||||
]
|
||||
|
||||
@ -2081,7 +2090,7 @@ async def get_or_post_players(
|
||||
params=[
|
||||
(
|
||||
"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}",
|
||||
)
|
||||
],
|
||||
)
|
||||
@ -2105,10 +2114,10 @@ async def get_or_post_players(
|
||||
raise KeyError("Could not get players - not enough stat DFs were supplied")
|
||||
|
||||
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(
|
||||
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")
|
||||
@ -2280,7 +2289,7 @@ async def post_positions(pos_df: pd.DataFrame, delete_existing: bool = False):
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
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")
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@ def build_c_throw(all_positions, pos_code):
|
||||
|
||||
async def fetch_data(data):
|
||||
start_time = log_time("start", print_to_console=False)
|
||||
this_query = await db_get(endpoint=data[0], params=data[1])
|
||||
this_query = await db_get(endpoint=data[0], params=data[1], timeout=120)
|
||||
log_time("end", print_to_console=False, start_time=start_time)
|
||||
return this_query
|
||||
|
||||
@ -126,7 +126,9 @@ async def get_scouting_dfs(cardset_id: list = None) -> pd.DataFrame:
|
||||
)
|
||||
|
||||
start_time = log_time("start", message="Building base dataframes")
|
||||
vl_vals = api_data[0]["ratings"]
|
||||
vl_vals = [
|
||||
x for x in api_data[0]["ratings"] if x["battingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vl_vals:
|
||||
x.update(x["battingcard"])
|
||||
x["player_id"] = x["battingcard"]["player"]["player_id"]
|
||||
@ -137,7 +139,9 @@ async def get_scouting_dfs(cardset_id: list = None) -> pd.DataFrame:
|
||||
del x["battingcard"]
|
||||
del x["player"]
|
||||
|
||||
vr_vals = api_data[1]["ratings"]
|
||||
vr_vals = [
|
||||
x for x in api_data[1]["ratings"] if x["battingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vr_vals:
|
||||
x["player_id"] = x["battingcard"]["player"]["player_id"]
|
||||
del x["battingcard"]
|
||||
|
||||
@ -34,7 +34,9 @@ async def get_scouting_dfs(cardset_id: list = None):
|
||||
)
|
||||
start_time = log_time("start", message="Building base dataframes")
|
||||
|
||||
vl_vals = api_data[0]["ratings"]
|
||||
vl_vals = [
|
||||
x for x in api_data[0]["ratings"] if x["pitchingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vl_vals:
|
||||
x.update(x["pitchingcard"])
|
||||
x["player_id"] = x["pitchingcard"]["player"]["player_id"]
|
||||
@ -47,7 +49,9 @@ async def get_scouting_dfs(cardset_id: list = None):
|
||||
x["closer_rating"] = x["pitchingcard"]["closer_rating"]
|
||||
del x["pitchingcard"], x["player"]
|
||||
|
||||
vr_vals = api_data[1]["ratings"]
|
||||
vr_vals = [
|
||||
x for x in api_data[1]["ratings"] if x["pitchingcard"].get("variant", 0) == 0
|
||||
]
|
||||
for x in vr_vals:
|
||||
x["player_id"] = x["pitchingcard"]["player"]["player_id"]
|
||||
del x["pitchingcard"]
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
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}')
|
||||
@ -170,6 +170,7 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats_bref")
|
||||
@patch("automated_data_fetcher.pb.pitching_stats_bref")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_baseball_reference_data(
|
||||
self,
|
||||
mock_pitching,
|
||||
@ -206,6 +207,7 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@patch("automated_data_fetcher.pb.pitching_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fangraphs_data(
|
||||
self,
|
||||
mock_pitching,
|
||||
@ -231,6 +233,7 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats_range")
|
||||
@patch("automated_data_fetcher.pb.pitching_stats_range")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fangraphs_data_with_dates(
|
||||
self,
|
||||
mock_pitching,
|
||||
@ -253,6 +256,7 @@ class TestDataFetcher:
|
||||
mock_pitching.assert_called_once_with(start_date, end_date)
|
||||
|
||||
@patch("automated_data_fetcher.get_all_pybaseball_ids")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_players_existing_function(self, mock_get_ids, fetcher):
|
||||
"""Test getting player IDs using existing function"""
|
||||
mock_get_ids.return_value = ["12345", "67890", "11111"]
|
||||
@ -264,6 +268,7 @@ class TestDataFetcher:
|
||||
|
||||
@patch("automated_data_fetcher.get_all_pybaseball_ids")
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_players_fallback(
|
||||
self, mock_batting, mock_get_ids, fetcher, sample_batting_data
|
||||
):
|
||||
@ -279,6 +284,7 @@ class TestDataFetcher:
|
||||
assert result == expected_ids
|
||||
|
||||
@patch("automated_data_fetcher.pb.get_splits")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_player_splits(
|
||||
self, mock_get_splits, fetcher, sample_splits_data
|
||||
):
|
||||
@ -333,6 +339,7 @@ class TestLiveSeriesDataFetcher:
|
||||
|
||||
@patch.object(DataFetcher, "fetch_baseball_reference_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):
|
||||
"""Test fetching live series data"""
|
||||
# Mock return values
|
||||
@ -360,6 +367,7 @@ class TestUtilityFunctions:
|
||||
"""Test cases for utility functions"""
|
||||
|
||||
@patch("automated_data_fetcher.DataFetcher")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_season_data(self, mock_fetcher_class):
|
||||
"""Test fetch_season_data function"""
|
||||
# Create mock fetcher instance
|
||||
@ -389,6 +397,7 @@ class TestUtilityFunctions:
|
||||
assert any("AUTOMATED DOWNLOAD COMPLETE" in call for call in print_calls)
|
||||
|
||||
@patch("automated_data_fetcher.LiveSeriesDataFetcher")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_live_series_data(self, mock_fetcher_class):
|
||||
"""Test fetch_live_series_data function"""
|
||||
# Create mock fetcher instance
|
||||
@ -416,6 +425,7 @@ class TestErrorHandling:
|
||||
return DataFetcher(2023, "Season")
|
||||
|
||||
@patch("automated_data_fetcher.pb.pitching_stats_bref")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_baseball_reference_data_error(self, mock_pitching, fetcher):
|
||||
"""Test error handling in Baseball Reference data fetch"""
|
||||
# Mock function to raise an exception
|
||||
@ -425,6 +435,7 @@ class TestErrorHandling:
|
||||
await fetcher.fetch_baseball_reference_data()
|
||||
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_fangraphs_data_error(self, mock_batting, fetcher):
|
||||
"""Test error handling in FanGraphs data fetch"""
|
||||
# Mock function to raise an exception
|
||||
@ -435,6 +446,7 @@ class TestErrorHandling:
|
||||
|
||||
@patch("automated_data_fetcher.get_all_pybaseball_ids")
|
||||
@patch("automated_data_fetcher.pb.batting_stats")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_active_players_complete_failure(
|
||||
self, mock_batting, mock_get_ids, fetcher
|
||||
):
|
||||
@ -449,6 +461,7 @@ class TestErrorHandling:
|
||||
assert result == []
|
||||
|
||||
@patch("automated_data_fetcher.pb.get_splits")
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_player_splits_individual_errors(
|
||||
self, mock_get_splits, fetcher
|
||||
):
|
||||
@ -479,6 +492,7 @@ class TestIntegration:
|
||||
"""Integration tests that require network access"""
|
||||
|
||||
@pytest.mark.skip(reason="Requires network access and may be slow")
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_data_fetch(self):
|
||||
"""Test fetching real data from pybaseball (skip by default)"""
|
||||
fetcher = DataFetcher(2022, "Season") # Use a complete season
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
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
|
||||
from creation_helpers import mround, sanitize_chance_output
|
||||
|
||||
|
||||
def test_mround():
|
||||
|
||||
Loading…
Reference in New Issue
Block a user