paper-dynasty-discord/cogs/compare.py
Cal Corum 0b8beda8b5
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
feat(compare): add /compare slash command for side-by-side card comparison
Implements Roadmap 2.5b: new /compare command lets players compare two
cards of the same type (batter vs batter or pitcher vs pitcher) in a
side-by-side embed with directional delta arrows (▲▼═).

- cogs/compare.py: new CompareCog with /compare slash command and
  player_autocomplete on both params; fetches battingcard/pitchingcard
  data from API; validates type compatibility; sends public embed
- tests/test_compare_command.py: 30 unit tests covering _delta_arrow,
  _is_pitcher, batter/pitcher embed builders, type mismatch error, and
  edge cases (None stats, tied values)
- paperdynasty.py: registers cogs.compare in COGS list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 10:35:17 -05:00

426 lines
13 KiB
Python

"""
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))