All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
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>
426 lines
13 KiB
Python
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))
|