All checks were successful
Ruff Lint / lint (pull_request) Successful in 24s
- 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>
343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""
|
||
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))
|