Merge pull request 'feat: Refractor tier badge prefix in card embed title (WP-12) (#77)' (#88) from ai/paper-dynasty-database#77 into main

This commit is contained in:
cal 2026-03-23 20:05:44 +00:00
commit 7a50ab0bce
2 changed files with 316 additions and 54 deletions

View File

@ -2,39 +2,30 @@ import asyncio
import datetime
import logging
import math
import os
import random
import traceback
import discord
import pygsheets
import aiohttp
from discord.ext import commands
from api_calls import *
from bs4 import BeautifulSoup
from difflib import get_close_matches
from dataclasses import dataclass
from typing import Optional, Literal, Union, List
from typing import Optional, Union, List
from exceptions import log_exception
from in_game.gameplay_models import Team
from constants import *
from discord_ui import *
from random_content import *
from utils import (
position_name_to_abbrev,
user_has_role,
get_roster_sheet_legacy,
get_roster_sheet,
get_player_url,
owner_only,
get_cal_user,
get_context_user,
)
from search_utils import *
from .discord_utils import *
# Refractor tier badge prefixes for card embeds (T0 = no badge)
TIER_BADGES = {1: "BC", 2: "R", 3: "GR", 4: "SF"}
async def get_player_photo(player):
search_term = player["bbref_id"] if player["bbref_id"] else player["p_name"]
@ -122,8 +113,18 @@ async def share_channel(channel, user, read_only=False):
async def get_card_embeds(card, include_stats=False) -> list:
tier_badge = ""
try:
evo_state = await db_get(f"evolution/cards/{card['id']}")
if evo_state and evo_state.get("current_tier", 0) > 0:
tier = evo_state["current_tier"]
badge = TIER_BADGES.get(tier)
tier_badge = f"[{badge}] " if badge else ""
except Exception:
pass
embed = discord.Embed(
title=f"{card['player']['p_name']}",
title=f"{tier_badge}{card['player']['p_name']}",
color=int(card["player"]["rarity"]["color"], 16),
)
# embed.description = card['team']['lname']
@ -166,7 +167,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
]
if any(bool_list):
if count == 1:
coll_string = f"Only you"
coll_string = "Only you"
else:
coll_string = (
f"You and {count - 1} other{'s' if count - 1 != 1 else ''}"
@ -174,7 +175,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
elif count:
coll_string = f"{count} other team{'s' if count != 1 else ''}"
else:
coll_string = f"0 teams"
coll_string = "0 teams"
embed.add_field(name="Collected By", value=coll_string)
else:
embed.add_field(
@ -223,7 +224,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
except Exception as e:
logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True
f"could not pull evolution: {e}", exc_info=True, stack_info=True
)
if "420420" not in card["player"]["strat_code"]:
try:
@ -234,7 +235,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
except Exception as e:
logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True
f"could not pull evolution: {e}", exc_info=True, stack_info=True
)
if include_stats:
@ -334,7 +335,7 @@ async def display_cards(
)
try:
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
logger.debug(f"Cards sorted successfully")
logger.debug("Cards sorted successfully")
card_embeds = [await get_card_embeds(x) for x in cards]
logger.debug(f"Created {len(card_embeds)} card embeds")
@ -355,15 +356,15 @@ async def display_cards(
r_emoji = ""
view.left_button.disabled = True
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Close Pack"
view.cancel_button.label = "Close Pack"
view.right_button.label = f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
if len(cards) == 1:
view.right_button.disabled = True
logger.debug(f"Pagination view created successfully")
logger.debug("Pagination view created successfully")
if pack_cover:
logger.debug(f"Sending pack cover message")
logger.debug("Sending pack cover message")
msg = await channel.send(
content=None,
embed=image_embed(pack_cover, title=f"{team['lname']}", desc=pack_name),
@ -375,7 +376,7 @@ async def display_cards(
content=None, embeds=card_embeds[page_num], view=view
)
logger.debug(f"Initial message sent successfully")
logger.debug("Initial message sent successfully")
except Exception as e:
logger.error(
f"Error creating view or sending initial message: {e}", exc_info=True
@ -392,12 +393,12 @@ async def display_cards(
f"{user.mention} you've got {len(cards)} cards here"
)
logger.debug(f"Follow-up message sent successfully")
logger.debug("Follow-up message sent successfully")
except Exception as e:
logger.error(f"Error sending follow-up message: {e}", exc_info=True)
return False
logger.debug(f"Starting main interaction loop")
logger.debug("Starting main interaction loop")
while True:
try:
logger.debug(f"Waiting for user interaction on page {page_num}")
@ -463,7 +464,7 @@ async def display_cards(
),
view=view,
)
logger.debug(f"MVP display updated successfully")
logger.debug("MVP display updated successfully")
except Exception as e:
logger.error(
f"Error processing shiny card on page {page_num}: {e}", exc_info=True
@ -471,19 +472,19 @@ async def display_cards(
# Continue with regular flow instead of crashing
try:
tmp_msg = await channel.send(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
await follow_up.edit(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
await tmp_msg.delete()
except discord.errors.NotFound:
# Role might not exist or message was already deleted
await follow_up.edit(content=f"We've got an MVP!")
await follow_up.edit(content="We've got an MVP!")
except Exception as e:
# Log error but don't crash the function
logger.error(f"Error handling MVP notification: {e}")
await follow_up.edit(content=f"We've got an MVP!")
await follow_up.edit(content="We've got an MVP!")
await view.wait()
view = Pagination([user], timeout=10)
@ -491,7 +492,7 @@ async def display_cards(
view.right_button.label = (
f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
)
view.cancel_button.label = f"Close Pack"
view.cancel_button.label = "Close Pack"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(card_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
@ -539,7 +540,7 @@ async def embed_pagination(
l_emoji = ""
r_emoji = ""
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}"
view.cancel_button.label = f"Cancel"
view.cancel_button.label = "Cancel"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
@ -574,7 +575,7 @@ async def embed_pagination(
view = Pagination([user], timeout=timeout)
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}"
view.cancel_button.label = f"Cancel"
view.cancel_button.label = "Cancel"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
@ -888,7 +889,7 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
timeout=10,
)
if not success:
raise ConnectionError(f"Failed to create this pack of cards.")
raise ConnectionError("Failed to create this pack of cards.")
await db_patch(
"packs",
@ -954,7 +955,7 @@ def get_sheets(bot):
except Exception as e:
logger.error(f"Could not grab sheets auth: {e}")
raise ConnectionError(
f"Bot has not authenticated with discord; please try again in 1 minute."
"Bot has not authenticated with discord; please try again in 1 minute."
)
@ -1064,7 +1065,7 @@ def get_blank_team_card(player):
def get_rosters(team, bot, roster_num: Optional[int] = None) -> list:
sheets = get_sheets(bot)
this_sheet = sheets.open_by_key(team["gsheet"])
r_sheet = this_sheet.worksheet_by_title(f"My Rosters")
r_sheet = this_sheet.worksheet_by_title("My Rosters")
logger.debug(f"this_sheet: {this_sheet} / r_sheet = {r_sheet}")
all_rosters = [None, None, None]
@ -1145,11 +1146,11 @@ def get_roster_lineups(team, bot, roster_num, lineup_num) -> list:
try:
lineup_cells = [(row[0].value, int(row[1].value)) for row in raw_cells]
except ValueError as e:
except ValueError:
logger.error(f"Could not pull roster for {team['abbrev']} due to a ValueError")
raise ValueError(
f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
f"get the card IDs"
"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
"get the card IDs"
)
logger.debug(f"lineup_cells: {lineup_cells}")
@ -1544,7 +1545,7 @@ def get_ratings_guide(sheets):
}
for x in p_data
]
except Exception as e:
except Exception:
return {"valid": False}
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers}
@ -1756,7 +1757,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
pack_ids = await roll_for_cards(all_packs)
if not pack_ids:
logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}")
raise ValueError(f"I was not able to unpack these cards")
raise ValueError("I was not able to unpack these cards")
all_cards = []
for p_id in pack_ids:
@ -1767,7 +1768,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
if not all_cards:
logger.error(f"open_packs - unable to get cards for packs: {pack_ids}")
raise ValueError(f"I was not able to display these cards")
raise ValueError("I was not able to display these cards")
# Present cards to opening channel
if type(context) == commands.Context:
@ -1826,7 +1827,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -1844,7 +1845,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.left_button.disabled = True
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
@ -1887,7 +1888,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1:
@ -1933,7 +1934,7 @@ async def open_choice_pack(
players = pl["players"]
elif pack_type == "Team Choice":
if this_pack["pack_team"] is None:
raise KeyError(f"Team not listed for Team Choice pack")
raise KeyError("Team not listed for Team Choice pack")
d1000 = random.randint(1, 1000)
pack_cover = this_pack["pack_team"]["logo"]
@ -1972,7 +1973,7 @@ async def open_choice_pack(
rarity_id += 1
elif pack_type == "Promo Choice":
if this_pack["pack_cardset"] is None:
raise KeyError(f"Cardset not listed for Promo Choice pack")
raise KeyError("Cardset not listed for Promo Choice pack")
d1000 = random.randint(1, 1000)
pack_cover = IMAGES["mvp-hype"]
@ -2029,8 +2030,8 @@ async def open_choice_pack(
rarity_id += 3
if len(players) == 0:
logger.error(f"Could not create choice pack")
raise ConnectionError(f"Could not create choice pack")
logger.error("Could not create choice pack")
raise ConnectionError("Could not create choice pack")
if type(context) == commands.Context:
author = context.author
@ -2053,7 +2054,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30)
view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -2071,10 +2072,10 @@ async def open_choice_pack(
)
if rarity_id >= 5:
tmp_msg = await pack_channel.send(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
else:
tmp_msg = await pack_channel.send(content=f"We've got a choice pack here!")
tmp_msg = await pack_channel.send(content="We've got a choice pack here!")
while True:
await view.wait()
@ -2089,7 +2090,7 @@ async def open_choice_pack(
)
except Exception as e:
logger.error(f"failed to create cards: {e}")
raise ConnectionError(f"Failed to distribute these cards.")
raise ConnectionError("Failed to distribute these cards.")
await db_patch(
"packs",
@ -2123,7 +2124,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1:

View File

@ -0,0 +1,261 @@
"""
Tests for WP-12: Tier Badge on Card Embed.
Verifies that get_card_embeds() prepends a tier badge to the card title when a
card has Refractor tier progression, and falls back gracefully when the Refractor
API is unavailable or returns no state.
"""
import pytest
from unittest.mock import AsyncMock, patch
import discord
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_card(card_id=1, player_name="Mike Trout", rarity_color="FFD700"):
"""Minimal card dict matching the API shape consumed by get_card_embeds."""
return {
"id": card_id,
"player": {
"player_id": 101,
"p_name": player_name,
"rarity": {"name": "MVP", "value": 5, "color": rarity_color},
"cost": 500,
"image": "https://example.com/card.png",
"image2": None,
"mlbclub": "Los Angeles Angels",
"franchise": "Los Angeles Angels",
"headshot": "https://example.com/headshot.jpg",
"cardset": {"name": "2023 Season"},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"bbref_id": "troutmi01",
"strat_code": "420420",
"fangr_id": None,
"vanity_card": None,
},
"team": {
"id": 10,
"lname": "Paper Dynasty",
"logo": "https://example.com/logo.png",
"season": 7,
},
}
def _make_paperdex():
"""Minimal paperdex response."""
return {"count": 0, "paperdex": []}
# ---------------------------------------------------------------------------
# Helpers to patch the async dependencies of get_card_embeds
# ---------------------------------------------------------------------------
def _patch_db_get(evo_response=None, paperdex_response=None):
"""
Return a side_effect callable that routes db_get calls to the right mock
responses, so other get_card_embeds internals still behave.
"""
if paperdex_response is None:
paperdex_response = _make_paperdex()
async def _side_effect(endpoint, *args, **kwargs):
if str(endpoint).startswith("evolution/cards/"):
return evo_response
if endpoint == "paperdex":
return paperdex_response
# Fallback for any other endpoint (e.g. plays/batting, plays/pitching)
return None
return _side_effect
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestTierBadgeFormat:
"""Unit: tier badge string format for each tier level."""
@pytest.mark.asyncio
async def test_tier_zero_no_badge(self):
"""T0 evolution state (current_tier=0) should produce no badge in title."""
card = _make_card()
evo_state = {"current_tier": 0, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_tier_one_badge(self):
"""current_tier=1 should prefix title with [BC] (Base Chrome)."""
card = _make_card()
evo_state = {"current_tier": 1, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[BC] Mike Trout"
@pytest.mark.asyncio
async def test_tier_two_badge(self):
"""current_tier=2 should prefix title with [R] (Refractor)."""
card = _make_card()
evo_state = {"current_tier": 2, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[R] Mike Trout"
@pytest.mark.asyncio
async def test_tier_three_badge(self):
"""current_tier=3 should prefix title with [GR] (Gold Refractor)."""
card = _make_card()
evo_state = {"current_tier": 3, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[GR] Mike Trout"
@pytest.mark.asyncio
async def test_tier_four_superfractor_badge(self):
"""current_tier=4 (Superfractor) should prefix title with [SF]."""
card = _make_card()
evo_state = {"current_tier": 4, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[SF] Mike Trout"
class TestTierBadgeInTitle:
"""Unit: badge appears correctly in the embed title."""
@pytest.mark.asyncio
async def test_badge_prepended_to_player_name(self):
"""Badge should be prepended so title reads '[Tx] <player_name>'."""
card = _make_card(player_name="Juan Soto")
evo_state = {"current_tier": 2, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title.startswith("[R] ")
assert "Juan Soto" in embeds[0].title
class TestFullyEvolvedBadge:
"""Unit: fully evolved card shows [SF] badge (Superfractor)."""
@pytest.mark.asyncio
async def test_fully_evolved_badge(self):
"""T4 card should show [SF] prefix, not [T4]."""
card = _make_card()
evo_state = {"current_tier": 4}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title.startswith("[SF] ")
assert "[T4]" not in embeds[0].title
class TestNoBadgeGracefulFallback:
"""Unit: embed renders correctly when evolution state is absent or API fails."""
@pytest.mark.asyncio
async def test_no_evolution_state_no_badge(self):
"""When evolution API returns None (404), title has no badge."""
card = _make_card()
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=None)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_api_exception_no_badge(self):
"""When evolution API raises an exception, card display is unaffected."""
card = _make_card()
async def _failing_db_get(endpoint, *args, **kwargs):
if str(endpoint).startswith("evolution/cards/"):
raise ConnectionError("API unreachable")
if endpoint == "paperdex":
return _make_paperdex()
return None
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _failing_db_get
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
class TestEmbedColorUnchanged:
"""Unit: embed color comes from card rarity, not affected by evolution state."""
@pytest.mark.asyncio
async def test_embed_color_from_rarity_with_evolution(self):
"""Color is still derived from rarity even when a tier badge is present."""
rarity_color = "FF0000"
card = _make_card(rarity_color=rarity_color)
evo_state = {"current_tier": 2}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].color == discord.Color(int(rarity_color, 16))
@pytest.mark.asyncio
async def test_embed_color_from_rarity_without_evolution(self):
"""Color is derived from rarity when no evolution state exists."""
rarity_color = "00FF00"
card = _make_card(rarity_color=rarity_color)
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=None)
embeds = await _call_get_card_embeds(card)
assert embeds[0].color == discord.Color(int(rarity_color, 16))
# ---------------------------------------------------------------------------
# Helper: call get_card_embeds and return embed list
# ---------------------------------------------------------------------------
async def _call_get_card_embeds(card):
"""Import and call get_card_embeds, returning the list of embeds."""
from helpers.main import get_card_embeds
result = await get_card_embeds(card)
if isinstance(result, list):
return result
return [result]