""" Compare cog — /compare slash command. Displays a side-by-side stat embed for two cards of the same type (batter vs batter, pitcher vs pitcher) with directional delta arrows. Card stats are derived from the battingcards / pitchingcards API endpoints which carry the actual card data (running, steal range, pitcher ratings, etc.) alongside the player's rarity and cost. Batter stats shown: Cost (Overall proxy), Rarity, Running, Steal Low, Steal High, Bunting, Hit & Run Pitcher stats shown: Cost (Overall proxy), Rarity, Starter Rating, Relief Rating, Closer Rating, Balk, Wild Pitch Arrow semantics: ▲ card2 is higher (better for ↑-better stats) ▼ card1 is higher (better for ↑-better stats) ═ tied """ import logging from typing import List, Optional, Tuple import discord from discord import app_commands from discord.ext import commands from api_calls import db_get from constants import PD_PLAYERS_ROLE_NAME from utilities.autocomplete import player_autocomplete logger = logging.getLogger("discord_app") # ----- helpers ---------------------------------------------------------------- GRADE_ORDER = ["A", "B", "C", "D", "E", "F"] def _grade_to_int(grade: Optional[str]) -> Optional[int]: """Convert a letter grade (A-F) to a numeric rank for comparison. Lower rank = better grade. Returns None when grade is None/empty. """ if grade is None: return None upper = grade.upper().strip() try: return GRADE_ORDER.index(upper) except ValueError: return None def _delta_arrow( val1, val2, higher_is_better: bool = True, grade_field: bool = False, ) -> str: """Return a directional arrow showing which card has the better value. Args: val1: stat value for card1 val2: stat value for card2 higher_is_better: when True, a larger numeric value is preferred. When False (e.g. balk, wild_pitch), a smaller value is preferred. grade_field: when True, val1/val2 are letter grades (A-F) where A > B. Returns: '▲' if card2 wins, '▼' if card1 wins, '═' if tied / not comparable. """ if val1 is None or val2 is None: return "═" if grade_field: n1 = _grade_to_int(val1) n2 = _grade_to_int(val2) if n1 is None or n2 is None or n1 == n2: return "═" # Lower index = better grade; card2 wins when n2 < n1 return "▲" if n2 < n1 else "▼" try: n1 = float(val1) n2 = float(val2) except (TypeError, ValueError): return "═" if n1 == n2: return "═" if higher_is_better: return "▲" if n2 > n1 else "▼" else: # lower is better (e.g. balk count) return "▲" if n2 < n1 else "▼" def _fmt(val) -> str: """Format a stat value for display. Falls back to '—' when None.""" if val is None: return "—" return str(val) def _is_pitcher(player: dict) -> bool: """Return True if the player is a pitcher (pos_1 in SP, RP).""" return player.get("pos_1", "").upper() in ("SP", "RP") def _card_type_label(player: dict) -> str: return "pitcher" if _is_pitcher(player) else "batter" # ----- embed builder (pure function, testable without Discord state) --------- _BATTER_STATS: List[Tuple[str, str, str, bool]] = [ # (label, key_in_card, key_in_player, higher_is_better) ("Cost (Overall)", "cost", "player", True), ("Rarity", "rarity_value", "player", True), ("Running", "running", "battingcard", True), ("Steal Low", "steal_low", "battingcard", True), ("Steal High", "steal_high", "battingcard", True), ("Bunting", "bunting", "battingcard", False), # grade: A>B>C... ("Hit & Run", "hit_and_run", "battingcard", False), # grade ] _PITCHER_STATS: List[Tuple[str, str, str, bool]] = [ ("Cost (Overall)", "cost", "player", True), ("Rarity", "rarity_value", "player", True), ("Starter Rating", "starter_rating", "pitchingcard", True), ("Relief Rating", "relief_rating", "pitchingcard", True), ("Closer Rating", "closer_rating", "pitchingcard", True), ("Balk", "balk", "pitchingcard", False), # lower is better ("Wild Pitch", "wild_pitch", "pitchingcard", False), # lower is better ] _GRADE_FIELDS = {"bunting", "hit_and_run"} class CompareMismatchError(ValueError): """Raised when two cards are not of the same type.""" def build_compare_embed( card1: dict, card2: dict, card1_name: str, card2_name: str, ) -> discord.Embed: """Build a side-by-side comparison embed for two cards. Args: card1: card data dict (player + battingcard OR pitchingcard). Expects 'player', 'battingcard' or 'pitchingcard' keys. card2: same shape as card1 card1_name: display name override (falls back to player p_name) card2_name: display name override Returns: discord.Embed with inline stat rows Raises: CompareMismatchError: if card types differ (batter vs pitcher) """ p1 = card1.get("player", {}) p2 = card2.get("player", {}) type1 = _card_type_label(p1) type2 = _card_type_label(p2) if type1 != type2: raise CompareMismatchError( f"Card types differ: '{card1_name}' is a {type1}, " f"'{card2_name}' is a {type2}." ) color_hex = p1.get("rarity", {}).get("color", "3498DB") try: color = int(color_hex, 16) except (TypeError, ValueError): color = 0x3498DB # Embed header embed = discord.Embed( title="Card Comparison", description=( f"**{card1_name}** vs **{card2_name}** — " f"{'Pitchers' if type1 == 'pitcher' else 'Batters'}" ), color=color, ) # Thumbnail from card1 headshot if available thumbnail = p1.get("headshot") or p2.get("headshot") if thumbnail: embed.set_thumbnail(url=thumbnail) # Card name headers (inline, side-by-side feel) embed.add_field(name="Card 1", value=f"**{card1_name}**", inline=True) embed.add_field(name="Stat", value="\u200b", inline=True) embed.add_field(name="Card 2", value=f"**{card2_name}**", inline=True) # Choose stat spec stats = _PITCHER_STATS if type1 == "pitcher" else _BATTER_STATS for label, key, source, higher_is_better in stats: # Extract values if source == "player": if key == "rarity_value": v1 = p1.get("rarity", {}).get("value") v2 = p2.get("rarity", {}).get("value") # Display as rarity name + value display1 = p1.get("rarity", {}).get("name", _fmt(v1)) display2 = p2.get("rarity", {}).get("name", _fmt(v2)) else: v1 = p1.get(key) v2 = p2.get(key) display1 = _fmt(v1) display2 = _fmt(v2) elif source == "battingcard": bc1 = card1.get("battingcard", {}) or {} bc2 = card2.get("battingcard", {}) or {} v1 = bc1.get(key) v2 = bc2.get(key) display1 = _fmt(v1) display2 = _fmt(v2) elif source == "pitchingcard": pc1 = card1.get("pitchingcard", {}) or {} pc2 = card2.get("pitchingcard", {}) or {} v1 = pc1.get(key) v2 = pc2.get(key) display1 = _fmt(v1) display2 = _fmt(v2) else: continue is_grade = key in _GRADE_FIELDS arrow = _delta_arrow( v1, v2, higher_is_better=higher_is_better, grade_field=is_grade, ) embed.add_field(name="\u200b", value=display1, inline=True) embed.add_field(name=label, value=arrow, inline=True) embed.add_field(name="\u200b", value=display2, inline=True) embed.set_footer(text="Paper Dynasty — /compare") return embed # ----- card fetch helpers ----------------------------------------------------- async def _fetch_player_by_name(name: str) -> Optional[dict]: """Search for a player by name and return the first match.""" result = await db_get( "players/search", params=[("q", name), ("limit", 1)], timeout=5, ) if not result or not result.get("players"): return None return result["players"][0] async def _fetch_batting_card(player_id: int) -> Optional[dict]: """Fetch the variant-0 batting card for a player.""" result = await db_get( "battingcards", params=[("player_id", player_id), ("variant", 0)], timeout=5, ) if not result or not result.get("cards"): # Fall back to any variant result = await db_get( "battingcards", params=[("player_id", player_id)], timeout=5, ) if not result or not result.get("cards"): return None return result["cards"][0] async def _fetch_pitching_card(player_id: int) -> Optional[dict]: """Fetch the variant-0 pitching card for a player.""" result = await db_get( "pitchingcards", params=[("player_id", player_id), ("variant", 0)], timeout=5, ) if not result or not result.get("cards"): result = await db_get( "pitchingcards", params=[("player_id", player_id)], timeout=5, ) if not result or not result.get("cards"): return None return result["cards"][0] async def _build_card_data(player: dict) -> dict: """Build a unified card data dict for use with build_compare_embed. Returns a dict with 'player', 'battingcard', and 'pitchingcard' keys. """ pid = player.get("player_id") or player.get("id") batting_card = None pitching_card = None if _is_pitcher(player): pitching_card = await _fetch_pitching_card(pid) else: batting_card = await _fetch_batting_card(pid) return { "player": player, "battingcard": batting_card, "pitchingcard": pitching_card, } # ----- Cog -------------------------------------------------------------------- class CompareCog(commands.Cog, name="Compare"): """Slash command cog providing /compare for side-by-side card comparison.""" def __init__(self, bot: commands.Bot): self.bot = bot @app_commands.command( name="compare", description="Side-by-side stat comparison for two cards", ) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) @app_commands.describe( card1="First player's card (type a name to search)", card2="Second player's card (type a name to search)", ) @app_commands.autocomplete(card1=player_autocomplete, card2=player_autocomplete) async def compare_command( self, interaction: discord.Interaction, card1: str, card2: str, ): """Compare two cards side-by-side. Fetches both players by name, validates that they are the same card type (both batters or both pitchers), then builds and sends the comparison embed. """ await interaction.response.defer() # --- fetch player 1 --------------------------------------------------- player1 = await _fetch_player_by_name(card1) if not player1: await interaction.edit_original_response( content=f"Could not find a card for **{card1}**." ) return # --- fetch player 2 --------------------------------------------------- player2 = await _fetch_player_by_name(card2) if not player2: await interaction.edit_original_response( content=f"Could not find a card for **{card2}**." ) return # --- type-gate -------------------------------------------------------- type1 = _card_type_label(player1) type2 = _card_type_label(player2) if type1 != type2: await interaction.edit_original_response( content=( "Can only compare cards of the same type " "(batter vs batter, pitcher vs pitcher)." ), ) return # --- build card data -------------------------------------------------- card_data1 = await _build_card_data(player1) card_data2 = await _build_card_data(player2) name1 = player1.get("p_name", card1) name2 = player2.get("p_name", card2) # --- build embed ------------------------------------------------------ try: embed = build_compare_embed(card_data1, card_data2, name1, name2) except CompareMismatchError as exc: logger.warning("CompareMismatchError (should not reach here): %s", exc) await interaction.edit_original_response( content=( "Can only compare cards of the same type " "(batter vs batter, pitcher vs pitcher)." ), ) return except Exception as exc: logger.error( "compare_command build_compare_embed error: %s", exc, exc_info=True ) await interaction.edit_original_response( content="Something went wrong building the comparison. Please contact Cal." ) return # Send publicly so players can share the result await interaction.edit_original_response(embed=embed) async def setup(bot: commands.Bot) -> None: """Discord.py cog loader entry point.""" await bot.add_cog(CompareCog(bot))