paper-dynasty-discord/cogs/refractor.py
Cal Corum a53cc5cac3
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
feat: use Discord Choice menus for /refractor status parameters
Replace freeform text inputs with dropdown selections:
- card_type: Batter, Starting Pitcher, Relief Pitcher
- tier: T0-T4 with names (Base Card through Superfractor)
- progress: "Close to next tier" option
- Removed season param (not useful for current UX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:16:26 -05:00

257 lines
8.2 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.

"""
Refractor cog — /refractor status slash command.
Displays a team's refractor progress: formula value vs next threshold
with a progress bar, paginated 10 cards per page.
Tier names: Base Card (T0) / Base Chrome (T1) / Refractor (T2) /
Gold Refractor (T3) / Superfractor (T4).
Depends on WP-07 (refractor/cards API endpoint).
"""
import logging
from typing import Optional
import discord
from discord import app_commands
from discord.app_commands import Choice
from discord.ext import commands
from api_calls import db_get
from helpers.main import get_team_by_owner
logger = logging.getLogger("discord_app")
PAGE_SIZE = 10
TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
FORMULA_LABELS = {
"batter": "PA+TB×2",
"sp": "IP+K",
"rp": "IP+K",
}
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}
def render_progress_bar(current: int, threshold: int, width: int = 10) -> str:
"""
Render a fixed-width ASCII progress bar.
Examples:
render_progress_bar(120, 149) -> '[========--]'
render_progress_bar(0, 100) -> '[----------]'
render_progress_bar(100, 100) -> '[==========]'
"""
if threshold <= 0:
filled = width
else:
ratio = min(current / threshold, 1.0)
filled = round(ratio * width)
empty = width - filled
return f"[{'=' * filled}{'-' * empty}]"
def format_refractor_entry(card_state: dict) -> str:
"""
Format a single card state dict as a display string.
Expected keys: player_name, card_type, current_tier, formula_value,
next_threshold (None if fully evolved).
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the
player name for tiers 1-4. T0 cards have no badge.
Output example:
**[BC] Mike Trout** (Base Chrome)
[========--] 120/149 (PA+TB×2) — T1 → T2
"""
player_name = card_state.get("player_name", "Unknown")
track = card_state.get("track", {})
card_type = track.get("card_type", "batter")
current_tier = card_state.get("current_tier", 0)
formula_value = int(card_state.get("current_value", 0))
next_threshold = int(card_state.get("next_threshold") or 0) or None
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
formula_label = FORMULA_LABELS.get(card_type, card_type)
badge = TIER_BADGES.get(current_tier, "")
display_name = f"{badge} {player_name}" if badge else player_name
if current_tier >= 4 or next_threshold is None:
bar = "[==========]"
detail = "FULLY EVOLVED ★"
else:
bar = render_progress_bar(formula_value, next_threshold)
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}"
first_line = f"**{display_name}** ({tier_label})"
second_line = f"{bar} {detail}"
return f"{first_line}\n{second_line}"
def apply_close_filter(card_states: list) -> list:
"""
Return only cards within 80% of their next tier threshold.
Fully evolved cards (T4 or no next_threshold) are excluded.
"""
result = []
for state in card_states:
current_tier = state.get("current_tier", 0)
formula_value = int(state.get("current_value", 0))
next_threshold = state.get("next_threshold")
if current_tier >= 4 or not next_threshold:
continue
if formula_value >= 0.8 * int(next_threshold):
result.append(state)
return result
def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
"""
Slice items for the given 1-indexed page.
Returns (page_items, total_pages). Page is clamped to valid range.
"""
total_pages = max(1, (len(items) + page_size - 1) // page_size)
page = max(1, min(page, total_pages))
start = (page - 1) * page_size
return items[start : start + page_size], total_pages
class Refractor(commands.Cog):
"""Refractor progress tracking slash commands."""
def __init__(self, bot):
self.bot = bot
group_refractor = app_commands.Group(
name="refractor", description="Refractor tracking commands"
)
@group_refractor.command(
name="status", description="Show your team's refractor progress"
)
@app_commands.describe(
card_type="Filter by card type",
tier="Filter by current tier",
progress="Filter by advancement progress",
page="Page number (default: 1, 10 cards per page)",
)
@app_commands.choices(
card_type=[
Choice(value="batter", name="Batter"),
Choice(value="sp", name="Starting Pitcher"),
Choice(value="rp", name="Relief Pitcher"),
],
tier=[
Choice(value="0", name="T0 — Base Card"),
Choice(value="1", name="T1 — Base Chrome"),
Choice(value="2", name="T2 — Refractor"),
Choice(value="3", name="T3 — Gold Refractor"),
Choice(value="4", name="T4 — Superfractor"),
],
progress=[
Choice(value="close", name="Close to next tier (≥80%)"),
],
)
async def refractor_status(
self,
interaction: discord.Interaction,
card_type: Optional[Choice[str]] = None,
tier: Optional[Choice[str]] = None,
progress: Optional[Choice[str]] = None,
page: int = 1,
):
"""Show a paginated view of the invoking user's team refractor progress."""
await interaction.response.defer(ephemeral=True)
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.edit_original_response(
content="You don't have a team. Sign up with /newteam first."
)
return
page = max(1, page)
offset = (page - 1) * PAGE_SIZE
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
if card_type:
params.append(("card_type", card_type.value))
if tier is not None:
params.append(("tier", tier.value))
if progress:
params.append(("progress", progress.value))
data = await db_get("refractor/cards", params=params)
if not data:
logger.error(
"Refractor API returned empty response for team %s", team["id"]
)
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
# API error responses contain "detail" key
if isinstance(data, dict) and "detail" in data:
logger.error(
"Refractor API error for team %s: %s", team["id"], data["detail"]
)
await interaction.edit_original_response(
content="Something went wrong fetching refractor data. Please try again later."
)
return
items = data if isinstance(data, list) else data.get("items", [])
total_count = (
data.get("count", len(items)) if isinstance(data, dict) else len(items)
)
logger.debug(
"Refractor status for team %s: %d items returned, %d total (page %d)",
team["id"],
len(items),
total_count,
page,
)
if not items:
if progress == "close":
await interaction.edit_original_response(
content="No cards are currently close to a tier advancement."
)
else:
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages)
page_items = items
lines = [format_refractor_entry(state) for state in page_items]
embed = discord.Embed(
title=f"{team['sname']} Refractor Status",
description="\n\n".join(lines),
color=0x6F42C1,
)
embed.set_footer(
text=f"Page {page}/{total_pages} · {total_count} card(s) total"
)
await interaction.edit_original_response(embed=embed)
async def setup(bot):
await bot.add_cog(Refractor(bot))