paper-dynasty-discord/cogs/players_new/evolution.py
Cal Corum fce9cc5650
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m23s
feat(WP-11): /evo status slash command — closes #76
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) <noreply@anthropic.com>
2026-03-18 15:45:41 -05:00

207 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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