feat: refractor card art pipeline — render trigger + /player view #139

Merged
cal merged 3 commits from feat/refractor-card-art-pipeline into main 2026-04-06 22:34:02 +00:00
4 changed files with 477 additions and 92 deletions

View File

@ -28,7 +28,7 @@ import helpers
from in_game.gameplay_queries import get_team_or_none
from in_game.simulations import get_pos_embeds, get_result
from in_game.gameplay_models import Lineup, Play, Session, engine
from api_calls import db_get, db_post, db_patch, get_team_by_abbrev
from api_calls import db_get, db_post, db_patch, get_team_by_abbrev, DB_URL
from helpers import (
ACTIVE_EVENT_LITERAL,
PD_PLAYERS_ROLE_NAME,
@ -293,29 +293,29 @@ def get_ai_records(short_games, long_games):
if line["away_team"]["is_ai"]:
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"w"
] += (1 if home_win else 0)
] += 1 if home_win else 0
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"l"
] += (1 if not home_win else 0)
] += 1 if not home_win else 0
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"points"
] += (2 if home_win else 1)
] += 2 if home_win else 1
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"rd"
] += (line["home_score"] - line["away_score"])
] += line["home_score"] - line["away_score"]
elif line["home_team"]["is_ai"]:
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"w"
] += (1 if not home_win else 0)
] += 1 if not home_win else 0
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"l"
] += (1 if home_win else 0)
] += 1 if home_win else 0
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"points"
] += (2 if not home_win else 1)
] += 2 if not home_win else 1
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"rd"
] += (line["away_score"] - line["home_score"])
] += line["away_score"] - line["home_score"]
logger.debug(f"done league games")
return all_results
@ -367,51 +367,51 @@ def get_record_embed_legacy(embed: discord.Embed, results: dict, league: str):
embed.add_field(
name=f"AL East ({ale_points} pts)",
value=f'BAL: {results["BAL"][league]["w"]} - {results["BAL"][league]["l"]} ({results["BAL"][league]["rd"]} RD)\n'
f'BOS: {results["BOS"][league]["w"]} - {results["BOS"][league]["l"]} ({results["BOS"][league]["rd"]} RD)\n'
f'NYY: {results["NYY"][league]["w"]} - {results["NYY"][league]["l"]} ({results["NYY"][league]["rd"]} RD)\n'
f'TBR: {results["TBR"][league]["w"]} - {results["TBR"][league]["l"]} ({results["TBR"][league]["rd"]} RD)\n'
f'TOR: {results["TOR"][league]["w"]} - {results["TOR"][league]["l"]} ({results["TOR"][league]["rd"]} RD)\n',
value=f"BAL: {results['BAL'][league]['w']} - {results['BAL'][league]['l']} ({results['BAL'][league]['rd']} RD)\n"
f"BOS: {results['BOS'][league]['w']} - {results['BOS'][league]['l']} ({results['BOS'][league]['rd']} RD)\n"
f"NYY: {results['NYY'][league]['w']} - {results['NYY'][league]['l']} ({results['NYY'][league]['rd']} RD)\n"
f"TBR: {results['TBR'][league]['w']} - {results['TBR'][league]['l']} ({results['TBR'][league]['rd']} RD)\n"
f"TOR: {results['TOR'][league]['w']} - {results['TOR'][league]['l']} ({results['TOR'][league]['rd']} RD)\n",
)
embed.add_field(
name=f"AL Central ({alc_points} pts)",
value=f'CLE: {results["CLE"][league]["w"]} - {results["CLE"][league]["l"]} ({results["CLE"][league]["rd"]} RD)\n'
f'CHW: {results["CHW"][league]["w"]} - {results["CHW"][league]["l"]} ({results["CHW"][league]["rd"]} RD)\n'
f'DET: {results["DET"][league]["w"]} - {results["DET"][league]["l"]} ({results["DET"][league]["rd"]} RD)\n'
f'KCR: {results["KCR"][league]["w"]} - {results["KCR"][league]["l"]} ({results["KCR"][league]["rd"]} RD)\n'
f'MIN: {results["MIN"][league]["w"]} - {results["MIN"][league]["l"]} ({results["MIN"][league]["rd"]} RD)\n',
value=f"CLE: {results['CLE'][league]['w']} - {results['CLE'][league]['l']} ({results['CLE'][league]['rd']} RD)\n"
f"CHW: {results['CHW'][league]['w']} - {results['CHW'][league]['l']} ({results['CHW'][league]['rd']} RD)\n"
f"DET: {results['DET'][league]['w']} - {results['DET'][league]['l']} ({results['DET'][league]['rd']} RD)\n"
f"KCR: {results['KCR'][league]['w']} - {results['KCR'][league]['l']} ({results['KCR'][league]['rd']} RD)\n"
f"MIN: {results['MIN'][league]['w']} - {results['MIN'][league]['l']} ({results['MIN'][league]['rd']} RD)\n",
)
embed.add_field(
name=f"AL West ({alw_points} pts)",
value=f'HOU: {results["HOU"][league]["w"]} - {results["HOU"][league]["l"]} ({results["HOU"][league]["rd"]} RD)\n'
f'LAA: {results["LAA"][league]["w"]} - {results["LAA"][league]["l"]} ({results["LAA"][league]["rd"]} RD)\n'
f'OAK: {results["OAK"][league]["w"]} - {results["OAK"][league]["l"]} ({results["OAK"][league]["rd"]} RD)\n'
f'SEA: {results["SEA"][league]["w"]} - {results["SEA"][league]["l"]} ({results["SEA"][league]["rd"]} RD)\n'
f'TEX: {results["TEX"][league]["w"]} - {results["TEX"][league]["l"]} ({results["TEX"][league]["rd"]} RD)\n',
value=f"HOU: {results['HOU'][league]['w']} - {results['HOU'][league]['l']} ({results['HOU'][league]['rd']} RD)\n"
f"LAA: {results['LAA'][league]['w']} - {results['LAA'][league]['l']} ({results['LAA'][league]['rd']} RD)\n"
f"OAK: {results['OAK'][league]['w']} - {results['OAK'][league]['l']} ({results['OAK'][league]['rd']} RD)\n"
f"SEA: {results['SEA'][league]['w']} - {results['SEA'][league]['l']} ({results['SEA'][league]['rd']} RD)\n"
f"TEX: {results['TEX'][league]['w']} - {results['TEX'][league]['l']} ({results['TEX'][league]['rd']} RD)\n",
)
embed.add_field(
name=f"NL East ({nle_points} pts)",
value=f'ATL: {results["ATL"][league]["w"]} - {results["ATL"][league]["l"]} ({results["ATL"][league]["rd"]} RD)\n'
f'MIA: {results["MIA"][league]["w"]} - {results["MIA"][league]["l"]} ({results["MIA"][league]["rd"]} RD)\n'
f'NYM: {results["NYM"][league]["w"]} - {results["NYM"][league]["l"]} ({results["NYM"][league]["rd"]} RD)\n'
f'PHI: {results["PHI"][league]["w"]} - {results["PHI"][league]["l"]} ({results["PHI"][league]["rd"]} RD)\n'
f'WSN: {results["WSN"][league]["w"]} - {results["WSN"][league]["l"]} ({results["WSN"][league]["rd"]} RD)\n',
value=f"ATL: {results['ATL'][league]['w']} - {results['ATL'][league]['l']} ({results['ATL'][league]['rd']} RD)\n"
f"MIA: {results['MIA'][league]['w']} - {results['MIA'][league]['l']} ({results['MIA'][league]['rd']} RD)\n"
f"NYM: {results['NYM'][league]['w']} - {results['NYM'][league]['l']} ({results['NYM'][league]['rd']} RD)\n"
f"PHI: {results['PHI'][league]['w']} - {results['PHI'][league]['l']} ({results['PHI'][league]['rd']} RD)\n"
f"WSN: {results['WSN'][league]['w']} - {results['WSN'][league]['l']} ({results['WSN'][league]['rd']} RD)\n",
)
embed.add_field(
name=f"NL Central ({nlc_points} pts)",
value=f'CHC: {results["CHC"][league]["w"]} - {results["CHC"][league]["l"]} ({results["CHC"][league]["rd"]} RD)\n'
f'CHW: {results["CIN"][league]["w"]} - {results["CIN"][league]["l"]} ({results["CIN"][league]["rd"]} RD)\n'
f'MIL: {results["MIL"][league]["w"]} - {results["MIL"][league]["l"]} ({results["MIL"][league]["rd"]} RD)\n'
f'PIT: {results["PIT"][league]["w"]} - {results["PIT"][league]["l"]} ({results["PIT"][league]["rd"]} RD)\n'
f'STL: {results["STL"][league]["w"]} - {results["STL"][league]["l"]} ({results["STL"][league]["rd"]} RD)\n',
value=f"CHC: {results['CHC'][league]['w']} - {results['CHC'][league]['l']} ({results['CHC'][league]['rd']} RD)\n"
f"CHW: {results['CIN'][league]['w']} - {results['CIN'][league]['l']} ({results['CIN'][league]['rd']} RD)\n"
f"MIL: {results['MIL'][league]['w']} - {results['MIL'][league]['l']} ({results['MIL'][league]['rd']} RD)\n"
f"PIT: {results['PIT'][league]['w']} - {results['PIT'][league]['l']} ({results['PIT'][league]['rd']} RD)\n"
f"STL: {results['STL'][league]['w']} - {results['STL'][league]['l']} ({results['STL'][league]['rd']} RD)\n",
)
embed.add_field(
name=f"NL West ({nlw_points} pts)",
value=f'ARI: {results["ARI"][league]["w"]} - {results["ARI"][league]["l"]} ({results["ARI"][league]["rd"]} RD)\n'
f'COL: {results["COL"][league]["w"]} - {results["COL"][league]["l"]} ({results["COL"][league]["rd"]} RD)\n'
f'LAD: {results["LAD"][league]["w"]} - {results["LAD"][league]["l"]} ({results["LAD"][league]["rd"]} RD)\n'
f'SDP: {results["SDP"][league]["w"]} - {results["SDP"][league]["l"]} ({results["SDP"][league]["rd"]} RD)\n'
f'SFG: {results["SFG"][league]["w"]} - {results["SFG"][league]["l"]} ({results["SFG"][league]["rd"]} RD)\n',
value=f"ARI: {results['ARI'][league]['w']} - {results['ARI'][league]['l']} ({results['ARI'][league]['rd']} RD)\n"
f"COL: {results['COL'][league]['w']} - {results['COL'][league]['l']} ({results['COL'][league]['rd']} RD)\n"
f"LAD: {results['LAD'][league]['w']} - {results['LAD'][league]['l']} ({results['LAD'][league]['rd']} RD)\n"
f"SDP: {results['SDP'][league]['w']} - {results['SDP'][league]['l']} ({results['SDP'][league]['rd']} RD)\n"
f"SFG: {results['SFG'][league]['w']} - {results['SFG'][league]['l']} ({results['SFG'][league]['rd']} RD)\n",
)
return embed
@ -421,56 +421,134 @@ def get_record_embed(team: dict, results: dict, league: str):
embed = get_team_embed(league, team)
embed.add_field(
name=f"AL East",
value=f'BAL: {results["BAL"][0]} - {results["BAL"][1]} ({results["BAL"][2]} RD)\n'
f'BOS: {results["BOS"][0]} - {results["BOS"][1]} ({results["BOS"][2]} RD)\n'
f'NYY: {results["NYY"][0]} - {results["NYY"][1]} ({results["NYY"][2]} RD)\n'
f'TBR: {results["TBR"][0]} - {results["TBR"][1]} ({results["TBR"][2]} RD)\n'
f'TOR: {results["TOR"][0]} - {results["TOR"][1]} ({results["TOR"][2]} RD)\n',
value=f"BAL: {results['BAL'][0]} - {results['BAL'][1]} ({results['BAL'][2]} RD)\n"
f"BOS: {results['BOS'][0]} - {results['BOS'][1]} ({results['BOS'][2]} RD)\n"
f"NYY: {results['NYY'][0]} - {results['NYY'][1]} ({results['NYY'][2]} RD)\n"
f"TBR: {results['TBR'][0]} - {results['TBR'][1]} ({results['TBR'][2]} RD)\n"
f"TOR: {results['TOR'][0]} - {results['TOR'][1]} ({results['TOR'][2]} RD)\n",
)
embed.add_field(
name=f"AL Central",
value=f'CLE: {results["CLE"][0]} - {results["CLE"][1]} ({results["CLE"][2]} RD)\n'
f'CHW: {results["CHW"][0]} - {results["CHW"][1]} ({results["CHW"][2]} RD)\n'
f'DET: {results["DET"][0]} - {results["DET"][1]} ({results["DET"][2]} RD)\n'
f'KCR: {results["KCR"][0]} - {results["KCR"][1]} ({results["KCR"][2]} RD)\n'
f'MIN: {results["MIN"][0]} - {results["MIN"][1]} ({results["MIN"][2]} RD)\n',
value=f"CLE: {results['CLE'][0]} - {results['CLE'][1]} ({results['CLE'][2]} RD)\n"
f"CHW: {results['CHW'][0]} - {results['CHW'][1]} ({results['CHW'][2]} RD)\n"
f"DET: {results['DET'][0]} - {results['DET'][1]} ({results['DET'][2]} RD)\n"
f"KCR: {results['KCR'][0]} - {results['KCR'][1]} ({results['KCR'][2]} RD)\n"
f"MIN: {results['MIN'][0]} - {results['MIN'][1]} ({results['MIN'][2]} RD)\n",
)
embed.add_field(
name=f"AL West",
value=f'HOU: {results["HOU"][0]} - {results["HOU"][1]} ({results["HOU"][2]} RD)\n'
f'LAA: {results["LAA"][0]} - {results["LAA"][1]} ({results["LAA"][2]} RD)\n'
f'OAK: {results["OAK"][0]} - {results["OAK"][1]} ({results["OAK"][2]} RD)\n'
f'SEA: {results["SEA"][0]} - {results["SEA"][1]} ({results["SEA"][2]} RD)\n'
f'TEX: {results["TEX"][0]} - {results["TEX"][1]} ({results["TEX"][2]} RD)\n',
value=f"HOU: {results['HOU'][0]} - {results['HOU'][1]} ({results['HOU'][2]} RD)\n"
f"LAA: {results['LAA'][0]} - {results['LAA'][1]} ({results['LAA'][2]} RD)\n"
f"OAK: {results['OAK'][0]} - {results['OAK'][1]} ({results['OAK'][2]} RD)\n"
f"SEA: {results['SEA'][0]} - {results['SEA'][1]} ({results['SEA'][2]} RD)\n"
f"TEX: {results['TEX'][0]} - {results['TEX'][1]} ({results['TEX'][2]} RD)\n",
)
embed.add_field(
name=f"NL East",
value=f'ATL: {results["ATL"][0]} - {results["ATL"][1]} ({results["ATL"][2]} RD)\n'
f'MIA: {results["MIA"][0]} - {results["MIA"][1]} ({results["MIA"][2]} RD)\n'
f'NYM: {results["NYM"][0]} - {results["NYM"][1]} ({results["NYM"][2]} RD)\n'
f'PHI: {results["PHI"][0]} - {results["PHI"][1]} ({results["PHI"][2]} RD)\n'
f'WSN: {results["WSN"][0]} - {results["WSN"][1]} ({results["WSN"][2]} RD)\n',
value=f"ATL: {results['ATL'][0]} - {results['ATL'][1]} ({results['ATL'][2]} RD)\n"
f"MIA: {results['MIA'][0]} - {results['MIA'][1]} ({results['MIA'][2]} RD)\n"
f"NYM: {results['NYM'][0]} - {results['NYM'][1]} ({results['NYM'][2]} RD)\n"
f"PHI: {results['PHI'][0]} - {results['PHI'][1]} ({results['PHI'][2]} RD)\n"
f"WSN: {results['WSN'][0]} - {results['WSN'][1]} ({results['WSN'][2]} RD)\n",
)
embed.add_field(
name=f"NL Central",
value=f'CHC: {results["CHC"][0]} - {results["CHC"][1]} ({results["CHC"][2]} RD)\n'
f'CIN: {results["CIN"][0]} - {results["CIN"][1]} ({results["CIN"][2]} RD)\n'
f'MIL: {results["MIL"][0]} - {results["MIL"][1]} ({results["MIL"][2]} RD)\n'
f'PIT: {results["PIT"][0]} - {results["PIT"][1]} ({results["PIT"][2]} RD)\n'
f'STL: {results["STL"][0]} - {results["STL"][1]} ({results["STL"][2]} RD)\n',
value=f"CHC: {results['CHC'][0]} - {results['CHC'][1]} ({results['CHC'][2]} RD)\n"
f"CIN: {results['CIN'][0]} - {results['CIN'][1]} ({results['CIN'][2]} RD)\n"
f"MIL: {results['MIL'][0]} - {results['MIL'][1]} ({results['MIL'][2]} RD)\n"
f"PIT: {results['PIT'][0]} - {results['PIT'][1]} ({results['PIT'][2]} RD)\n"
f"STL: {results['STL'][0]} - {results['STL'][1]} ({results['STL'][2]} RD)\n",
)
embed.add_field(
name=f"NL West",
value=f'ARI: {results["ARI"][0]} - {results["ARI"][1]} ({results["ARI"][2]} RD)\n'
f'COL: {results["COL"][0]} - {results["COL"][1]} ({results["COL"][2]} RD)\n'
f'LAD: {results["LAD"][0]} - {results["LAD"][1]} ({results["LAD"][2]} RD)\n'
f'SDP: {results["SDP"][0]} - {results["SDP"][1]} ({results["SDP"][2]} RD)\n'
f'SFG: {results["SFG"][0]} - {results["SFG"][1]} ({results["SFG"][2]} RD)\n',
value=f"ARI: {results['ARI'][0]} - {results['ARI'][1]} ({results['ARI'][2]} RD)\n"
f"COL: {results['COL'][0]} - {results['COL'][1]} ({results['COL'][2]} RD)\n"
f"LAD: {results['LAD'][0]} - {results['LAD'][1]} ({results['LAD'][2]} RD)\n"
f"SDP: {results['SDP'][0]} - {results['SDP'][1]} ({results['SDP'][2]} RD)\n"
f"SFG: {results['SFG'][0]} - {results['SFG'][1]} ({results['SFG'][2]} RD)\n",
)
return embed
REFRACTOR_TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
async def _build_refractor_response(
player_name: str,
player_id: int,
refractor_tier: int,
refractor_data: dict,
) -> dict:
"""Build response data for a /player refractor_tier request.
Returns a dict with:
- found: bool
- image_url: str or None
- needs_render: bool
- variant: int
- card_type: str
- player_name: str
- tier_name: str
- current_tier: int (when found)
- top_cards: list (when not found)
"""
items = refractor_data.get("items", [])
match = None
for item in items:
if item["player_id"] == player_id and item["current_tier"] >= refractor_tier:
match = item
break
if match:
# Map track card_type to the URL card_type format
track_type = match.get("track", {}).get("card_type", "batter")
card_type = "pitching" if track_type in ("sp", "rp") else "batting"
return {
"found": True,
"image_url": match.get("image_url"),
"needs_render": match.get("image_url") is None,
"variant": match.get("variant", 0),
"card_type": card_type,
"player_name": player_name,
"tier_name": REFRACTOR_TIER_NAMES.get(refractor_tier, f"T{refractor_tier}"),
"current_tier": match["current_tier"],
"top_cards": [],
}
sorted_cards = sorted(
items, key=lambda x: (-x["current_tier"], -x.get("current_value", 0))
)
top_cards = []
for card in sorted_cards[:5]:
tier = card["current_tier"]
top_cards.append(
{
"player_name": card.get("player_name", "Unknown"),
"tier": tier,
"tier_name": REFRACTOR_TIER_NAMES.get(tier, f"T{tier}"),
"image_url": card.get("image_url"),
}
)
return {
"found": False,
"image_url": None,
"needs_render": False,
"variant": 0,
"player_name": player_name,
"tier_name": REFRACTOR_TIER_NAMES.get(refractor_tier, f"T{refractor_tier}"),
"top_cards": top_cards,
}
class Players(commands.Cog):
def __init__(self, bot):
self.bot = bot
@ -650,12 +728,83 @@ class Players(commands.Cog):
name="player", description="Display one or more of the player's cards"
)
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
@app_commands.describe(
refractor_tier="View a refractor tier of this card (1-4)",
)
@app_commands.autocomplete(
player_name=player_autocomplete, cardset=cardset_autocomplete
)
async def player_slash_command(
self, interaction: discord.Interaction, player_name: str, cardset: str = "All"
self,
interaction: discord.Interaction,
player_name: str,
cardset: str = "All",
refractor_tier: Optional[int] = None,
):
if refractor_tier is not None:
await interaction.response.defer()
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.edit_original_response(
content="You don't have a team yet. Join a team first!"
)
return
this_player = fuzzy_search(player_name, self.player_list)
player_data = await db_get("players", params=[("name", this_player)])
if not player_data or not player_data.get("players"):
await interaction.edit_original_response(
content=f"Player '{player_name}' not found."
)
return
pid = player_data["players"][0]["id"]
refractor_data = await db_get(
"refractor/cards", params=[("team_id", team["id"]), ("limit", 100)]
)
if not refractor_data:
refractor_data = {"count": 0, "items": []}
result = await _build_refractor_response(
player_name=this_player,
player_id=pid,
refractor_tier=refractor_tier,
refractor_data=refractor_data,
)
if result["found"]:
embed = discord.Embed(
title=f"{result['player_name']}{result['tier_name']}",
color=discord.Color.gold(),
)
if result["needs_render"]:
today = datetime.date.today().isoformat()
card_type = result.get("card_type", "batting")
render_url = f"{DB_URL}/v2/players/{pid}/{card_type}card/{today}/{result['variant']}"
embed.set_image(url=render_url)
embed.set_footer(text="First render — image generating...")
else:
embed.set_image(url=result["image_url"])
await interaction.edit_original_response(embed=embed)
else:
msg = f"You don't have a T{refractor_tier} refractor of **{this_player}**."
if result["top_cards"]:
msg += "\n\nYour top refractor cards:"
for card in result["top_cards"]:
tier_label = f"T{card['tier']} {card['tier_name']}"
if card["image_url"]:
msg += f"\n> [{card['player_name']}{tier_label}]({card['image_url']})"
else:
msg += f"\n> {card['player_name']}{tier_label}"
else:
msg += "\n\nYou don't have any refractor cards yet. Play games to earn them!"
await interaction.edit_original_response(content=msg)
return
ephemeral = False
if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]:
ephemeral = True
@ -694,7 +843,7 @@ class Players(commands.Cog):
if len(all_embeds) > 1:
await interaction.edit_original_response(
content=f'# {all_players["players"][0]["p_name"]}'
content=f"# {all_players['players'][0]['p_name']}"
)
await embed_pagination(
all_embeds,
@ -788,11 +937,11 @@ class Players(commands.Cog):
current = await db_get("current")
await interaction.response.send_message(
f'I\'m tallying the {team["lname"]} results now...', ephemeral=ephemeral
f"I'm tallying the {team['lname']} results now...", ephemeral=ephemeral
)
st_query = await db_get(
f'teams/{team["id"]}/season-record', object_id=current["season"]
f"teams/{team['id']}/season-record", object_id=current["season"]
)
minor_embed = get_record_embed(team, st_query["minor-league"], "Minor League")
@ -812,7 +961,7 @@ class Players(commands.Cog):
start_page = 3
await interaction.edit_original_response(
content=f'Here are the {team["lname"]} campaign records'
content=f"Here are the {team['lname']} campaign records"
)
await embed_pagination(
[minor_embed, major_embed, flashback_embed, hof_embed],
@ -856,18 +1005,18 @@ class Players(commands.Cog):
c_query = await db_get("cards", object_id=card_id)
if c_query:
c_string = (
f'Card ID {card_id} is a {helpers.player_desc(c_query["player"])}'
f"Card ID {card_id} is a {helpers.player_desc(c_query['player'])}"
)
if c_query["team"] is not None:
c_string += f' owned by the {c_query["team"]["sname"]}'
c_string += f" owned by the {c_query['team']['sname']}"
if c_query["pack"] is not None:
c_string += (
f' pulled from a {c_query["pack"]["pack_type"]["name"]} pack.'
f" pulled from a {c_query['pack']['pack_type']['name']} pack."
)
else:
c_query["team"] = c_query["pack"]["team"]
c_string += (
f' used by the {c_query["pack"]["team"]["sname"]} in a gauntlet'
f" used by the {c_query['pack']['team']['sname']} in a gauntlet"
)
await interaction.edit_original_response(
@ -947,7 +1096,7 @@ class Players(commands.Cog):
await ctx.send(f"Who?")
return
await ctx.send(f'{t_query["teams"][0]["sname"]} are a bunch of cuties!')
await ctx.send(f"{t_query['teams'][0]['sname']} are a bunch of cuties!")
@commands.hybrid_command(name="random", help="Check out a random card")
@commands.has_any_role(PD_PLAYERS_ROLE_NAME)
@ -1074,7 +1223,7 @@ class Players(commands.Cog):
r_query = await db_get("results", params=params)
if not r_query["count"]:
await ctx.send(
f'There are no Ranked games on record this {"week" if which == "week" else "season"}.'
f"There are no Ranked games on record this {'week' if which == 'week' else 'season'}."
)
return
@ -1116,8 +1265,8 @@ class Players(commands.Cog):
# await ctx.send(f'sorted: {sorted_records}')
embed = get_team_embed(
title=f'{"Season" if which == "season" else "Week"} '
f'{current["season"] if which == "season" else current["week"]} Standings'
title=f"{'Season' if which == 'season' else 'Week'} "
f"{current['season'] if which == 'season' else current['week']} Standings"
)
chunk_string = ""
@ -1126,8 +1275,8 @@ class Players(commands.Cog):
team = await db_get("teams", object_id=record[0])
if team:
chunk_string += (
f'{record[1]["points"]} pt{"s" if record[1]["points"] != 1 else ""} '
f'({record[1]["wins"]}-{record[1]["losses"]}) - {team["sname"]} [{team["ranking"]}]\n'
f"{record[1]['points']} pt{'s' if record[1]['points'] != 1 else ''} "
f"({record[1]['wins']}-{record[1]['losses']}) - {team['sname']} [{team['ranking']}]\n"
)
else:
@ -1237,7 +1386,7 @@ class Players(commands.Cog):
this_run = r_query["runs"][0]
else:
await interaction.channel.send(
content=f'I do not see an active run for the {this_team["lname"]}.'
content=f"I do not see an active run for the {this_team['lname']}."
)
else:
await interaction.channel.send(
@ -1311,7 +1460,7 @@ class Players(commands.Cog):
if r_query["count"] != 0:
await interaction.edit_original_response(
content=f'Looks like you already have a {r_query["runs"][0]["gauntlet"]["name"]} run active! '
content=f"Looks like you already have a {r_query['runs'][0]['gauntlet']['name']} run active! "
f"You can check it out with the `/gauntlets status` command."
)
return
@ -1322,7 +1471,7 @@ class Players(commands.Cog):
)
except ZeroDivisionError as e:
logger.error(
f'ZeroDivisionError in {this_event["name"]} draft for the {main_team.sname}: {e}'
f"ZeroDivisionError in {this_event['name']} draft for the {main_team.sname}: {e}"
)
await gauntlets.wipe_team(draft_team, interaction)
await interaction.channel.send(
@ -1333,7 +1482,7 @@ class Players(commands.Cog):
return
except Exception as e:
logger.error(
f'Failed to run {this_event["name"]} draft for the {main_team.sname}: {e}'
f"Failed to run {this_event['name']} draft for the {main_team.sname}: {e}"
)
await gauntlets.wipe_team(draft_team, interaction)
await interaction.channel.send(
@ -1348,7 +1497,7 @@ class Players(commands.Cog):
f"Good luck, champ in the making! To start playing, follow these steps:\n\n"
f"1) Make a copy of the Team Sheet Template found in `/help-pd links`\n"
f"2) Run `/newsheet` to link it to your Gauntlet team\n"
f'3) Go play your first game with `/new-game gauntlet {this_event["name"]}`'
f"3) Go play your first game with `/new-game gauntlet {this_event['name']}`"
)
else:
await interaction.channel.send(
@ -1360,7 +1509,7 @@ class Players(commands.Cog):
await helpers.send_to_channel(
bot=self.bot,
channel_name="pd-news-ticker",
content=f'The {main_team.lname} have entered the {this_event["name"]} Gauntlet!',
content=f"The {main_team.lname} have entered the {this_event['name']} Gauntlet!",
embed=draft_embed,
)
@ -1373,7 +1522,7 @@ class Players(commands.Cog):
): # type: ignore
await interaction.response.defer()
main_team = await get_team_by_owner(interaction.user.id)
draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}')
draft_team = await get_team_by_abbrev(f"Gauntlet-{main_team['abbrev']}")
if draft_team is None:
await interaction.edit_original_response(
content="Hmm, I can't find a gauntlet team for you. Have you signed up already?"
@ -1404,7 +1553,7 @@ class Players(commands.Cog):
this_run = r_query["runs"][0]
else:
await interaction.edit_original_response(
content=f'I do not see an active run for the {draft_team["lname"]}.'
content=f"I do not see an active run for the {draft_team['lname']}."
)
return

View File

@ -1,5 +1,6 @@
import asyncio
import copy
import datetime
import logging
import discord
from discord import SelectOption
@ -4243,6 +4244,34 @@ async def get_game_summary_embed(
return game_embed
async def _trigger_variant_renders(tier_ups: list) -> None:
"""Fire-and-forget: hit card render URLs to trigger S3 upload for new variants.
Each tier-up with a variant_created value gets a GET request to the card
render endpoint, which triggers Playwright render + S3 upload as a side effect.
Failures are logged but never raised.
"""
today = datetime.date.today().isoformat()
for tier_up in tier_ups:
variant = tier_up.get("variant_created")
if variant is None:
continue
player_id = tier_up["player_id"]
track = tier_up.get("track_name", "Batter")
card_type = "pitching" if track.lower() == "pitcher" else "batting"
try:
await db_get(
f"players/{player_id}/{card_type}card/{today}/{variant}",
none_okay=True,
)
except Exception:
logger.warning(
"Failed to trigger variant render for player %d variant %d (non-fatal)",
player_id,
variant,
)
async def complete_game(
session: Session,
interaction: discord.Interaction,
@ -4353,6 +4382,7 @@ async def complete_game(
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
await notify_tier_completion(interaction.channel, tier_up)
await _trigger_variant_renders(evo_result["tier_ups"])
except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")

View File

@ -0,0 +1,141 @@
"""Tests for /player refractor_tier view.
Tests cover _build_refractor_response, a module-level helper that processes
raw API refractor data and returns structured response data for the slash command.
The function is pure (no network calls) so tests run without mocks for the
happy path cases, keeping tests readable and fast.
"""
import sys
import os
import pytest
# Make the repo root importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cogs.players import _build_refractor_response
REFRACTOR_CARDS_RESPONSE = {
"count": 3,
"items": [
{
"player_id": 100,
"player_name": "Mike Trout",
"current_tier": 3,
"current_value": 160,
"variant": 7,
"track": {"card_type": "batter"},
"image_url": "https://s3.example.com/cards/cardset-027/player-100/v7/battingcard.png",
},
{
"player_id": 200,
"player_name": "Barry Bonds",
"current_tier": 2,
"current_value": 110,
"variant": 3,
"track": {"card_type": "batter"},
"image_url": "https://s3.example.com/cards/cardset-027/player-200/v3/battingcard.png",
},
{
"player_id": 300,
"player_name": "Ken Griffey Jr.",
"current_tier": 1,
"current_value": 55,
"variant": 1,
"track": {"card_type": "batter"},
"image_url": None,
},
],
}
class TestBuildRefractorResponse:
"""Build embed content for /player refractor_tier views."""
@pytest.mark.asyncio
async def test_happy_path_returns_embed_with_image(self):
"""When user has the refractor at requested tier, embed includes S3 image.
Verifies that when a player_id match is found at or above the requested
tier, the result is marked as found and the image_url is passed through.
"""
result = await _build_refractor_response(
player_name="Mike Trout",
player_id=100,
refractor_tier=3,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is True
assert "s3.example.com" in result["image_url"]
@pytest.mark.asyncio
async def test_not_found_returns_top_5(self):
"""When user doesn't have the refractor, show top 5 cards.
Verifies that when no match is found for the given player_id + tier,
the response includes the top cards sorted by tier descending, and
the highest-tier card appears first.
"""
result = await _build_refractor_response(
player_name="Nobody",
player_id=999,
refractor_tier=2,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is False
assert len(result["top_cards"]) <= 5
assert result["top_cards"][0]["player_name"] == "Mike Trout"
@pytest.mark.asyncio
async def test_image_url_none_triggers_render(self):
"""When refractor exists but image_url is None, result signals render needed.
A card may exist at the requested tier without a cached S3 image URL
if it has never been rendered. The response should set needs_render=True
so the caller can construct a render endpoint URL and show a placeholder.
"""
result = await _build_refractor_response(
player_name="Ken Griffey Jr.",
player_id=300,
refractor_tier=1,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is True
assert result["image_url"] is None
assert result["needs_render"] is True
assert result["variant"] == 1
@pytest.mark.asyncio
async def test_no_refractors_at_all(self):
"""When user has zero refractor cards, clean message.
An empty items list should produce found=False with an empty top_cards
list, allowing the caller to show a "no refractors yet" message.
"""
empty_data = {"count": 0, "items": []}
result = await _build_refractor_response(
player_name="Someone",
player_id=500,
refractor_tier=1,
refractor_data=empty_data,
)
assert result["found"] is False
assert result["top_cards"] == []
@pytest.mark.asyncio
async def test_tier_higher_than_current_not_found(self):
"""Requesting T4 when player is at T3 returns not found.
The match condition requires current_tier >= refractor_tier. Requesting
a tier the player hasn't reached should return found=False so the
caller can show what tier they do have.
"""
result = await _build_refractor_response(
player_name="Mike Trout",
player_id=100,
refractor_tier=4,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is False

View File

@ -0,0 +1,65 @@
"""Tests for post-game refractor card render trigger."""
from unittest.mock import AsyncMock, patch
import pytest
from command_logic.logic_gameplay import _trigger_variant_renders
class TestTriggerVariantRenders:
"""Fire-and-forget card render calls after tier-ups."""
@pytest.mark.asyncio
async def test_calls_render_url_for_each_tier_up(self):
"""Each tier-up with variant_created triggers a card render GET request."""
tier_ups = [
{"player_id": 100, "variant_created": 7, "track_name": "Batter"},
{"player_id": 200, "variant_created": 3, "track_name": "Pitcher"},
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
await _trigger_variant_renders(tier_ups)
assert mock_get.call_count == 2
call_args_list = [call.args[0] for call in mock_get.call_args_list]
assert any("100" in url and "7" in url for url in call_args_list)
assert any("200" in url and "3" in url for url in call_args_list)
@pytest.mark.asyncio
async def test_skips_tier_ups_without_variant(self):
"""Tier-ups without variant_created are skipped."""
tier_ups = [
{"player_id": 100, "track_name": "Batter"},
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
await _trigger_variant_renders(tier_ups)
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_api_failure_does_not_raise(self):
"""Render trigger failures are swallowed — fire-and-forget."""
tier_ups = [
{"player_id": 100, "variant_created": 7, "track_name": "Batter"},
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.side_effect = Exception("API down")
await _trigger_variant_renders(tier_ups)
@pytest.mark.asyncio
async def test_empty_tier_ups_is_noop(self):
"""Empty tier_ups list does nothing."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
await _trigger_variant_renders([])
mock_get.assert_not_called()