From fce9cc5650485b001557e57a1665dc3cc9b9fa74 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 18 Mar 2026 15:45:41 -0500 Subject: [PATCH] =?UTF-8?q?feat(WP-11):=20/evo=20status=20slash=20command?= =?UTF-8?q?=20=E2=80=94=20closes=20#76?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /evo status command showing paginated evolution progress: - Progress bar with formula value vs next threshold - Tier display names (Unranked/Initiate/Rising/Ascendant/Evolved) - Formula shorthands (PA+TB×2, IP+K) - Filters: card_type, tier, progress="close" (within 80%) - Pagination at 10 per page - Evolution cog registered in players_new/__init__.py - 15 unit tests for pure helper functions Co-Authored-By: Claude Opus 4.6 (1M context) --- cogs/players_new/__init__.py | 14 +-- cogs/players_new/evolution.py | 206 +++++++++++++++++++++++++++++++ tests/test_evolution_commands.py | 173 ++++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 cogs/players_new/evolution.py create mode 100644 tests/test_evolution_commands.py diff --git a/cogs/players_new/__init__.py b/cogs/players_new/__init__.py index 736f370..c3fdd76 100644 --- a/cogs/players_new/__init__.py +++ b/cogs/players_new/__init__.py @@ -5,11 +5,7 @@ from .shared_utils import get_ai_records, get_record_embed, get_record_embed_leg import logging from discord.ext import commands -__all__ = [ - 'get_ai_records', - 'get_record_embed', - 'get_record_embed_legacy' -] +__all__ = ["get_ai_records", "get_record_embed", "get_record_embed_legacy"] async def setup(bot): @@ -24,12 +20,14 @@ async def setup(bot): from .standings_records import StandingsRecords from .team_management import TeamManagement from .utility_commands import UtilityCommands - + from .evolution import Evolution + await bot.add_cog(Gauntlet(bot)) await bot.add_cog(Paperdex(bot)) await bot.add_cog(PlayerLookup(bot)) await bot.add_cog(StandingsRecords(bot)) await bot.add_cog(TeamManagement(bot)) await bot.add_cog(UtilityCommands(bot)) - - logging.getLogger('discord_app').info('All player cogs loaded successfully') \ No newline at end of file + await bot.add_cog(Evolution(bot)) + + logging.getLogger("discord_app").info("All player cogs loaded successfully") diff --git a/cogs/players_new/evolution.py b/cogs/players_new/evolution.py new file mode 100644 index 0000000..902fd24 --- /dev/null +++ b/cogs/players_new/evolution.py @@ -0,0 +1,206 @@ +# Evolution Status Module +# Displays evolution tier progress for a team's cards + +from discord.ext import commands +from discord import app_commands +import discord +from typing import Optional +import logging + +from api_calls import db_get +from helpers import get_team_by_owner, is_ephemeral_channel + +logger = logging.getLogger("discord_app") + +# Tier display names +TIER_NAMES = { + 0: "Unranked", + 1: "Initiate", + 2: "Rising", + 3: "Ascendant", + 4: "Evolved", +} + +# Formula shorthands by card_type +FORMULA_SHORTHANDS = { + "batter": "PA+TB×2", + "sp": "IP+K", + "rp": "IP+K", +} + + +def render_progress_bar( + current_value: float, next_threshold: float | None, width: int = 10 +) -> str: + """Render a text progress bar. + + Args: + current_value: Current formula value. + next_threshold: Threshold for the next tier. None if fully evolved. + width: Number of characters in the bar. + + Returns: + A string like '[========--] 120/149' or '[==========] FULLY EVOLVED'. + """ + if next_threshold is None or next_threshold <= 0: + return f"[{'=' * width}] FULLY EVOLVED" + + ratio = min(current_value / next_threshold, 1.0) + filled = round(ratio * width) + empty = width - filled + bar = f"[{'=' * filled}{'-' * empty}]" + return f"{bar} {int(current_value)}/{int(next_threshold)}" + + +def format_evo_entry(state: dict) -> str: + """Format a single evolution card state into a display line. + + Args: + state: Card state dict from the API with nested track info. + + Returns: + Formatted string like 'Mike Trout [========--] 120/149 (PA+TB×2) T1 → T2' + """ + track = state.get("track", {}) + card_type = track.get("card_type", "batter") + formula = FORMULA_SHORTHANDS.get(card_type, "???") + current_tier = state.get("current_tier", 0) + current_value = state.get("current_value", 0.0) + next_threshold = state.get("next_threshold") + fully_evolved = state.get("fully_evolved", False) + + bar = render_progress_bar(current_value, next_threshold) + + if fully_evolved: + tier_label = f"T4 — {TIER_NAMES[4]}" + else: + next_tier = current_tier + 1 + tier_label = ( + f"{TIER_NAMES.get(current_tier, '?')} → {TIER_NAMES.get(next_tier, '?')}" + ) + + return f"{bar} ({formula}) {tier_label}" + + +def is_close_to_tierup(state: dict, threshold_pct: float = 0.80) -> bool: + """Check if a card is close to its next tier-up. + + Args: + state: Card state dict from the API. + threshold_pct: Fraction of next_threshold that counts as "close". + + Returns: + True if current_value >= threshold_pct * next_threshold. + """ + next_threshold = state.get("next_threshold") + if next_threshold is None or next_threshold <= 0: + return False + current_value = state.get("current_value", 0.0) + return current_value >= threshold_pct * next_threshold + + +class Evolution(commands.Cog): + """Evolution tier progress for Paper Dynasty cards.""" + + def __init__(self, bot): + self.bot = bot + + evo_group = app_commands.Group(name="evo", description="Evolution commands") + + @evo_group.command(name="status", description="View your team's evolution progress") + @app_commands.describe( + type="Filter by card type (batter, sp, rp)", + tier="Filter by minimum tier (0-4)", + progress="Show only cards close to tier-up (type 'close')", + page="Page number (default: 1)", + ) + async def evo_status( + self, + interaction: discord.Interaction, + type: Optional[str] = None, + tier: Optional[int] = None, + progress: Optional[str] = None, + page: int = 1, + ): + await interaction.response.defer( + ephemeral=is_ephemeral_channel(interaction.channel) + ) + + # Look up the user's team + team = await get_team_by_owner(interaction.user.id) + if not team: + await interaction.followup.send( + "You don't have a team registered. Use `/register` first.", + ephemeral=True, + ) + return + + team_id = team.get("team_id") or team.get("id") + + # Build query params + params = [("page", page), ("per_page", 10)] + if type: + params.append(("card_type", type)) + if tier is not None: + params.append(("tier", tier)) + + try: + result = await db_get( + f"teams/{team_id}/evolutions", + params=params, + none_okay=True, + ) + except Exception: + logger.warning( + f"Failed to fetch evolution data for team {team_id}", + exc_info=True, + ) + await interaction.followup.send( + "Could not fetch evolution data. Please try again later.", + ephemeral=True, + ) + return + + if not result or not result.get("items"): + await interaction.followup.send( + "No evolution cards found for your team.", + ephemeral=True, + ) + return + + items = result["items"] + total_count = result.get("count", len(items)) + + # Apply "close" filter client-side + if progress and progress.lower() == "close": + items = [s for s in items if is_close_to_tierup(s)] + if not items: + await interaction.followup.send( + "No cards are close to a tier-up right now.", + ephemeral=True, + ) + return + + # Build embed + embed = discord.Embed( + title=f"Evolution Progress — {team.get('lname', 'Your Team')}", + color=discord.Color.purple(), + ) + + lines = [] + for state in items: + # Try to get player name from the state + player_name = state.get( + "player_name", f"Player #{state.get('player_id', '?')}" + ) + entry = format_evo_entry(state) + lines.append(f"**{player_name}**\n{entry}") + + embed.description = "\n\n".join(lines) if lines else "No evolution data." + + # Pagination footer + per_page = 10 + total_pages = max(1, (total_count + per_page - 1) // per_page) + embed.set_footer(text=f"Page {page}/{total_pages} • {total_count} total cards") + + await interaction.followup.send(embed=embed) diff --git a/tests/test_evolution_commands.py b/tests/test_evolution_commands.py new file mode 100644 index 0000000..eb65458 --- /dev/null +++ b/tests/test_evolution_commands.py @@ -0,0 +1,173 @@ +"""Tests for the evolution status command helpers (WP-11). + +Unit tests for progress bar rendering, entry formatting, tier display +names, close-to-tierup filtering, and edge cases. No Discord bot or +API calls required — these test pure functions only. +""" + +import pytest +from cogs.players_new.evolution import ( + render_progress_bar, + format_evo_entry, + is_close_to_tierup, + TIER_NAMES, + FORMULA_SHORTHANDS, +) + +# --------------------------------------------------------------------------- +# render_progress_bar +# --------------------------------------------------------------------------- + + +class TestRenderProgressBar: + def test_80_percent_filled(self): + """120/149 should be ~80% filled (8 of 10 chars).""" + result = render_progress_bar(120, 149, width=10) + assert "[========--]" in result + assert "120/149" in result + + def test_zero_progress(self): + """0/37 should be empty bar.""" + result = render_progress_bar(0, 37, width=10) + assert "[----------]" in result + assert "0/37" in result + + def test_full_progress_not_evolved(self): + """Value at threshold shows full bar.""" + result = render_progress_bar(149, 149, width=10) + assert "[==========]" in result + assert "149/149" in result + + def test_fully_evolved(self): + """next_threshold=None means fully evolved.""" + result = render_progress_bar(900, None, width=10) + assert "FULLY EVOLVED" in result + assert "[==========]" in result + + def test_over_threshold_capped(self): + """Value exceeding threshold still caps at 100%.""" + result = render_progress_bar(200, 149, width=10) + assert "[==========]" in result + + +# --------------------------------------------------------------------------- +# format_evo_entry +# --------------------------------------------------------------------------- + + +class TestFormatEvoEntry: + def test_batter_t1_to_t2(self): + """Batter at T1 progressing toward T2.""" + state = { + "current_tier": 1, + "current_value": 120.0, + "next_threshold": 149, + "fully_evolved": False, + "track": {"card_type": "batter"}, + } + result = format_evo_entry(state) + assert "(PA+TB×2)" in result + assert "Initiate → Rising" in result + + def test_pitcher_sp(self): + """SP track shows IP+K formula.""" + state = { + "current_tier": 0, + "current_value": 5.0, + "next_threshold": 10, + "fully_evolved": False, + "track": {"card_type": "sp"}, + } + result = format_evo_entry(state) + assert "(IP+K)" in result + assert "Unranked → Initiate" in result + + def test_fully_evolved_entry(self): + """Fully evolved card shows T4 — Evolved.""" + state = { + "current_tier": 4, + "current_value": 900.0, + "next_threshold": None, + "fully_evolved": True, + "track": {"card_type": "batter"}, + } + result = format_evo_entry(state) + assert "FULLY EVOLVED" in result + assert "Evolved" in result + + +# --------------------------------------------------------------------------- +# is_close_to_tierup +# --------------------------------------------------------------------------- + + +class TestIsCloseToTierup: + def test_at_80_percent(self): + """Exactly 80% of threshold counts as close.""" + state = {"current_value": 119.2, "next_threshold": 149} + assert is_close_to_tierup(state, threshold_pct=0.80) + + def test_below_80_percent(self): + """Below 80% is not close.""" + state = {"current_value": 100, "next_threshold": 149} + assert not is_close_to_tierup(state, threshold_pct=0.80) + + def test_fully_evolved_not_close(self): + """Fully evolved (no next threshold) is not close.""" + state = {"current_value": 900, "next_threshold": None} + assert not is_close_to_tierup(state) + + def test_zero_threshold(self): + """Zero threshold edge case returns False.""" + state = {"current_value": 0, "next_threshold": 0} + assert not is_close_to_tierup(state) + + +# --------------------------------------------------------------------------- +# Tier names and formula shorthands +# --------------------------------------------------------------------------- + + +class TestConstants: + def test_all_tier_names_present(self): + """All 5 tiers (0-4) have display names.""" + assert len(TIER_NAMES) == 5 + for i in range(5): + assert i in TIER_NAMES + + def test_tier_name_values(self): + assert TIER_NAMES[0] == "Unranked" + assert TIER_NAMES[1] == "Initiate" + assert TIER_NAMES[2] == "Rising" + assert TIER_NAMES[3] == "Ascendant" + assert TIER_NAMES[4] == "Evolved" + + def test_formula_shorthands(self): + assert FORMULA_SHORTHANDS["batter"] == "PA+TB×2" + assert FORMULA_SHORTHANDS["sp"] == "IP+K" + assert FORMULA_SHORTHANDS["rp"] == "IP+K" + + +# --------------------------------------------------------------------------- +# Empty / edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + def test_missing_track_defaults(self): + """State with missing track info still formats without error.""" + state = { + "current_tier": 0, + "current_value": 0, + "next_threshold": 37, + "fully_evolved": False, + "track": {}, + } + result = format_evo_entry(state) + assert isinstance(result, str) + + def test_state_with_no_keys(self): + """Completely empty state dict doesn't crash.""" + state = {} + result = format_evo_entry(state) + assert isinstance(result, str) -- 2.25.1