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:
commit
6aeef36f20
@ -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")
|
||||
|
||||
206
cogs/players_new/evolution.py
Normal file
206
cogs/players_new/evolution.py
Normal 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)
|
||||
173
tests/test_evolution_commands.py
Normal file
173
tests/test_evolution_commands.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user