diff --git a/cogs/players.py b/cogs/players.py index cc3ad7d..ffaa81b 100644 --- a/cogs/players.py +++ b/cogs/players.py @@ -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 diff --git a/command_logic/logic_gameplay.py b/command_logic/logic_gameplay.py index 660de07..846fe3e 100644 --- a/command_logic/logic_gameplay.py +++ b/command_logic/logic_gameplay.py @@ -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}") diff --git a/tests/test_player_refractor_view.py b/tests/test_player_refractor_view.py new file mode 100644 index 0000000..79317c7 --- /dev/null +++ b/tests/test_player_refractor_view.py @@ -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 diff --git a/tests/test_post_game_render.py b/tests/test_post_game_render.py new file mode 100644 index 0000000..2470dfe --- /dev/null +++ b/tests/test_post_game_render.py @@ -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()