paper-dynasty-discord/cogs/refractor.py
Cal Corum b9deb14b62
All checks were successful
Ruff Lint / lint (pull_request) Successful in 24s
feat: add Prev/Next navigation buttons to /refractor status
- RefractorPaginationView with ◀ Prev / Next ▶ buttons
- Buttons re-fetch from API on each page change
- Prev disabled on page 1, Next disabled on last page
- Only the command invoker can use the buttons
- Buttons auto-disable after 2 min timeout
- Single-page results show no buttons

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

343 lines
11 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 RefractorPaginationView(discord.ui.View):
"""Prev/Next buttons for refractor status pagination."""
def __init__(
self,
team: dict,
page: int,
total_pages: int,
total_count: int,
params: list,
owner_id: int,
timeout: float = 120.0,
):
super().__init__(timeout=timeout)
self.team = team
self.page = page
self.total_pages = total_pages
self.total_count = total_count
self.base_params = params
self.owner_id = owner_id
self._update_buttons()
def _update_buttons(self):
self.prev_btn.disabled = self.page <= 1
self.next_btn.disabled = self.page >= self.total_pages
async def _fetch_and_update(self, interaction: discord.Interaction):
offset = (self.page - 1) * PAGE_SIZE
params = [(k, v) for k, v in self.base_params if k != "offset"]
params.append(("offset", offset))
data = await db_get("refractor/cards", params=params)
items = data.get("items", []) if isinstance(data, dict) else []
self.total_count = (
data.get("count", self.total_count)
if isinstance(data, dict)
else self.total_count
)
self.total_pages = max(1, (self.total_count + PAGE_SIZE - 1) // PAGE_SIZE)
self.page = min(self.page, self.total_pages)
lines = [format_refractor_entry(state) for state in items]
embed = discord.Embed(
title=f"{self.team['sname']} Refractor Status",
description="\n\n".join(lines) if lines else "No cards found.",
color=0x6F42C1,
)
embed.set_footer(
text=f"Page {self.page}/{self.total_pages} · {self.total_count} card(s) total"
)
self._update_buttons()
await interaction.response.edit_message(embed=embed, view=self)
@discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.blurple)
async def prev_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.owner_id:
return
self.page = max(1, self.page - 1)
await self._fetch_and_update(interaction)
@discord.ui.button(label="Next ▶", style=discord.ButtonStyle.blurple)
async def next_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.owner_id:
return
self.page = min(self.total_pages, self.page + 1)
await self._fetch_and_update(interaction)
async def on_timeout(self):
self.prev_btn.disabled = True
self.next_btn.disabled = True
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)
lines = [format_refractor_entry(state) for state in 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"
)
if total_pages > 1:
view = RefractorPaginationView(
team=team,
page=page,
total_pages=total_pages,
total_count=total_count,
params=params,
owner_id=interaction.user.id,
)
await interaction.edit_original_response(embed=embed, view=view)
else:
await interaction.edit_original_response(embed=embed)
async def setup(bot):
await bot.add_cog(Refractor(bot))