All checks were successful
Ruff Lint / lint (pull_request) Successful in 19s
Cast current_value and next_threshold to int to avoid ugly floating point numbers like 53.0/149.0 in the progress display. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
7.7 KiB
Python
243 lines
7.7 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.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="Card type filter (batter, sp, rp)",
|
||
season="Season number (default: current)",
|
||
tier="Filter by current tier (0-4)",
|
||
progress='Use "close" to show cards within 80% of their next tier',
|
||
page="Page number (default: 1, 10 cards per page)",
|
||
)
|
||
async def refractor_status(
|
||
self,
|
||
interaction: discord.Interaction,
|
||
card_type: Optional[str] = None,
|
||
season: Optional[int] = None,
|
||
tier: Optional[int] = None,
|
||
progress: Optional[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))
|
||
if season is not None:
|
||
params.append(("season", season))
|
||
if tier is not None:
|
||
params.append(("tier", tier))
|
||
if progress:
|
||
params.append(("progress", progress))
|
||
|
||
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))
|