Merge pull request 'feat(WP-11): /evo status slash command (#76)' (#92) from feature/wp11-evo-status into card-evolution

Reviewed-on: #92
This commit is contained in:
cal 2026-03-18 21:19:30 +00:00
commit 6aeef36f20
3 changed files with 385 additions and 8 deletions

View File

@ -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')
await bot.add_cog(Evolution(bot))
logging.getLogger("discord_app").info("All player cogs loaded successfully")

View File

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

View File

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