All checks were successful
Ruff Lint / lint (pull_request) Successful in 21s
Replace plain ASCII progress bars and text badges with a polished embed: - Unicode block progress bars (▰▱) replacing ASCII [===---] - Tier-specific symbols (○ ◈ ◆ ✦ ★) instead of [BC]/[R]/[GR]/[SF] badges - Team-branded embeds via get_team_embed (color, logo, season footer) - Tier distribution summary header in code block - Percentage display and backtick-wrapped values - Tier-specific accent colors for single-tier filtered views - Sparkle treatment for fully evolved cards (✧ FULLY EVOLVED ✧) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
435 lines
14 KiB
Python
435 lines
14 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.discord_utils import get_team_embed
|
||
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-specific symbols for visual hierarchy in the status display.
|
||
TIER_SYMBOLS = {
|
||
0: "○", # Base Card — hollow circle
|
||
1: "◈", # Base Chrome — diamond with dot
|
||
2: "◆", # Refractor — filled diamond
|
||
3: "✦", # Gold Refractor — four-pointed star
|
||
4: "★", # Superfractor — filled star
|
||
}
|
||
|
||
# Embed accent colors per tier (used for single-tier filtered views).
|
||
TIER_COLORS = {
|
||
0: 0x95A5A6, # slate grey
|
||
1: 0xBDC3C7, # silver/chrome
|
||
2: 0x3498DB, # refractor blue
|
||
3: 0xF1C40F, # gold
|
||
4: 0x1ABC9C, # teal superfractor
|
||
}
|
||
|
||
|
||
def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
|
||
"""
|
||
Render a Unicode block 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 = max(0.0, min(current / threshold, 1.0))
|
||
filled = round(ratio * width)
|
||
empty = width - filled
|
||
return f"{'▰' * filled}{'▱' * empty}"
|
||
|
||
|
||
def _pct_label(current: int, threshold: int) -> str:
|
||
"""Return a percentage string like '80%'."""
|
||
if threshold <= 0:
|
||
return "100%"
|
||
return f"{min(current / threshold, 1.0):.0%}"
|
||
|
||
|
||
def format_refractor_entry(card_state: dict) -> str:
|
||
"""
|
||
Format a single card state dict as a rich display string.
|
||
|
||
Output example (in-progress):
|
||
◈ **Mike Trout** — Base Chrome
|
||
▰▰▰▰▰▰▰▰▰▰▱▱ `120/149` 80% · PA+TB×2 · T1 → T2
|
||
|
||
Output example (fully evolved):
|
||
★ **Barry Bonds** — Superfractor
|
||
▰▰▰▰▰▰▰▰▰▰▰▰ ✧ FULLY EVOLVED ✧
|
||
"""
|
||
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)
|
||
symbol = TIER_SYMBOLS.get(current_tier, "·")
|
||
|
||
first_line = f"{symbol} **{player_name}** — {tier_label}"
|
||
|
||
if current_tier >= 4 or next_threshold is None:
|
||
bar = render_progress_bar(1, 1)
|
||
second_line = f"{bar} ✧ FULLY EVOLVED ✧"
|
||
else:
|
||
bar = render_progress_bar(formula_value, next_threshold)
|
||
pct = _pct_label(formula_value, next_threshold)
|
||
second_line = (
|
||
f"{bar} `{formula_value}/{next_threshold}` {pct}"
|
||
f" · {formula_label} · T{current_tier} → T{current_tier + 1}"
|
||
)
|
||
|
||
return f"{first_line}\n{second_line}"
|
||
|
||
|
||
def build_tier_summary(items: list, total_count: int) -> str:
|
||
"""
|
||
Build a one-line summary of tier distribution from the current page items.
|
||
|
||
Returns something like: '○ 3 ◈ 12 ◆ 8 ✦ 5 ★ 2 — 30 cards'
|
||
"""
|
||
counts = {t: 0 for t in range(5)}
|
||
for item in items:
|
||
t = item.get("current_tier", 0)
|
||
if t in counts:
|
||
counts[t] += 1
|
||
|
||
parts = []
|
||
for t in range(5):
|
||
if counts[t] > 0:
|
||
parts.append(f"{TIER_SYMBOLS[t]} {counts[t]}")
|
||
summary = " ".join(parts) if parts else "No cards"
|
||
return f"{summary} — {total_count} total"
|
||
|
||
|
||
def build_status_embed(
|
||
team: dict,
|
||
items: list,
|
||
page: int,
|
||
total_pages: int,
|
||
total_count: int,
|
||
tier_filter: Optional[int] = None,
|
||
) -> discord.Embed:
|
||
"""
|
||
Build the refractor status embed with team branding.
|
||
|
||
Uses get_team_embed for consistent team color/logo/footer, then layers
|
||
on the refractor-specific content.
|
||
"""
|
||
embed = get_team_embed(f"{team['sname']} — Refractor Status", team=team)
|
||
|
||
# Override color for single-tier views to match the tier's identity.
|
||
if tier_filter is not None and tier_filter in TIER_COLORS:
|
||
embed.color = TIER_COLORS[tier_filter]
|
||
|
||
# Header: tier distribution summary
|
||
header = build_tier_summary(items, total_count)
|
||
|
||
# Card entries
|
||
lines = [format_refractor_entry(state) for state in items]
|
||
body = "\n\n".join(lines) if lines else "*No cards found.*"
|
||
|
||
# Separator between header and cards
|
||
embed.description = f"```{header}```\n{body}"
|
||
|
||
# Page indicator in footer (append to existing footer text)
|
||
existing_footer = embed.footer.text or ""
|
||
page_text = f"Page {page}/{total_pages}"
|
||
embed.set_footer(
|
||
text=f"{page_text} · {existing_footer}" if existing_footer else page_text,
|
||
icon_url=embed.footer.icon_url,
|
||
)
|
||
|
||
return embed
|
||
|
||
|
||
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,
|
||
tier_filter: Optional[int] = None,
|
||
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.tier_filter = tier_filter
|
||
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)
|
||
|
||
embed = build_status_embed(
|
||
self.team,
|
||
items,
|
||
self.page,
|
||
self.total_pages,
|
||
self.total_count,
|
||
tier_filter=self.tier_filter,
|
||
)
|
||
self._update_buttons()
|
||
await interaction.response.edit_message(embed=embed, view=self)
|
||
|
||
@discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.grey)
|
||
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.grey)
|
||
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))
|
||
|
||
tier_filter = int(tier.value) if tier is not None else None
|
||
|
||
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:
|
||
has_filters = card_type or tier is not None or progress
|
||
if has_filters:
|
||
parts = []
|
||
if card_type:
|
||
parts.append(f"**{card_type.name}**")
|
||
if tier is not None:
|
||
parts.append(f"**{tier.name}**")
|
||
if progress:
|
||
parts.append(f"progress: **{progress.name}**")
|
||
filter_str = ", ".join(parts)
|
||
await interaction.edit_original_response(
|
||
content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
|
||
)
|
||
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)
|
||
|
||
embed = build_status_embed(
|
||
team, items, page, total_pages, total_count, tier_filter=tier_filter
|
||
)
|
||
|
||
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,
|
||
tier_filter=tier_filter,
|
||
)
|
||
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))
|