All checks were successful
Ruff Lint / lint (pull_request) Successful in 20s
- Tier labels as suffix tags: **Name** — Base Chrome [T1] (T0 gets no suffix) - Compact progress line: bar value/threshold (pct) — removed formula and tier arrow - Fully evolved shows `MAX` instead of FULLY EVOLVED - Deleted unused FORMULA_LABELS dict - Added _FULL_BAR constant, moved T0-branch lookups into else - Fixed mock API shape in test (cards → items) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
424 lines
14 KiB
Python
424 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",
|
|
}
|
|
|
|
# Tier-specific labels for the status display.
|
|
TIER_SYMBOLS = {
|
|
0: "Base", # Base Card — used in summary only, not in per-card display
|
|
1: "T1", # Base Chrome
|
|
2: "T2", # Refractor
|
|
3: "T3", # Gold Refractor
|
|
4: "T4★", # Superfractor
|
|
}
|
|
|
|
_FULL_BAR = "▰" * 12
|
|
|
|
# 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 compact two-line display string.
|
|
|
|
Output example (base card — no suffix):
|
|
**Mike Trout**
|
|
▰▰▰▰▰▰▰▰▰▰▱▱ 120/149 (80%)
|
|
|
|
Output example (evolved — suffix tag):
|
|
**Mike Trout** — Base Chrome [T1]
|
|
▰▰▰▰▰▰▰▰▰▰▱▱ 120/149 (80%)
|
|
|
|
Output example (fully evolved):
|
|
**Barry Bonds** — Superfractor [T4★]
|
|
▰▰▰▰▰▰▰▰▰▰▰▰ `MAX`
|
|
"""
|
|
player_name = card_state.get("player_name", "Unknown")
|
|
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
|
|
|
|
if current_tier == 0:
|
|
first_line = f"**{player_name}**"
|
|
else:
|
|
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
|
|
symbol = TIER_SYMBOLS.get(current_tier, "")
|
|
first_line = f"**{player_name}** — {tier_label} [{symbol}]"
|
|
|
|
if current_tier >= 4 or next_threshold is None:
|
|
second_line = f"{_FULL_BAR} `MAX`"
|
|
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})"
|
|
|
|
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: 'T0: 3 T1: 12 T2: 8 T3: 5 T4★: 2 — 30 total'
|
|
"""
|
|
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 = build_tier_summary(items, total_count)
|
|
lines = [format_refractor_entry(state) for state in items]
|
|
body = "\n\n".join(lines) if lines else "*No cards found.*"
|
|
embed.description = f"```{header}```\n{body}"
|
|
|
|
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))
|