Compare commits

...

25 Commits

Author SHA1 Message Date
cal
b65d91a65b Merge pull request 'fix: remove docker-compose.yml from tracking, add example template' (#136) from fix/docker-compose-secrets-untrack into main 2026-04-01 18:02:35 +00:00
cal
4bda3bf0de Merge branch 'main' into fix/docker-compose-secrets-untrack
All checks were successful
Ruff Lint / lint (pull_request) Successful in 15s
2026-04-01 18:02:29 +00:00
cal
ff768c95f5 Merge pull request 'chore: add .env.example with placeholder values' (#135) from chore/add-env-example into main 2026-04-01 18:02:17 +00:00
Cal Corum
fb545ef34a fix: remove docker-compose.yml from tracking, add example template
All checks were successful
Ruff Lint / lint (pull_request) Successful in 21s
docker-compose.yml contains hardcoded credentials (BOT_TOKEN, API_TOKEN,
RESTART_WEBHOOK_URL) and should never be committed. The *compose.yml gitignore
rule already excluded it but docker-compose.example.yml was not provided as a
reference for contributors.

- Add !docker-compose.example.yml negation to .gitignore so example is tracked
- Add docker-compose.example.yml with placeholder values for all secrets

Closes paper-dynasty-database#9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:59:40 -05:00
Cal Corum
f704b09933 chore: add .env.example with placeholder values
All checks were successful
Ruff Lint / lint (pull_request) Successful in 29s
Document all required environment variables for running the Discord bot,
including bot token, API credentials, database config, and webhook URL.

References paper-dynasty-database#9
2026-04-01 11:59:44 -05:00
Cal Corum
94f3b1dc97 fix: apply open-packs hotfix to cogs/economy.py
Port the Check-In Player pack fix from the hotfix branch to the legacy
economy.py cog (which production currently loads instead of economy_new).

- Filter auto-open pack types from the manual open-packs menu
- Add pretty_name fallback for hyphenated pack type names
- Add ruff noqa for pre-existing star import warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:04:19 -05:00
cal
fca85d583f Merge pull request 'fix: prevent crash when Check-In Player packs in open-packs' (#134) from hotfix/open-packs-checkin into main 2026-03-26 13:50:09 +00:00
cal
b6592b8a70 Merge branch 'main' into hotfix/open-packs-checkin
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
2026-03-26 13:50:01 +00:00
Cal Corum
01f6fb50d5 fix: prevent crash when Check-In Player packs appear in open-packs menu
All checks were successful
Build Docker Image / build (pull_request) Successful in 3m17s
Check-In Player packs (auto-opened by daily check-in) could end up orphaned
in inventory if roll_for_cards failed. The open-packs command crashed because:
1. The hyphenated pack type name bypassed the pretty_name logic, producing an
   empty select menu that Discord rejected (400 Bad Request)
2. Even if displayed, selecting it would raise KeyError in the callback since
   "Check-In Player".split("-") doesn't match any known pack type token

Fixes:
- Filter auto-open pack types out of the manual open-packs menu
- Add fallback for hyphenated pack type names in pretty_name logic
- Replace KeyError with graceful user-facing message for unknown pack types
- Change "contact an admin" to "contact Cal" in all user-facing messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:45:51 -05:00
cal
f843b45099 Merge pull request 'fix: clean up refractor status — suffix tags, compact layout' (#133) from fix/refractor-status-cleanup into main
All checks were successful
Build Docker Image / build (push) Successful in 2m47s
2026-03-26 06:00:32 +00:00
Cal Corum
bbad1daba2 fix: clean up refractor status display — suffix tags, compact layout, dead code removal
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>
2026-03-26 00:22:35 -05:00
cal
2d7c19814e Merge pull request 'fix: replace abstract tier symbols with readable labels' (#132) from fix/refractor-tier-labels into main
All checks were successful
Build Docker Image / build (push) Successful in 2m46s
2026-03-26 04:49:19 +00:00
Cal Corum
c3ff85fd2d fix: replace abstract tier symbols with readable labels in /refractor status
All checks were successful
Ruff Lint / lint (pull_request) Successful in 11s
Unicode symbols (○ ◈ ◆ ✦ ★) were too similar to distinguish at a glance.
Now uses T1/T2/T3/T4★ prefixes with no prefix for base cards (T0).
Summary header reads "Base: 1  T1: 9 — 64 total" instead of cryptic symbols.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:47:03 -05:00
cal
64c656ce91 Merge pull request 'feat: redesign /refractor status with rich Unicode display' (#129) from feat/refractor-status-redesign into main
All checks were successful
Build Docker Image / build (push) Successful in 2m42s
2026-03-26 03:50:41 +00:00
Cal Corum
cd822857bf feat: redesign /refractor status with rich Unicode display and team branding
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>
2026-03-25 22:46:38 -05:00
cal
34774290b8 Merge pull request 'fix: context-aware empty state messages for /refractor status' (#128) from fix/refractor-empty-filter-message into main
All checks were successful
Build Docker Image / build (push) Successful in 3m29s
2026-03-25 23:57:53 +00:00
Cal Corum
6239f1177c fix: context-aware empty state messages for /refractor status
All checks were successful
Ruff Lint / lint (pull_request) Successful in 23s
When filters are active and return 0 results, show which filters were
applied and suggest removing them, instead of the misleading
"No refractor data found for your team."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:57:32 -05:00
cal
dea6316201 Merge pull request 'feat: add Prev/Next navigation buttons to /refractor status' (#127) from feat/refractor-pagination-buttons into main
All checks were successful
Build Docker Image / build (push) Successful in 3m8s
2026-03-25 22:43:29 +00:00
Cal Corum
b9deb14b62 feat: add Prev/Next navigation buttons to /refractor status
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>
2026-03-25 17:43:06 -05:00
cal
48392a9bbe Merge pull request 'feat: use Discord Choice menus for /refractor status parameters' (#126) from enhancement/refractor-choice-params into main
All checks were successful
Build Docker Image / build (push) Successful in 2m40s
2026-03-25 22:16:53 +00:00
Cal Corum
a53cc5cac3 feat: use Discord Choice menus for /refractor status parameters
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
Replace freeform text inputs with dropdown selections:
- card_type: Batter, Starting Pitcher, Relief Pitcher
- tier: T0-T4 with names (Base Card through Superfractor)
- progress: "Close to next tier" option
- Removed season param (not useful for current UX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:16:26 -05:00
cal
a8b4d6cdbb Merge pull request 'fix: round refractor values to integers in display' (#125) from fix/refractor-round-values into main
All checks were successful
Build Docker Image / build (push) Successful in 2m39s
2026-03-25 21:55:24 +00:00
Cal Corum
8d2cdc81fe fix: round refractor values to integers in display
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>
2026-03-25 16:55:03 -05:00
cal
27ce8b3617 Merge pull request 'fix: add debug logging for successful refractor API responses' (#124) from fix/refractor-debug-logging into main
All checks were successful
Build Docker Image / build (push) Successful in 2m47s
2026-03-25 21:47:41 +00:00
Cal Corum
17d124feb4 fix: add debug logging for successful refractor API responses
All checks were successful
Ruff Lint / lint (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:46:01 -05:00
10 changed files with 1131 additions and 727 deletions

54
.env.example Normal file
View File

@ -0,0 +1,54 @@
# Paper Dynasty Discord Bot - Environment Configuration
# Copy this file to .env and fill in your actual values.
# DO NOT commit .env to version control!
# =============================================================================
# BOT CONFIGURATION
# =============================================================================
# Discord bot token (from Discord Developer Portal)
BOT_TOKEN=your-discord-bot-token-here
# Discord server (guild) ID
GUILD_ID=your-guild-id-here
# Channel ID for scoreboard messages
SCOREBOARD_CHANNEL=your-scoreboard-channel-id-here
# =============================================================================
# API CONFIGURATION
# =============================================================================
# Paper Dynasty API authentication token
API_TOKEN=your-api-token-here
# Target database environment: 'dev' or 'prod'
# Default: dev
DATABASE=dev
# =============================================================================
# APPLICATION CONFIGURATION
# =============================================================================
# Logging level: DEBUG, INFO, WARNING, ERROR
# Default: INFO
LOG_LEVEL=INFO
# Python hash seed (set for reproducibility in tests)
PYTHONHASHSEED=0
# =============================================================================
# DATABASE CONFIGURATION (PostgreSQL — used by gameplay_models.py)
# =============================================================================
DB_USERNAME=your_db_username
DB_PASSWORD=your_db_password
DB_URL=localhost
DB_NAME=postgres
# =============================================================================
# WEBHOOKS
# =============================================================================
# Discord webhook URL for restart notifications
RESTART_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-id/your-webhook-token

1
.gitignore vendored
View File

@ -133,6 +133,7 @@ dmypy.json
storage* storage*
storage/paper-dynasty-service-creds.json storage/paper-dynasty-service-creds.json
*compose.yml *compose.yml
!docker-compose.example.yml
**.db **.db
**/htmlcov **/htmlcov
.vscode/** .vscode/**

View File

@ -1,3 +1,4 @@
# ruff: noqa: F403, F405
import copy import copy
import helpers import helpers
@ -695,6 +696,9 @@ class Economy(commands.Cog):
) )
return return
# Pack types that are auto-opened and should not appear in the manual open menu
AUTO_OPEN_TYPES = {"Check-In Player"}
# Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium) # Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium)
p_count = 0 p_count = 0
p_data = { p_data = {
@ -711,6 +715,11 @@ class Economy(commands.Cog):
p_group = None p_group = None
logger.debug(f"pack: {pack}") logger.debug(f"pack: {pack}")
logger.debug(f"pack cardset: {pack['pack_cardset']}") logger.debug(f"pack cardset: {pack['pack_cardset']}")
if pack["pack_type"]["name"] in AUTO_OPEN_TYPES:
logger.debug(
f"Skipping auto-open pack type: {pack['pack_type']['name']}"
)
continue
if pack["pack_team"] is None and pack["pack_cardset"] is None: if pack["pack_team"] is None and pack["pack_cardset"] is None:
p_group = pack["pack_type"]["name"] p_group = pack["pack_type"]["name"]
# Add to p_data if this is a new pack type # Add to p_data if this is a new pack type
@ -773,6 +782,9 @@ class Economy(commands.Cog):
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}" pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
elif "Cardset" in key: elif "Cardset" in key:
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}" pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
else:
# Pack type name contains a hyphen (e.g. "Check-In Player")
pretty_name = key
if pretty_name is not None: if pretty_name is not None:
embed.add_field(name=pretty_name, value=f"Qty: {len(p_data[key])}") embed.add_field(name=pretty_name, value=f"Qty: {len(p_data[key])}")

View File

@ -1,4 +1,4 @@
# Economy Packs Module # Economy Packs Module
# Contains pack opening, daily rewards, and donation commands from the original economy.py # Contains pack opening, daily rewards, and donation commands from the original economy.py
import logging import logging
@ -9,97 +9,135 @@ import datetime
# Import specific utilities needed by this module # Import specific utilities needed by this module
import random import random
from api_calls import db_get, db_post, db_patch from api_calls import db_get, db_post
from helpers.constants import PD_PLAYERS_ROLE_NAME, PD_PLAYERS, IMAGES from helpers.constants import PD_PLAYERS_ROLE_NAME, PD_PLAYERS, IMAGES
from helpers import ( from helpers import (
get_team_by_owner, display_cards, give_packs, legal_channel, get_channel, get_team_by_owner,
get_cal_user, refresh_sheet, roll_for_cards, int_timestamp, get_context_user display_cards,
give_packs,
legal_channel,
get_channel,
get_cal_user,
refresh_sheet,
roll_for_cards,
int_timestamp,
get_context_user,
) )
from helpers.discord_utils import get_team_embed, send_to_channel, get_emoji from helpers.discord_utils import get_team_embed, get_emoji
from discord_ui import SelectView, SelectOpenPack from discord_ui import SelectView, SelectOpenPack
logger = logging.getLogger('discord_app') logger = logging.getLogger("discord_app")
class Packs(commands.Cog): class Packs(commands.Cog):
"""Pack management, daily rewards, and donation system for Paper Dynasty.""" """Pack management, daily rewards, and donation system for Paper Dynasty."""
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@commands.hybrid_group(name='donation', help='Mod: Give packs for PD donations') @commands.hybrid_group(name="donation", help="Mod: Give packs for PD donations")
@commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.has_any_role(PD_PLAYERS_ROLE_NAME)
async def donation(self, ctx: commands.Context): async def donation(self, ctx: commands.Context):
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
await ctx.send('To buy packs, visit https://ko-fi.com/manticorum/shop and include your discord username!') await ctx.send(
"To buy packs, visit https://ko-fi.com/manticorum/shop and include your discord username!"
)
@donation.command(name='premium', help='Mod: Give premium packs', aliases=['p', 'prem']) @donation.command(
name="premium", help="Mod: Give premium packs", aliases=["p", "prem"]
)
async def donation_premium(self, ctx: commands.Context, num_packs: int, gm: Member): async def donation_premium(self, ctx: commands.Context, num_packs: int, gm: Member):
if ctx.author.id != self.bot.owner_id: if ctx.author.id != self.bot.owner_id:
await ctx.send('Wait a second. You\'re not in charge here!') await ctx.send("Wait a second. You're not in charge here!")
return return
team = await get_team_by_owner(gm.id) team = await get_team_by_owner(gm.id)
p_query = await db_get('packtypes', params=[('name', 'Premium')]) p_query = await db_get("packtypes", params=[("name", "Premium")])
if p_query['count'] == 0: if p_query["count"] == 0:
await ctx.send('Oof. I couldn\'t find a Premium Pack') await ctx.send("Oof. I couldn't find a Premium Pack")
return return
total_packs = await give_packs(team, num_packs, pack_type=p_query['packtypes'][0]) total_packs = await give_packs(
await ctx.send(f'The {team["lname"]} now have {total_packs["count"]} total packs!') team, num_packs, pack_type=p_query["packtypes"][0]
)
await ctx.send(
f"The {team['lname']} now have {total_packs['count']} total packs!"
)
@donation.command(name='standard', help='Mod: Give standard packs', aliases=['s', 'sta']) @donation.command(
async def donation_standard(self, ctx: commands.Context, num_packs: int, gm: Member): name="standard", help="Mod: Give standard packs", aliases=["s", "sta"]
)
async def donation_standard(
self, ctx: commands.Context, num_packs: int, gm: Member
):
if ctx.author.id != self.bot.owner_id: if ctx.author.id != self.bot.owner_id:
await ctx.send('Wait a second. You\'re not in charge here!') await ctx.send("Wait a second. You're not in charge here!")
return return
team = await get_team_by_owner(gm.id) team = await get_team_by_owner(gm.id)
p_query = await db_get('packtypes', params=[('name', 'Standard')]) p_query = await db_get("packtypes", params=[("name", "Standard")])
if p_query['count'] == 0: if p_query["count"] == 0:
await ctx.send('Oof. I couldn\'t find a Standard Pack') await ctx.send("Oof. I couldn't find a Standard Pack")
return return
total_packs = await give_packs(team, num_packs, pack_type=p_query['packtypes'][0]) total_packs = await give_packs(
await ctx.send(f'The {team["lname"]} now have {total_packs["count"]} total packs!') team, num_packs, pack_type=p_query["packtypes"][0]
)
await ctx.send(
f"The {team['lname']} now have {total_packs['count']} total packs!"
)
@commands.hybrid_command(name='lastpack', help='Replay your last pack') @commands.hybrid_command(name="lastpack", help="Replay your last pack")
@commands.check(legal_channel) @commands.check(legal_channel)
@commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.has_any_role(PD_PLAYERS_ROLE_NAME)
async def last_pack_command(self, ctx: commands.Context): async def last_pack_command(self, ctx: commands.Context):
team = await get_team_by_owner(get_context_user(ctx).id) team = await get_team_by_owner(get_context_user(ctx).id)
if not team: if not team:
await ctx.send(f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!') await ctx.send(
"I don't see a team for you, yet. You can sign up with the `/newteam` command!"
)
return return
p_query = await db_get( p_query = await db_get(
'packs', "packs",
params=[('opened', True), ('team_id', team['id']), ('new_to_old', True), ('limit', 1)] params=[
("opened", True),
("team_id", team["id"]),
("new_to_old", True),
("limit", 1),
],
) )
if not p_query['count']: if not p_query["count"]:
await ctx.send(f'I do not see any packs for you, bub.') await ctx.send("I do not see any packs for you, bub.")
return return
pack_name = p_query['packs'][0]['pack_type']['name'] pack_name = p_query["packs"][0]["pack_type"]["name"]
if pack_name == 'Standard': if pack_name == "Standard":
pack_cover = IMAGES['pack-sta'] pack_cover = IMAGES["pack-sta"]
elif pack_name == 'Premium': elif pack_name == "Premium":
pack_cover = IMAGES['pack-pre'] pack_cover = IMAGES["pack-pre"]
else: else:
pack_cover = None pack_cover = None
c_query = await db_get( c_query = await db_get("cards", params=[("pack_id", p_query["packs"][0]["id"])])
'cards', if not c_query["count"]:
params=[('pack_id', p_query['packs'][0]['id'])] await ctx.send("Hmm...I didn't see any cards in that pack.")
)
if not c_query['count']:
await ctx.send(f'Hmm...I didn\'t see any cards in that pack.')
return return
await display_cards(c_query['cards'], team, ctx.channel, ctx.author, self.bot, pack_cover=pack_cover) await display_cards(
c_query["cards"],
team,
ctx.channel,
ctx.author,
self.bot,
pack_cover=pack_cover,
)
@app_commands.command(name='comeonmanineedthis', description='Daily check-in for cards, currency, and packs') @app_commands.command(
name="comeonmanineedthis",
description="Daily check-in for cards, currency, and packs",
)
@commands.has_any_role(PD_PLAYERS) @commands.has_any_role(PD_PLAYERS)
@commands.check(legal_channel) @commands.check(legal_channel)
async def daily_checkin(self, interaction: discord.Interaction): async def daily_checkin(self, interaction: discord.Interaction):
@ -107,97 +145,127 @@ class Packs(commands.Cog):
team = await get_team_by_owner(interaction.user.id) team = await get_team_by_owner(interaction.user.id)
if not team: if not team:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' content="I don't see a team for you, yet. You can sign up with the `/newteam` command!"
) )
return return
current = await db_get('current') current = await db_get("current")
now = datetime.datetime.now() now = datetime.datetime.now()
midnight = int_timestamp(datetime.datetime(now.year, now.month, now.day, 0, 0, 0)) midnight = int_timestamp(
daily = await db_get('rewards', params=[ datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
('name', 'Daily Check-in'), ('team_id', team['id']), ('created_after', midnight) )
]) daily = await db_get(
logger.debug(f'midnight: {midnight} / now: {int_timestamp(now)}') "rewards",
logger.debug(f'daily_return: {daily}') params=[
("name", "Daily Check-in"),
("team_id", team["id"]),
("created_after", midnight),
],
)
logger.debug(f"midnight: {midnight} / now: {int_timestamp(now)}")
logger.debug(f"daily_return: {daily}")
if daily: if daily:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'Looks like you already checked in today - come back at midnight Central!' content="Looks like you already checked in today - come back at midnight Central!"
) )
return return
await db_post('rewards', payload={ await db_post(
'name': 'Daily Check-in', 'team_id': team['id'], 'season': current['season'], 'week': current['week'], "rewards",
'created': int_timestamp(now) payload={
}) "name": "Daily Check-in",
current = await db_get('current') "team_id": team["id"],
check_ins = await db_get('rewards', params=[ "season": current["season"],
('name', 'Daily Check-in'), ('team_id', team['id']), ('season', current['season']) "week": current["week"],
]) "created": int_timestamp(now),
},
)
current = await db_get("current")
check_ins = await db_get(
"rewards",
params=[
("name", "Daily Check-in"),
("team_id", team["id"]),
("season", current["season"]),
],
)
check_count = check_ins['count'] % 5 check_count = check_ins["count"] % 5
# 2nd, 4th, and 5th check-ins # 2nd, 4th, and 5th check-ins
if check_count == 0 or check_count % 2 == 0: if check_count == 0 or check_count % 2 == 0:
# Every fifth check-in # Every fifth check-in
if check_count == 0: if check_count == 0:
greeting = await interaction.edit_original_response( greeting = await interaction.edit_original_response(
content=f'Hey, you just earned a Standard pack of cards!' content="Hey, you just earned a Standard pack of cards!"
) )
pack_channel = get_channel(interaction, 'pack-openings') pack_channel = get_channel(interaction, "pack-openings")
p_query = await db_get('packtypes', params=[('name', 'Standard')]) p_query = await db_get("packtypes", params=[("name", "Standard")])
if not p_query: if not p_query:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I was not able to pull this pack for you. ' content=f"I was not able to pull this pack for you. "
f'Maybe ping {get_cal_user(interaction).mention}?' f"Maybe ping {get_cal_user(interaction).mention}?"
) )
return return
# Every second and fourth check-in # Every second and fourth check-in
else: else:
greeting = await interaction.edit_original_response( greeting = await interaction.edit_original_response(
content=f'Hey, you just earned a player card!' content="Hey, you just earned a player card!"
) )
pack_channel = interaction.channel pack_channel = interaction.channel
p_query = await db_get('packtypes', params=[('name', 'Check-In Player')]) p_query = await db_get(
"packtypes", params=[("name", "Check-In Player")]
)
if not p_query: if not p_query:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I was not able to pull this card for you. ' content=f"I was not able to pull this card for you. "
f'Maybe ping {get_cal_user(interaction).mention}?' f"Maybe ping {get_cal_user(interaction).mention}?"
) )
return return
await give_packs(team, 1, p_query['packtypes'][0]) await give_packs(team, 1, p_query["packtypes"][0])
p_query = await db_get( p_query = await db_get(
'packs', "packs",
params=[('opened', False), ('team_id', team['id']), ('new_to_old', True), ('limit', 1)] params=[
("opened", False),
("team_id", team["id"]),
("new_to_old", True),
("limit", 1),
],
) )
if not p_query['count']: if not p_query["count"]:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I do not see any packs in here. {await get_emoji(interaction, "ConfusedPsyduck")}') content=f"I do not see any packs in here. {await get_emoji(interaction, 'ConfusedPsyduck')}"
)
return return
pack_ids = await roll_for_cards(p_query['packs'], extra_val=check_ins['count']) pack_ids = await roll_for_cards(
p_query["packs"], extra_val=check_ins["count"]
)
if not pack_ids: if not pack_ids:
await greeting.edit( await greeting.edit(
content=f'I was not able to create these cards {await get_emoji(interaction, "slight_frown")}' content=f"I was not able to create these cards {await get_emoji(interaction, 'slight_frown')}"
) )
return return
all_cards = [] all_cards = []
for p_id in pack_ids: for p_id in pack_ids:
new_cards = await db_get('cards', params=[('pack_id', p_id)]) new_cards = await db_get("cards", params=[("pack_id", p_id)])
all_cards.extend(new_cards['cards']) all_cards.extend(new_cards["cards"])
if not all_cards: if not all_cards:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I was not able to pull these cards {await get_emoji(interaction, "slight_frown")}' content=f"I was not able to pull these cards {await get_emoji(interaction, 'slight_frown')}"
) )
return return
await display_cards(all_cards, team, pack_channel, interaction.user, self.bot) await display_cards(
all_cards, team, pack_channel, interaction.user, self.bot
)
await refresh_sheet(team, self.bot) await refresh_sheet(team, self.bot)
return return
@ -215,87 +283,102 @@ class Packs(commands.Cog):
else: else:
m_reward = 25 m_reward = 25
team = await db_post(f'teams/{team["id"]}/money/{m_reward}') team = await db_post(f"teams/{team['id']}/money/{m_reward}")
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'You just earned {m_reward}₼! That brings your wallet to {team["wallet"]}₼!') content=f"You just earned {m_reward}₼! That brings your wallet to {team['wallet']}₼!"
)
@app_commands.command(name='open-packs', description='Open packs from your inventory') @app_commands.command(
name="open-packs", description="Open packs from your inventory"
)
@app_commands.checks.has_any_role(PD_PLAYERS) @app_commands.checks.has_any_role(PD_PLAYERS)
async def open_packs_slash(self, interaction: discord.Interaction): async def open_packs_slash(self, interaction: discord.Interaction):
if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']: if interaction.channel.name in [
"paper-dynasty-chat",
"pd-news-ticker",
"pd-network-news",
]:
await interaction.response.send_message( await interaction.response.send_message(
f'Please head to down to {get_channel(interaction, "pd-bot-hole")} to run this command.', f"Please head to down to {get_channel(interaction, 'pd-bot-hole')} to run this command.",
ephemeral=True ephemeral=True,
) )
return return
owner_team = await get_team_by_owner(interaction.user.id) owner_team = await get_team_by_owner(interaction.user.id)
if not owner_team: if not owner_team:
await interaction.response.send_message( await interaction.response.send_message(
f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!' "I don't see a team for you, yet. You can sign up with the `/newteam` command!"
) )
return return
p_query = await db_get('packs', params=[ p_query = await db_get(
('team_id', owner_team['id']), ('opened', False) "packs", params=[("team_id", owner_team["id"]), ("opened", False)]
]) )
if p_query['count'] == 0: if p_query["count"] == 0:
await interaction.response.send_message( await interaction.response.send_message(
f'Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by ' "Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by "
f'donating to the league.' "donating to the league."
) )
return return
# Pack types that are auto-opened and should not appear in the manual open menu
AUTO_OPEN_TYPES = {"Check-In Player"}
# Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium) # Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium)
p_count = 0 p_count = 0
p_data = { p_data = {
'Standard': [], "Standard": [],
'Premium': [], "Premium": [],
'Daily': [], "Daily": [],
'MVP': [], "MVP": [],
'All Star': [], "All Star": [],
'Mario': [], "Mario": [],
'Team Choice': [] "Team Choice": [],
} }
logger.debug(f'Parsing packs...') logger.debug("Parsing packs...")
for pack in p_query['packs']: for pack in p_query["packs"]:
p_group = None p_group = None
logger.debug(f'pack: {pack}') logger.debug(f"pack: {pack}")
logger.debug(f'pack cardset: {pack["pack_cardset"]}') logger.debug(f"pack cardset: {pack['pack_cardset']}")
if pack['pack_team'] is None and pack['pack_cardset'] is None: if pack["pack_type"]["name"] in AUTO_OPEN_TYPES:
p_group = pack['pack_type']['name'] logger.debug(
f"Skipping auto-open pack type: {pack['pack_type']['name']}"
)
continue
if pack["pack_team"] is None and pack["pack_cardset"] is None:
p_group = pack["pack_type"]["name"]
# Add to p_data if this is a new pack type # Add to p_data if this is a new pack type
if p_group not in p_data: if p_group not in p_data:
p_data[p_group] = [] p_data[p_group] = []
elif pack['pack_team'] is not None: elif pack["pack_team"] is not None:
if pack['pack_type']['name'] == 'Standard': if pack["pack_type"]["name"] == "Standard":
p_group = f'Standard-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' p_group = f"Standard-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
elif pack['pack_type']['name'] == 'Premium': elif pack["pack_type"]["name"] == "Premium":
p_group = f'Premium-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' p_group = f"Premium-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
elif pack['pack_type']['name'] == 'Team Choice': elif pack["pack_type"]["name"] == "Team Choice":
p_group = f'Team Choice-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' p_group = f"Team Choice-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
elif pack['pack_type']['name'] == 'MVP': elif pack["pack_type"]["name"] == "MVP":
p_group = f'MVP-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}' p_group = f"MVP-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
if pack['pack_cardset'] is not None: if pack["pack_cardset"] is not None:
p_group += f'-Cardset-{pack["pack_cardset"]["id"]}' p_group += f"-Cardset-{pack['pack_cardset']['id']}"
elif pack['pack_cardset'] is not None: elif pack["pack_cardset"] is not None:
if pack['pack_type']['name'] == 'Standard': if pack["pack_type"]["name"] == "Standard":
p_group = f'Standard-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' p_group = f"Standard-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
elif pack['pack_type']['name'] == 'Premium': elif pack["pack_type"]["name"] == "Premium":
p_group = f'Premium-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' p_group = f"Premium-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
elif pack['pack_type']['name'] == 'Team Choice': elif pack["pack_type"]["name"] == "Team Choice":
p_group = f'Team Choice-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' p_group = f"Team Choice-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
elif pack['pack_type']['name'] == 'All Star': elif pack["pack_type"]["name"] == "All Star":
p_group = f'All Star-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' p_group = f"All Star-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
elif pack['pack_type']['name'] == 'MVP': elif pack["pack_type"]["name"] == "MVP":
p_group = f'MVP-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' p_group = f"MVP-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
elif pack['pack_type']['name'] == 'Promo Choice': elif pack["pack_type"]["name"] == "Promo Choice":
p_group = f'Promo Choice-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}' p_group = f"Promo Choice-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
logger.info(f'p_group: {p_group}') logger.info(f"p_group: {p_group}")
if p_group is not None: if p_group is not None:
p_count += 1 p_count += 1
if p_group not in p_data: if p_group not in p_data:
@ -305,34 +388,41 @@ class Packs(commands.Cog):
if p_count == 0: if p_count == 0:
await interaction.response.send_message( await interaction.response.send_message(
f'Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by ' "Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by "
f'donating to the league.' "donating to the league."
) )
return return
# Display options and ask which group to open # Display options and ask which group to open
embed = get_team_embed(f'Unopened Packs', team=owner_team) embed = get_team_embed("Unopened Packs", team=owner_team)
embed.description = owner_team['lname'] embed.description = owner_team["lname"]
select_options = [] select_options = []
for key in p_data: for key in p_data:
if len(p_data[key]) > 0: if len(p_data[key]) > 0:
pretty_name = None pretty_name = None
# Not a specific pack # Not a specific pack
if '-' not in key: if "-" not in key:
pretty_name = key
elif "Team" in key:
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
elif "Cardset" in key:
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
else:
# Pack type name contains a hyphen (e.g. "Check-In Player")
pretty_name = key pretty_name = key
elif 'Team' in key:
pretty_name = f'{key.split("-")[0]} - {key.split("-")[3]}'
elif 'Cardset' in key:
pretty_name = f'{key.split("-")[0]} - {key.split("-")[3]}'
if pretty_name is not None: if pretty_name is not None:
embed.add_field(name=pretty_name, value=f'Qty: {len(p_data[key])}') embed.add_field(name=pretty_name, value=f"Qty: {len(p_data[key])}")
select_options.append(discord.SelectOption(label=pretty_name, value=key)) select_options.append(
discord.SelectOption(label=pretty_name, value=key)
)
view = SelectView(select_objects=[SelectOpenPack(select_options, owner_team)], timeout=15) view = SelectView(
select_objects=[SelectOpenPack(select_options, owner_team)], timeout=15
)
await interaction.response.send_message(embed=embed, view=view) await interaction.response.send_message(embed=embed, view=view)
async def setup(bot): async def setup(bot):
"""Setup function for the Packs cog.""" """Setup function for the Packs cog."""
await bot.add_cog(Packs(bot)) await bot.add_cog(Packs(bot))

View File

@ -15,9 +15,11 @@ from typing import Optional
import discord import discord
from discord import app_commands from discord import app_commands
from discord.app_commands import Choice
from discord.ext import commands from discord.ext import commands
from api_calls import db_get from api_calls import db_get
from helpers.discord_utils import get_team_embed
from helpers.main import get_team_by_owner from helpers.main import get_team_by_owner
logger = logging.getLogger("discord_app") logger = logging.getLogger("discord_app")
@ -32,72 +34,145 @@ TIER_NAMES = {
4: "Superfractor", 4: "Superfractor",
} }
FORMULA_LABELS = { # Tier-specific labels for the status display.
"batter": "PA+TB×2", TIER_SYMBOLS = {
"sp": "IP+K", 0: "Base", # Base Card — used in summary only, not in per-card display
"rp": "IP+K", 1: "T1", # Base Chrome
2: "T2", # Refractor
3: "T3", # Gold Refractor
4: "T4★", # Superfractor
} }
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"} _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 = 10) -> str: def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
""" """
Render a fixed-width ASCII progress bar. Render a Unicode block progress bar.
Examples: Examples:
render_progress_bar(120, 149) -> '[========--]' render_progress_bar(120, 149) -> '▰▰▰▰▰▰▰▰▰▰▱▱'
render_progress_bar(0, 100) -> '[----------]' render_progress_bar(0, 100) -> '▱▱▱▱▱▱▱▱▱▱▱▱'
render_progress_bar(100, 100) -> '[==========]' render_progress_bar(100, 100) -> '▰▰▰▰▰▰▰▰▰▰▰▰'
""" """
if threshold <= 0: if threshold <= 0:
filled = width filled = width
else: else:
ratio = min(current / threshold, 1.0) ratio = max(0.0, min(current / threshold, 1.0))
filled = round(ratio * width) filled = round(ratio * width)
empty = width - filled empty = width - filled
return f"[{'=' * filled}{'-' * empty}]" 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: def format_refractor_entry(card_state: dict) -> str:
""" """
Format a single card state dict as a display string. Format a single card state dict as a compact two-line display string.
Expected keys: player_name, card_type, current_tier, formula_value, Output example (base card no suffix):
next_threshold (None if fully evolved). **Mike Trout**
120/149 (80%)
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the Output example (evolved suffix tag):
player name for tiers 1-4. T0 cards have no badge. **Mike Trout** Base Chrome [T1]
120/149 (80%)
Output example: Output example (fully evolved):
**[BC] Mike Trout** (Base Chrome) **Barry Bonds** Superfractor [T4]
[========--] 120/149 (PA+TB×2) T1 T2 `MAX`
""" """
player_name = card_state.get("player_name", "Unknown") 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) current_tier = card_state.get("current_tier", 0)
formula_value = card_state.get("current_value", 0) formula_value = int(card_state.get("current_value", 0))
next_threshold = card_state.get("next_threshold") next_threshold = int(card_state.get("next_threshold") or 0) or None
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}") if current_tier == 0:
formula_label = FORMULA_LABELS.get(card_type, card_type) first_line = f"**{player_name}**"
else:
badge = TIER_BADGES.get(current_tier, "") tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
display_name = f"{badge} {player_name}" if badge else player_name symbol = TIER_SYMBOLS.get(current_tier, "")
first_line = f"**{player_name}** — {tier_label} [{symbol}]"
if current_tier >= 4 or next_threshold is None: if current_tier >= 4 or next_threshold is None:
bar = "[==========]" second_line = f"{_FULL_BAR} `MAX`"
detail = "FULLY EVOLVED ★"
else: else:
bar = render_progress_bar(formula_value, next_threshold) bar = render_progress_bar(formula_value, next_threshold)
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}" pct = _pct_label(formula_value, next_threshold)
second_line = f"{bar} {formula_value}/{next_threshold} ({pct})"
first_line = f"**{display_name}** ({tier_label})"
second_line = f"{bar} {detail}"
return f"{first_line}\n{second_line}" 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: def apply_close_filter(card_states: list) -> list:
""" """
Return only cards within 80% of their next tier threshold. Return only cards within 80% of their next tier threshold.
@ -107,11 +182,11 @@ def apply_close_filter(card_states: list) -> list:
result = [] result = []
for state in card_states: for state in card_states:
current_tier = state.get("current_tier", 0) current_tier = state.get("current_tier", 0)
formula_value = state.get("current_value", 0) formula_value = int(state.get("current_value", 0))
next_threshold = state.get("next_threshold") next_threshold = state.get("next_threshold")
if current_tier >= 4 or not next_threshold: if current_tier >= 4 or not next_threshold:
continue continue
if formula_value >= 0.8 * next_threshold: if formula_value >= 0.8 * int(next_threshold):
result.append(state) result.append(state)
return result return result
@ -128,6 +203,83 @@ def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
return items[start : start + page_size], total_pages 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): class Refractor(commands.Cog):
"""Refractor progress tracking slash commands.""" """Refractor progress tracking slash commands."""
@ -142,19 +294,34 @@ class Refractor(commands.Cog):
name="status", description="Show your team's refractor progress" name="status", description="Show your team's refractor progress"
) )
@app_commands.describe( @app_commands.describe(
card_type="Card type filter (batter, sp, rp)", card_type="Filter by card type",
season="Season number (default: current)", tier="Filter by current tier",
tier="Filter by current tier (0-4)", progress="Filter by advancement progress",
progress='Use "close" to show cards within 80% of their next tier',
page="Page number (default: 1, 10 cards per page)", 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( async def refractor_status(
self, self,
interaction: discord.Interaction, interaction: discord.Interaction,
card_type: Optional[str] = None, card_type: Optional[Choice[str]] = None,
season: Optional[int] = None, tier: Optional[Choice[str]] = None,
tier: Optional[int] = None, progress: Optional[Choice[str]] = None,
progress: Optional[str] = None,
page: int = 1, page: int = 1,
): ):
"""Show a paginated view of the invoking user's team refractor progress.""" """Show a paginated view of the invoking user's team refractor progress."""
@ -171,13 +338,13 @@ class Refractor(commands.Cog):
offset = (page - 1) * PAGE_SIZE offset = (page - 1) * PAGE_SIZE
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)] params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
if card_type: if card_type:
params.append(("card_type", card_type)) params.append(("card_type", card_type.value))
if season is not None:
params.append(("season", season))
if tier is not None: if tier is not None:
params.append(("tier", tier)) params.append(("tier", tier.value))
if progress: if progress:
params.append(("progress", 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) data = await db_get("refractor/cards", params=params)
if not data: if not data:
@ -203,10 +370,26 @@ class Refractor(commands.Cog):
total_count = ( total_count = (
data.get("count", len(items)) if isinstance(data, dict) else len(items) 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 not items:
if progress == "close": 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( await interaction.edit_original_response(
content="No cards are currently close to a tier advancement." content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
) )
else: else:
await interaction.edit_original_response( await interaction.edit_original_response(
@ -216,19 +399,24 @@ class Refractor(commands.Cog):
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages) page = min(page, total_pages)
page_items = items
lines = [format_refractor_entry(state) for state in page_items]
embed = discord.Embed( embed = build_status_embed(
title=f"{team['sname']} Refractor Status", team, items, page, total_pages, total_count, tier_filter=tier_filter
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) 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): async def setup(bot):

View File

@ -3,126 +3,148 @@ Discord Select UI components.
Contains all Select classes for various team, cardset, and pack selections. Contains all Select classes for various team, cardset, and pack selections.
""" """
import logging import logging
import discord import discord
from typing import Literal, Optional from typing import Literal, Optional
from helpers.constants import ALL_MLB_TEAMS, IMAGES, normalize_franchise from helpers.constants import ALL_MLB_TEAMS, IMAGES, normalize_franchise
logger = logging.getLogger('discord_app') logger = logging.getLogger("discord_app")
# Team name to ID mappings # Team name to ID mappings
AL_TEAM_IDS = { AL_TEAM_IDS = {
'Baltimore Orioles': 3, "Baltimore Orioles": 3,
'Boston Red Sox': 4, "Boston Red Sox": 4,
'Chicago White Sox': 6, "Chicago White Sox": 6,
'Cleveland Guardians': 8, "Cleveland Guardians": 8,
'Detroit Tigers': 10, "Detroit Tigers": 10,
'Houston Astros': 11, "Houston Astros": 11,
'Kansas City Royals': 12, "Kansas City Royals": 12,
'Los Angeles Angels': 13, "Los Angeles Angels": 13,
'Minnesota Twins': 17, "Minnesota Twins": 17,
'New York Yankees': 19, "New York Yankees": 19,
'Oakland Athletics': 20, "Oakland Athletics": 20,
'Athletics': 20, # Alias for post-Oakland move "Athletics": 20, # Alias for post-Oakland move
'Seattle Mariners': 24, "Seattle Mariners": 24,
'Tampa Bay Rays': 27, "Tampa Bay Rays": 27,
'Texas Rangers': 28, "Texas Rangers": 28,
'Toronto Blue Jays': 29 "Toronto Blue Jays": 29,
} }
NL_TEAM_IDS = { NL_TEAM_IDS = {
'Arizona Diamondbacks': 1, "Arizona Diamondbacks": 1,
'Atlanta Braves': 2, "Atlanta Braves": 2,
'Chicago Cubs': 5, "Chicago Cubs": 5,
'Cincinnati Reds': 7, "Cincinnati Reds": 7,
'Colorado Rockies': 9, "Colorado Rockies": 9,
'Los Angeles Dodgers': 14, "Los Angeles Dodgers": 14,
'Miami Marlins': 15, "Miami Marlins": 15,
'Milwaukee Brewers': 16, "Milwaukee Brewers": 16,
'New York Mets': 18, "New York Mets": 18,
'Philadelphia Phillies': 21, "Philadelphia Phillies": 21,
'Pittsburgh Pirates': 22, "Pittsburgh Pirates": 22,
'San Diego Padres': 23, "San Diego Padres": 23,
'San Francisco Giants': 25, "San Francisco Giants": 25,
'St Louis Cardinals': 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals' "St Louis Cardinals": 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals'
'Washington Nationals': 30 "Washington Nationals": 30,
} }
# Get AL teams from constants # Get AL teams from constants
AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS] AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS]
NL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in NL_TEAM_IDS or team == 'St Louis Cardinals'] NL_TEAMS = [
team
for team in ALL_MLB_TEAMS.keys()
if team in NL_TEAM_IDS or team == "St Louis Cardinals"
]
# Cardset mappings # Cardset mappings
CARDSET_LABELS_TO_IDS = { CARDSET_LABELS_TO_IDS = {
'2022 Season': 3, "2022 Season": 3,
'2022 Promos': 4, "2022 Promos": 4,
'2021 Season': 1, "2021 Season": 1,
'2019 Season': 5, "2019 Season": 5,
'2013 Season': 6, "2013 Season": 6,
'2012 Season': 7, "2012 Season": 7,
'Mario Super Sluggers': 8, "Mario Super Sluggers": 8,
'2023 Season': 9, "2023 Season": 9,
'2016 Season': 11, "2016 Season": 11,
'2008 Season': 12, "2008 Season": 12,
'2018 Season': 13, "2018 Season": 13,
'2024 Season': 17, "2024 Season": 17,
'2024 Promos': 18, "2024 Promos": 18,
'1998 Season': 20, "1998 Season": 20,
'2025 Season': 24, "2025 Season": 24,
'2005 Live': 27, "2005 Live": 27,
'Pokemon - Brilliant Stars': 23 "Pokemon - Brilliant Stars": 23,
} }
def _get_team_id(team_name: str, league: Literal['AL', 'NL']) -> int: def _get_team_id(team_name: str, league: Literal["AL", "NL"]) -> int:
"""Get team ID from team name and league.""" """Get team ID from team name and league."""
if league == 'AL': if league == "AL":
return AL_TEAM_IDS.get(team_name) return AL_TEAM_IDS.get(team_name)
else: else:
# Handle the St. Louis Cardinals special case # Handle the St. Louis Cardinals special case
if team_name == 'St. Louis Cardinals': if team_name == "St. Louis Cardinals":
return NL_TEAM_IDS.get('St Louis Cardinals') return NL_TEAM_IDS.get("St Louis Cardinals")
return NL_TEAM_IDS.get(team_name) return NL_TEAM_IDS.get(team_name)
class SelectChoicePackTeam(discord.ui.Select): class SelectChoicePackTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL'], team, cardset_id: Optional[int] = None): def __init__(
self, which: Literal["AL", "NL"], team, cardset_id: Optional[int] = None
):
self.which = which self.which = which
self.owner_team = team self.owner_team = team
self.cardset_id = cardset_id self.cardset_id = cardset_id
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label="St. Louis Cardinals"
super().__init__(placeholder=f'Select an {which} team', options=options) if team == "St Louis Cardinals"
else team
)
for team in NL_TEAMS
]
super().__init__(placeholder=f"Select an {which} team", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get, db_patch from api_calls import db_get, db_patch
from helpers import open_choice_pack from helpers import open_choice_pack
team_id = _get_team_id(self.values[0], self.which) team_id = _get_team_id(self.values[0], self.which)
if team_id is None: if team_id is None:
raise ValueError(f'Unknown team: {self.values[0]}') raise ValueError(f"Unknown team: {self.values[0]}")
await interaction.response.edit_message(content=f'You selected the **{self.values[0]}**', view=None) await interaction.response.edit_message(
content=f"You selected the **{self.values[0]}**", view=None
)
# Get the selected packs # Get the selected packs
params = [ params = [
('pack_type_id', 8), ('team_id', self.owner_team['id']), ('opened', False), ('limit', 1), ("pack_type_id", 8),
('exact_match', True) ("team_id", self.owner_team["id"]),
("opened", False),
("limit", 1),
("exact_match", True),
] ]
if self.cardset_id is not None: if self.cardset_id is not None:
params.append(('pack_cardset_id', self.cardset_id)) params.append(("pack_cardset_id", self.cardset_id))
p_query = await db_get('packs', params=params) p_query = await db_get("packs", params=params)
if p_query['count'] == 0: if p_query["count"] == 0:
logger.error(f'open-packs - no packs found with params: {params}') logger.error(f"open-packs - no packs found with params: {params}")
raise ValueError(f'Unable to open packs') raise ValueError("Unable to open packs")
this_pack = await db_patch('packs', object_id=p_query['packs'][0]['id'], params=[('pack_team_id', team_id)]) this_pack = await db_patch(
"packs",
object_id=p_query["packs"][0]["id"],
params=[("pack_team_id", team_id)],
)
await open_choice_pack(this_pack, self.owner_team, interaction, self.cardset_id) await open_choice_pack(this_pack, self.owner_team, interaction, self.cardset_id)
@ -130,104 +152,124 @@ class SelectChoicePackTeam(discord.ui.Select):
class SelectOpenPack(discord.ui.Select): class SelectOpenPack(discord.ui.Select):
def __init__(self, options: list, team: dict): def __init__(self, options: list, team: dict):
self.owner_team = team self.owner_team = team
super().__init__(placeholder='Select a Pack Type', options=options) super().__init__(placeholder="Select a Pack Type", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get from api_calls import db_get
from helpers import open_st_pr_packs, open_choice_pack from helpers import open_st_pr_packs, open_choice_pack
logger.info(f'SelectPackChoice - selection: {self.values[0]}') logger.info(f"SelectPackChoice - selection: {self.values[0]}")
pack_vals = self.values[0].split('-') pack_vals = self.values[0].split("-")
logger.info(f'pack_vals: {pack_vals}') logger.info(f"pack_vals: {pack_vals}")
# Get the selected packs # Get the selected packs
params = [('team_id', self.owner_team['id']), ('opened', False), ('limit', 5), ('exact_match', True)] params = [
("team_id", self.owner_team["id"]),
("opened", False),
("limit", 5),
("exact_match", True),
]
open_type = 'standard' open_type = "standard"
if 'Standard' in pack_vals: if "Standard" in pack_vals:
open_type = 'standard' open_type = "standard"
params.append(('pack_type_id', 1)) params.append(("pack_type_id", 1))
elif 'Premium' in pack_vals: elif "Premium" in pack_vals:
open_type = 'standard' open_type = "standard"
params.append(('pack_type_id', 3)) params.append(("pack_type_id", 3))
elif 'Daily' in pack_vals: elif "Daily" in pack_vals:
params.append(('pack_type_id', 4)) params.append(("pack_type_id", 4))
elif 'Promo Choice' in pack_vals: elif "Promo Choice" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 9)) params.append(("pack_type_id", 9))
elif 'MVP' in pack_vals: elif "MVP" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 5)) params.append(("pack_type_id", 5))
elif 'All Star' in pack_vals: elif "All Star" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 6)) params.append(("pack_type_id", 6))
elif 'Mario' in pack_vals: elif "Mario" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 7)) params.append(("pack_type_id", 7))
elif 'Team Choice' in pack_vals: elif "Team Choice" in pack_vals:
open_type = 'choice' open_type = "choice"
params.append(('pack_type_id', 8)) params.append(("pack_type_id", 8))
else: else:
raise KeyError(f'Cannot identify pack details: {pack_vals}') logger.error(
f"Unrecognized pack type in selector: {self.values[0]} (split: {pack_vals})"
)
await interaction.response.edit_message(view=None)
await interaction.followup.send(
content="This pack type cannot be opened manually. Please contact Cal.",
ephemeral=True,
)
return
# If team isn't already set on team choice pack, make team pack selection now # If team isn't already set on team choice pack, make team pack selection now
await interaction.response.edit_message(view=None) await interaction.response.edit_message(view=None)
cardset_id = None cardset_id = None
# Handle Team Choice packs with no team/cardset assigned # Handle Team Choice packs with no team/cardset assigned
if 'Team Choice' in pack_vals and 'Team' not in pack_vals and 'Cardset' not in pack_vals: if (
"Team Choice" in pack_vals
and "Team" not in pack_vals
and "Cardset" not in pack_vals
):
await interaction.followup.send( await interaction.followup.send(
content='This Team Choice pack needs to be assigned a team and cardset. ' content="This Team Choice pack needs to be assigned a team and cardset. "
'Please contact an admin to configure this pack.', "Please contact Cal to configure this pack.",
ephemeral=True ephemeral=True,
) )
return return
elif 'Team Choice' in pack_vals and 'Cardset' in pack_vals: elif "Team Choice" in pack_vals and "Cardset" in pack_vals:
# cardset_id = pack_vals[2] # cardset_id = pack_vals[2]
cardset_index = pack_vals.index('Cardset') cardset_index = pack_vals.index("Cardset")
cardset_id = pack_vals[cardset_index + 1] cardset_id = pack_vals[cardset_index + 1]
params.append(('pack_cardset_id', cardset_id)) params.append(("pack_cardset_id", cardset_id))
if 'Team' not in pack_vals: if "Team" not in pack_vals:
view = SelectView( view = SelectView(
[SelectChoicePackTeam('AL', self.owner_team, cardset_id), [
SelectChoicePackTeam('NL', self.owner_team, cardset_id)], SelectChoicePackTeam("AL", self.owner_team, cardset_id),
timeout=30 SelectChoicePackTeam("NL", self.owner_team, cardset_id),
],
timeout=30,
) )
await interaction.followup.send( await interaction.followup.send(
content='Please select a team for your Team Choice pack:', content="Please select a team for your Team Choice pack:", view=view
view=view
) )
return return
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
else:
if 'Team' in pack_vals:
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
if 'Cardset' in pack_vals:
cardset_id = pack_vals[pack_vals.index('Cardset') + 1]
params.append(('pack_cardset_id', cardset_id))
p_query = await db_get('packs', params=params) params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
if p_query['count'] == 0: else:
logger.error(f'open-packs - no packs found with params: {params}') if "Team" in pack_vals:
params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
if "Cardset" in pack_vals:
cardset_id = pack_vals[pack_vals.index("Cardset") + 1]
params.append(("pack_cardset_id", cardset_id))
p_query = await db_get("packs", params=params)
if p_query["count"] == 0:
logger.error(f"open-packs - no packs found with params: {params}")
await interaction.followup.send( await interaction.followup.send(
content='Unable to find the selected pack. Please contact an admin.', content="Unable to find the selected pack. Please contact Cal.",
ephemeral=True ephemeral=True,
) )
return return
# Open the packs # Open the packs
try: try:
if open_type == 'standard': if open_type == "standard":
await open_st_pr_packs(p_query['packs'], self.owner_team, interaction) await open_st_pr_packs(p_query["packs"], self.owner_team, interaction)
elif open_type == 'choice': elif open_type == "choice":
await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id) await open_choice_pack(
p_query["packs"][0], self.owner_team, interaction, cardset_id
)
except Exception as e: except Exception as e:
logger.error(f'Failed to open pack: {e}') logger.error(f"Failed to open pack: {e}")
await interaction.followup.send( await interaction.followup.send(
content=f'Failed to open pack. Please contact an admin. Error: {str(e)}', content=f"Failed to open pack. Please contact Cal. Error: {str(e)}",
ephemeral=True ephemeral=True,
) )
return return
@ -235,275 +277,317 @@ class SelectOpenPack(discord.ui.Select):
class SelectPaperdexCardset(discord.ui.Select): class SelectPaperdexCardset(discord.ui.Select):
def __init__(self): def __init__(self):
options = [ options = [
discord.SelectOption(label='2005 Live'), discord.SelectOption(label="2005 Live"),
discord.SelectOption(label='2025 Season'), discord.SelectOption(label="2025 Season"),
discord.SelectOption(label='1998 Season'), discord.SelectOption(label="1998 Season"),
discord.SelectOption(label='2024 Season'), discord.SelectOption(label="2024 Season"),
discord.SelectOption(label='2023 Season'), discord.SelectOption(label="2023 Season"),
discord.SelectOption(label='2022 Season'), discord.SelectOption(label="2022 Season"),
discord.SelectOption(label='2022 Promos'), discord.SelectOption(label="2022 Promos"),
discord.SelectOption(label='2021 Season'), discord.SelectOption(label="2021 Season"),
discord.SelectOption(label='2019 Season'), discord.SelectOption(label="2019 Season"),
discord.SelectOption(label='2018 Season'), discord.SelectOption(label="2018 Season"),
discord.SelectOption(label='2016 Season'), discord.SelectOption(label="2016 Season"),
discord.SelectOption(label='2013 Season'), discord.SelectOption(label="2013 Season"),
discord.SelectOption(label='2012 Season'), discord.SelectOption(label="2012 Season"),
discord.SelectOption(label='2008 Season'), discord.SelectOption(label="2008 Season"),
discord.SelectOption(label='Mario Super Sluggers') discord.SelectOption(label="Mario Super Sluggers"),
] ]
super().__init__(placeholder='Select a Cardset', options=options) super().__init__(placeholder="Select a Cardset", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get from api_calls import db_get
from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination
logger.info(f'SelectPaperdexCardset - selection: {self.values[0]}') logger.info(f"SelectPaperdexCardset - selection: {self.values[0]}")
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0]) cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
if cardset_id is None: if cardset_id is None:
raise ValueError(f'Unknown cardset: {self.values[0]}') raise ValueError(f"Unknown cardset: {self.values[0]}")
c_query = await db_get('cardsets', object_id=cardset_id, none_okay=False) c_query = await db_get("cardsets", object_id=cardset_id, none_okay=False)
await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None) await interaction.response.edit_message(
content="Okay, sifting through your cards...", view=None
)
cardset_embeds = await paperdex_cardset_embed( cardset_embeds = await paperdex_cardset_embed(
team=await get_team_by_owner(interaction.user.id), team=await get_team_by_owner(interaction.user.id), this_cardset=c_query
this_cardset=c_query
) )
await embed_pagination(cardset_embeds, interaction.channel, interaction.user) await embed_pagination(cardset_embeds, interaction.channel, interaction.user)
class SelectPaperdexTeam(discord.ui.Select): class SelectPaperdexTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL']): def __init__(self, which: Literal["AL", "NL"]):
self.which = which self.which = which
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label="St. Louis Cardinals"
super().__init__(placeholder=f'Select an {which} team', options=options) if team == "St Louis Cardinals"
else team
)
for team in NL_TEAMS
]
super().__init__(placeholder=f"Select an {which} team", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_get from api_calls import db_get
from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination
team_id = _get_team_id(self.values[0], self.which) team_id = _get_team_id(self.values[0], self.which)
if team_id is None: if team_id is None:
raise ValueError(f'Unknown team: {self.values[0]}') raise ValueError(f"Unknown team: {self.values[0]}")
t_query = await db_get('teams', object_id=team_id, none_okay=False) t_query = await db_get("teams", object_id=team_id, none_okay=False)
await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None) await interaction.response.edit_message(
content="Okay, sifting through your cards...", view=None
)
team_embeds = await paperdex_team_embed(team=await get_team_by_owner(interaction.user.id), mlb_team=t_query) team_embeds = await paperdex_team_embed(
team=await get_team_by_owner(interaction.user.id), mlb_team=t_query
)
await embed_pagination(team_embeds, interaction.channel, interaction.user) await embed_pagination(team_embeds, interaction.channel, interaction.user)
class SelectBuyPacksCardset(discord.ui.Select): class SelectBuyPacksCardset(discord.ui.Select):
def __init__(self, team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, cost: int): def __init__(
self,
team: dict,
quantity: int,
pack_type_id: int,
pack_embed: discord.Embed,
cost: int,
):
options = [ options = [
discord.SelectOption(label='2005 Live'), discord.SelectOption(label="2005 Live"),
discord.SelectOption(label='2025 Season'), discord.SelectOption(label="2025 Season"),
discord.SelectOption(label='1998 Season'), discord.SelectOption(label="1998 Season"),
discord.SelectOption(label='Pokemon - Brilliant Stars'), discord.SelectOption(label="Pokemon - Brilliant Stars"),
discord.SelectOption(label='2024 Season'), discord.SelectOption(label="2024 Season"),
discord.SelectOption(label='2023 Season'), discord.SelectOption(label="2023 Season"),
discord.SelectOption(label='2022 Season'), discord.SelectOption(label="2022 Season"),
discord.SelectOption(label='2021 Season'), discord.SelectOption(label="2021 Season"),
discord.SelectOption(label='2019 Season'), discord.SelectOption(label="2019 Season"),
discord.SelectOption(label='2018 Season'), discord.SelectOption(label="2018 Season"),
discord.SelectOption(label='2016 Season'), discord.SelectOption(label="2016 Season"),
discord.SelectOption(label='2013 Season'), discord.SelectOption(label="2013 Season"),
discord.SelectOption(label='2012 Season'), discord.SelectOption(label="2012 Season"),
discord.SelectOption(label='2008 Season') discord.SelectOption(label="2008 Season"),
] ]
self.team = team self.team = team
self.quantity = quantity self.quantity = quantity
self.pack_type_id = pack_type_id self.pack_type_id = pack_type_id
self.pack_embed = pack_embed self.pack_embed = pack_embed
self.cost = cost self.cost = cost
super().__init__(placeholder='Select a Cardset', options=options) super().__init__(placeholder="Select a Cardset", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_post from api_calls import db_post
from discord_ui.confirmations import Confirm from discord_ui.confirmations import Confirm
logger.info(f'SelectBuyPacksCardset - selection: {self.values[0]}') logger.info(f"SelectBuyPacksCardset - selection: {self.values[0]}")
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0]) cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
if cardset_id is None: if cardset_id is None:
raise ValueError(f'Unknown cardset: {self.values[0]}') raise ValueError(f"Unknown cardset: {self.values[0]}")
if self.values[0] == 'Pokemon - Brilliant Stars':
self.pack_embed.set_image(url=IMAGES['pack-pkmnbs'])
self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}' if self.values[0] == "Pokemon - Brilliant Stars":
self.pack_embed.set_image(url=IMAGES["pack-pkmnbs"])
self.pack_embed.description = (
f"{self.pack_embed.description} - {self.values[0]}"
)
view = Confirm(responders=[interaction.user], timeout=30) view = Confirm(responders=[interaction.user], timeout=30)
await interaction.response.edit_message( await interaction.response.edit_message(
content=None, content=None, embed=self.pack_embed, view=None
embed=self.pack_embed,
view=None
) )
question = await interaction.channel.send( question = await interaction.channel.send(
content=f'Your Wallet: {self.team["wallet"]}\n' content=f"Your Wallet: {self.team['wallet']}\n"
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}\n' f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}\n"
f'After Purchase: {self.team["wallet"] - self.cost}\n\n' f"After Purchase: {self.team['wallet'] - self.cost}\n\n"
f'Would you like to make this purchase?', f"Would you like to make this purchase?",
view=view view=view,
) )
await view.wait() await view.wait()
if not view.value: if not view.value:
await question.edit( await question.edit(content="Saving that money. Smart.", view=None)
content='Saving that money. Smart.',
view=None
)
return return
p_model = { p_model = {
'team_id': self.team['id'], "team_id": self.team["id"],
'pack_type_id': self.pack_type_id, "pack_type_id": self.pack_type_id,
'pack_cardset_id': cardset_id "pack_cardset_id": cardset_id,
} }
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]}) await db_post(
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}') "packs", payload={"packs": [p_model for x in range(self.quantity)]}
)
await db_post(f"teams/{self.team['id']}/money/-{self.cost}")
await question.edit( await question.edit(
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`",
view=None view=None,
) )
class SelectBuyPacksTeam(discord.ui.Select): class SelectBuyPacksTeam(discord.ui.Select):
def __init__( def __init__(
self, which: Literal['AL', 'NL'], team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, self,
cost: int): which: Literal["AL", "NL"],
team: dict,
quantity: int,
pack_type_id: int,
pack_embed: discord.Embed,
cost: int,
):
self.which = which self.which = which
self.team = team self.team = team
self.quantity = quantity self.quantity = quantity
self.pack_type_id = pack_type_id self.pack_type_id = pack_type_id
self.pack_embed = pack_embed self.pack_embed = pack_embed
self.cost = cost self.cost = cost
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label="St. Louis Cardinals"
super().__init__(placeholder=f'Select an {which} team', options=options) if team == "St Louis Cardinals"
else team
)
for team in NL_TEAMS
]
super().__init__(placeholder=f"Select an {which} team", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_post from api_calls import db_post
from discord_ui.confirmations import Confirm from discord_ui.confirmations import Confirm
team_id = _get_team_id(self.values[0], self.which) team_id = _get_team_id(self.values[0], self.which)
if team_id is None: if team_id is None:
raise ValueError(f'Unknown team: {self.values[0]}') raise ValueError(f"Unknown team: {self.values[0]}")
self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}' self.pack_embed.description = (
f"{self.pack_embed.description} - {self.values[0]}"
)
view = Confirm(responders=[interaction.user], timeout=30) view = Confirm(responders=[interaction.user], timeout=30)
await interaction.response.edit_message( await interaction.response.edit_message(
content=None, content=None, embed=self.pack_embed, view=None
embed=self.pack_embed,
view=None
) )
question = await interaction.channel.send( question = await interaction.channel.send(
content=f'Your Wallet: {self.team["wallet"]}\n' content=f"Your Wallet: {self.team['wallet']}\n"
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}\n' f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}\n"
f'After Purchase: {self.team["wallet"] - self.cost}\n\n' f"After Purchase: {self.team['wallet'] - self.cost}\n\n"
f'Would you like to make this purchase?', f"Would you like to make this purchase?",
view=view view=view,
) )
await view.wait() await view.wait()
if not view.value: if not view.value:
await question.edit( await question.edit(content="Saving that money. Smart.", view=None)
content='Saving that money. Smart.',
view=None
)
return return
p_model = { p_model = {
'team_id': self.team['id'], "team_id": self.team["id"],
'pack_type_id': self.pack_type_id, "pack_type_id": self.pack_type_id,
'pack_team_id': team_id "pack_team_id": team_id,
} }
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]}) await db_post(
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}') "packs", payload={"packs": [p_model for x in range(self.quantity)]}
)
await db_post(f"teams/{self.team['id']}/money/-{self.cost}")
await question.edit( await question.edit(
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`', content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`",
view=None view=None,
) )
class SelectUpdatePlayerTeam(discord.ui.Select): class SelectUpdatePlayerTeam(discord.ui.Select):
def __init__(self, which: Literal['AL', 'NL'], player: dict, reporting_team: dict, bot): def __init__(
self, which: Literal["AL", "NL"], player: dict, reporting_team: dict, bot
):
self.bot = bot self.bot = bot
self.which = which self.which = which
self.player = player self.player = player
self.reporting_team = reporting_team self.reporting_team = reporting_team
if which == 'AL': if which == "AL":
options = [discord.SelectOption(label=team) for team in AL_TEAMS] options = [discord.SelectOption(label=team) for team in AL_TEAMS]
else: else:
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team) options = [
for team in NL_TEAMS] discord.SelectOption(
label="St. Louis Cardinals"
super().__init__(placeholder=f'Select an {which} team', options=options) if team == "St Louis Cardinals"
else team
)
for team in NL_TEAMS
]
super().__init__(placeholder=f"Select an {which} team", options=options)
async def callback(self, interaction: discord.Interaction): async def callback(self, interaction: discord.Interaction):
# Import here to avoid circular imports # Import here to avoid circular imports
from api_calls import db_patch, db_post from api_calls import db_patch, db_post
from discord_ui.confirmations import Confirm from discord_ui.confirmations import Confirm
from helpers import player_desc, send_to_channel from helpers import player_desc, send_to_channel
# Check if already assigned - compare against both normalized franchise and full mlbclub # Check if already assigned - compare against both normalized franchise and full mlbclub
normalized_selection = normalize_franchise(self.values[0]) normalized_selection = normalize_franchise(self.values[0])
if normalized_selection == self.player['franchise'] or self.values[0] == self.player['mlbclub']: if (
normalized_selection == self.player["franchise"]
or self.values[0] == self.player["mlbclub"]
):
await interaction.response.send_message( await interaction.response.send_message(
content=f'Thank you for the help, but it looks like somebody beat you to it! ' content=f"Thank you for the help, but it looks like somebody beat you to it! "
f'**{player_desc(self.player)}** is already assigned to the **{self.player["mlbclub"]}**.' f"**{player_desc(self.player)}** is already assigned to the **{self.player['mlbclub']}**."
) )
return return
view = Confirm(responders=[interaction.user], timeout=15) view = Confirm(responders=[interaction.user], timeout=15)
await interaction.response.edit_message( await interaction.response.edit_message(
content=f'Should I update **{player_desc(self.player)}**\'s team to the **{self.values[0]}**?', content=f"Should I update **{player_desc(self.player)}**'s team to the **{self.values[0]}**?",
view=None view=None,
)
question = await interaction.channel.send(
content=None,
view=view
) )
question = await interaction.channel.send(content=None, view=view)
await view.wait() await view.wait()
if not view.value: if not view.value:
await question.edit( await question.edit(
content='That didnt\'t sound right to me, either. Let\'s not touch that.', content="That didnt't sound right to me, either. Let's not touch that.",
view=None view=None,
) )
return return
else: else:
await question.delete() await question.delete()
await db_patch('players', object_id=self.player['player_id'], params=[ await db_patch(
('mlbclub', self.values[0]), ('franchise', normalize_franchise(self.values[0])) "players",
]) object_id=self.player["player_id"],
await db_post(f'teams/{self.reporting_team["id"]}/money/25') params=[
await send_to_channel( ("mlbclub", self.values[0]),
self.bot, 'pd-news-ticker', ("franchise", normalize_franchise(self.values[0])),
content=f'{interaction.user.name} just updated **{player_desc(self.player)}**\'s team to the ' ],
f'**{self.values[0]}**'
) )
await interaction.channel.send(f'All done!') await db_post(f"teams/{self.reporting_team['id']}/money/25")
await send_to_channel(
self.bot,
"pd-news-ticker",
content=f"{interaction.user.name} just updated **{player_desc(self.player)}**'s team to the "
f"**{self.values[0]}**",
)
await interaction.channel.send("All done!")
class SelectView(discord.ui.View): class SelectView(discord.ui.View):
@ -511,4 +595,4 @@ class SelectView(discord.ui.View):
super().__init__(timeout=timeout) super().__init__(timeout=timeout)
for x in select_objects: for x in select_objects:
self.add_item(x) self.add_item(x)

View File

@ -0,0 +1,67 @@
services:
discord-app:
image: manticorum67/paper-dynasty-discordapp:dev
restart: unless-stopped
volumes:
- /path/to/dev-storage:/usr/src/app/storage
- /path/to/dev-logs:/usr/src/app/logs
environment:
- PYTHONBUFFERED=0
- GUILD_ID=your-guild-id-here
- BOT_TOKEN=your-bot-token-here
# - API_TOKEN=your-old-api-token-here
- LOG_LEVEL=INFO
- API_TOKEN=your-api-token-here
- SCOREBOARD_CHANNEL=your-scoreboard-channel-id-here
- TZ=America/Chicago
- PYTHONHASHSEED=1749583062
- DATABASE=Dev
- DB_USERNAME=postgres
- DB_PASSWORD=your-db-password-here
- DB_URL=db
- DB_NAME=postgres
- RESTART_WEBHOOK_URL=your-discord-webhook-url-here
networks:
- backend
depends_on:
db:
condition: service_healthy
ports:
- 8081:8081
healthcheck:
test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
db:
image: postgres
restart: unless-stopped
environment:
POSTGRES_PASSWORD: your-db-password-here
volumes:
- pd_postgres:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 5s
retries: 5
networks:
- backend
adminer:
image: adminer
restart: always
ports:
- 8080:8080
networks:
- backend
networks:
backend:
driver: bridge
volumes:
pd_postgres:

View File

@ -665,16 +665,28 @@ design but means tier-up notifications are best-effort.
Run order for Playwright automation: Run order for Playwright automation:
1. [ ] Execute REF-API-01 through REF-API-10 (API health + list endpoint) 1. [~] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
2. [ ] Execute REF-01 through REF-06 (basic /refractor status) - Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
3. [ ] Execute REF-10 through REF-19 (filters) - Not yet tested: REF-API-01, REF-API-02, REF-API-04, REF-API-05, REF-API-08, REF-API-09
4. [ ] Execute REF-20 through REF-23 (pagination) 2. [~] Execute REF-01 through REF-06 (basic /refractor status)
- Tested 2026-03-25: REF-01 (embed appears ✓), REF-02 (batter entry format ✓), REF-05 (tier badges [BC] ✓)
- Bugs found and fixed: wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
- Not yet tested: REF-03 (SP format), REF-04 (RP format), REF-06 (fully evolved)
3. [~] Execute REF-10 through REF-19 (filters)
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
- Choice dropdown menus added for all filter params (PR #126)
- Not yet tested: REF-11 through REF-19
4. [~] Execute REF-20 through REF-23 (pagination)
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
- Not yet tested: REF-21 (page 2), REF-22 (beyond total), REF-23 (page 0)
5. [ ] Execute REF-30 through REF-34 (edge cases) 5. [ ] Execute REF-30 through REF-34 (edge cases)
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds) 6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds)
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game) 7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing) 8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation) 9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation)
10. [ ] Execute REF-80 through REF-82 (force-evaluate API) 10. [~] Execute REF-80 through REF-82 (force-evaluate API)
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
- Not yet tested: REF-81, REF-82
### Approximate Time Estimates ### Approximate Time Estimates
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes - API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes

View File

@ -251,78 +251,36 @@ class TestEmbedColorUnchanged:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTierBadgesFormatConsistency: class TestTierSymbolsCompleteness:
""" """
T1-7: Assert that TIER_BADGES in cogs.refractor (format: "[BC]") and T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
helpers.main (format: "BC") are consistent wrapping the helpers.main and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
value in brackets must produce the cogs.refractor value.
Why: The two modules intentionally use different formats for different Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
rendering contexts: while card embed titles use helpers.main TIER_BADGES in bracket format.
- helpers.main uses bare strings ("BC") because get_card_embeds Both must cover the full tier range for their respective contexts.
wraps them in brackets when building the embed title.
- cogs.refractor uses bracket strings ("[BC]") because
format_refractor_entry inlines them directly into the display string.
If either definition is updated without updating the other, embed titles
and /refractor status output will display inconsistent badges. This test
acts as an explicit contract check so any future change to either dict
is immediately surfaced here.
""" """
def test_cogs_badge_equals_bracketed_helpers_badge_for_all_tiers(self): def test_tier_symbols_covers_all_tiers(self):
""" """TIER_SYMBOLS must have entries for T0 through T4."""
For every tier in cogs.refractor TIER_BADGES, wrapping the from cogs.refractor import TIER_SYMBOLS
helpers.main TIER_BADGES value in square brackets must produce
the cogs.refractor value.
i.e., f"[{helpers_badge}]" == cog_badge for all tiers. for tier in range(5):
""" assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert set(cog_badges.keys()) == set(helpers_badges.keys()), ( def test_tier_badges_covers_evolved_tiers(self):
"TIER_BADGES key sets differ between cogs.refractor and helpers.main. " """helpers.main TIER_BADGES must have entries for T1 through T4."""
f"cogs keys: {set(cog_badges.keys())}, helpers keys: {set(helpers_badges.keys())}" from helpers.main import TIER_BADGES
)
for tier, cog_badge in cog_badges.items(): for tier in range(1, 5):
helpers_badge = helpers_badges[tier] assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
expected = f"[{helpers_badge}]"
assert cog_badge == expected, (
f"Tier {tier} badge mismatch: "
f"cogs.refractor={cog_badge!r}, "
f"helpers.main={helpers_badge!r} "
f"(expected cog badge to equal '[{helpers_badge}]')"
)
def test_t1_badge_relationship(self): def test_tier_symbols_are_unique(self):
"""T1: helpers.main 'BC' wrapped in brackets equals cogs.refractor '[BC]'.""" """Each tier must have a distinct symbol."""
from cogs.refractor import TIER_BADGES as cog_badges from cogs.refractor import TIER_SYMBOLS
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[1]}]" == cog_badges[1] values = list(TIER_SYMBOLS.values())
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
def test_t2_badge_relationship(self):
"""T2: helpers.main 'R' wrapped in brackets equals cogs.refractor '[R]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[2]}]" == cog_badges[2]
def test_t3_badge_relationship(self):
"""T3: helpers.main 'GR' wrapped in brackets equals cogs.refractor '[GR]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[3]}]" == cog_badges[3]
def test_t4_badge_relationship(self):
"""T4: helpers.main 'SF' wrapped in brackets equals cogs.refractor '[SF]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[4]}]" == cog_badges[4]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -29,7 +29,7 @@ from cogs.refractor import (
apply_close_filter, apply_close_filter,
paginate, paginate,
TIER_NAMES, TIER_NAMES,
TIER_BADGES, TIER_SYMBOLS,
PAGE_SIZE, PAGE_SIZE,
) )
@ -40,12 +40,12 @@ from cogs.refractor import (
@pytest.fixture @pytest.fixture
def batter_state(): def batter_state():
"""A mid-progress batter card state.""" """A mid-progress batter card state (API response shape)."""
return { return {
"player_name": "Mike Trout", "player_name": "Mike Trout",
"card_type": "batter", "track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 1, "current_tier": 1,
"formula_value": 120, "current_value": 120,
"next_threshold": 149, "next_threshold": 149,
} }
@ -55,9 +55,9 @@ def evolved_state():
"""A fully evolved card state (T4).""" """A fully evolved card state (T4)."""
return { return {
"player_name": "Shohei Ohtani", "player_name": "Shohei Ohtani",
"card_type": "batter", "track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 4, "current_tier": 4,
"formula_value": 300, "current_value": 300,
"next_threshold": None, "next_threshold": None,
} }
@ -67,9 +67,9 @@ def sp_state():
"""A starting pitcher card state at T2.""" """A starting pitcher card state at T2."""
return { return {
"player_name": "Sandy Alcantara", "player_name": "Sandy Alcantara",
"card_type": "sp", "track": {"card_type": "sp", "formula": "ip + k"},
"current_tier": 2, "current_tier": 2,
"formula_value": 95, "current_value": 95,
"next_threshold": 120, "next_threshold": 120,
} }
@ -84,38 +84,44 @@ class TestRenderProgressBar:
Tests for render_progress_bar(). Tests for render_progress_bar().
Verifies width, fill character, empty character, boundary conditions, Verifies width, fill character, empty character, boundary conditions,
and clamping when current exceeds threshold. and clamping when current exceeds threshold. Default width is 12.
Uses Unicode block chars: (filled) and (empty).
""" """
def test_empty_bar(self): def test_empty_bar(self):
"""current=0 → all dashes.""" """current=0 → all empty blocks."""
assert render_progress_bar(0, 100) == "[----------]" assert render_progress_bar(0, 100) == "" * 12
def test_full_bar(self): def test_full_bar(self):
"""current == threshold → all equals.""" """current == threshold → all filled blocks."""
assert render_progress_bar(100, 100) == "[==========]" assert render_progress_bar(100, 100) == "" * 12
def test_partial_fill(self): def test_partial_fill(self):
"""120/149 ≈ 80.5%8 filled of 10.""" """120/149 ≈ 80.5%~10 filled of 12."""
bar = render_progress_bar(120, 149) bar = render_progress_bar(120, 149)
assert bar == "[========--]" filled = bar.count("")
empty = bar.count("")
assert filled + empty == 12
assert filled == 10 # round(0.805 * 12) = 10
def test_half_fill(self): def test_half_fill(self):
"""50/100 = 50% → 5 filled.""" """50/100 = 50% → 6 filled."""
assert render_progress_bar(50, 100) == "[=====-----]" bar = render_progress_bar(50, 100)
assert bar.count("") == 6
assert bar.count("") == 6
def test_over_threshold_clamps_to_full(self): def test_over_threshold_clamps_to_full(self):
"""current > threshold should not overflow the bar.""" """current > threshold should not overflow the bar."""
assert render_progress_bar(200, 100) == "[==========]" assert render_progress_bar(200, 100) == "" * 12
def test_zero_threshold_returns_full_bar(self): def test_zero_threshold_returns_full_bar(self):
"""threshold=0 avoids division by zero and returns full bar.""" """threshold=0 avoids division by zero and returns full bar."""
assert render_progress_bar(0, 0) == "[==========]" assert render_progress_bar(0, 0) == "" * 12
def test_custom_width(self): def test_custom_width(self):
"""Width parameter controls bar length.""" """Width parameter controls bar length."""
bar = render_progress_bar(5, 10, width=4) bar = render_progress_bar(5, 10, width=4)
assert bar == "[==--]" assert bar == "▰▰▱▱"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -140,44 +146,29 @@ class TestFormatRefractorEntry:
def test_tier_label_in_output(self, batter_state): def test_tier_label_in_output(self, batter_state):
"""Current tier name (Base Chrome for T1) appears in output.""" """Current tier name (Base Chrome for T1) appears in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "(Base Chrome)" in result assert "Base Chrome" in result
def test_progress_values_in_output(self, batter_state): def test_progress_values_in_output(self, batter_state):
"""current/threshold values appear in output.""" """current/threshold values appear in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "120/149" in result assert "120/149" in result
def test_formula_label_batter(self, batter_state): def test_percentage_in_output(self, batter_state):
"""Batter formula label PA+TB×2 appears in output.""" """Percentage appears in parentheses in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "PA+TB×2" in result assert "(80%)" in result or "(81%)" in result
def test_tier_progression_arrow(self, batter_state):
"""T1 → T2 arrow progression appears for non-evolved cards."""
result = format_refractor_entry(batter_state)
assert "T1 → T2" in result
def test_sp_formula_label(self, sp_state):
"""SP formula label IP+K appears for starting pitchers."""
result = format_refractor_entry(sp_state)
assert "IP+K" in result
def test_fully_evolved_no_threshold(self, evolved_state): def test_fully_evolved_no_threshold(self, evolved_state):
"""T4 card with next_threshold=None shows FULLY EVOLVED.""" """T4 card with next_threshold=None shows MAX."""
result = format_refractor_entry(evolved_state) result = format_refractor_entry(evolved_state)
assert "FULLY EVOLVED" in result assert "`MAX`" in result
def test_fully_evolved_by_tier(self, batter_state): def test_fully_evolved_by_tier(self, batter_state):
"""current_tier=4 triggers fully evolved display even with a threshold.""" """current_tier=4 triggers fully evolved display even with a threshold."""
batter_state["current_tier"] = 4 batter_state["current_tier"] = 4
batter_state["next_threshold"] = 200 batter_state["next_threshold"] = 200
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "FULLY EVOLVED" in result assert "`MAX`" in result
def test_fully_evolved_no_arrow(self, evolved_state):
"""Fully evolved cards don't show a tier arrow."""
result = format_refractor_entry(evolved_state)
assert "" not in result
def test_two_line_output(self, batter_state): def test_two_line_output(self, batter_state):
"""Output always has exactly two lines (name line + bar line).""" """Output always has exactly two lines (name line + bar line)."""
@ -191,69 +182,66 @@ class TestFormatRefractorEntry:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTierBadges: class TestTierSymbols:
""" """
Verify TIER_BADGES values and that format_refractor_entry prepends badges Verify TIER_SYMBOLS values and that format_refractor_entry prepends
correctly for T1-T4. T0 cards should have no badge prefix. the correct label for each tier. Labels use short readable text (T0-T4).
""" """
def test_t1_badge_value(self): def test_t0_symbol(self):
"""T1 badge is [BC] (Base Chrome).""" """T0 label is empty (base cards get no prefix)."""
assert TIER_BADGES[1] == "[BC]" assert TIER_SYMBOLS[0] == "Base"
def test_t2_badge_value(self): def test_t1_symbol(self):
"""T2 badge is [R] (Refractor).""" """T1 label is 'T1'."""
assert TIER_BADGES[2] == "[R]" assert TIER_SYMBOLS[1] == "T1"
def test_t3_badge_value(self): def test_t2_symbol(self):
"""T3 badge is [GR] (Gold Refractor).""" """T2 label is 'T2'."""
assert TIER_BADGES[3] == "[GR]" assert TIER_SYMBOLS[2] == "T2"
def test_t4_badge_value(self): def test_t3_symbol(self):
"""T4 badge is [SF] (Superfractor).""" """T3 label is 'T3'."""
assert TIER_BADGES[4] == "[SF]" assert TIER_SYMBOLS[3] == "T3"
def test_t0_no_badge(self): def test_t4_symbol(self):
"""T0 has no badge entry in TIER_BADGES.""" """T4 label is 'T4★'."""
assert 0 not in TIER_BADGES assert TIER_SYMBOLS[4] == "T4★"
def test_format_entry_t1_badge_present(self, batter_state): def test_format_entry_t1_suffix_tag(self, batter_state):
"""format_refractor_entry prepends [BC] badge for T1 cards.""" """T1 cards show [T1] suffix tag after the tier name."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "[BC]" in result assert "[T1]" in result
def test_format_entry_t2_badge_present(self, sp_state): def test_format_entry_t2_suffix_tag(self, sp_state):
"""format_refractor_entry prepends [R] badge for T2 cards.""" """T2 cards show [T2] suffix tag."""
result = format_refractor_entry(sp_state) result = format_refractor_entry(sp_state)
assert "[R]" in result assert "[T2]" in result
def test_format_entry_t4_badge_present(self, evolved_state): def test_format_entry_t4_suffix_tag(self, evolved_state):
"""format_refractor_entry prepends [SF] badge for T4 cards.""" """T4 cards show [T4★] suffix tag."""
result = format_refractor_entry(evolved_state) result = format_refractor_entry(evolved_state)
assert "[SF]" in result assert "[T4★]" in result
def test_format_entry_t0_no_badge(self): def test_format_entry_t0_name_only(self):
"""format_refractor_entry does not prepend any badge for T0 cards.""" """T0 cards show just the bold name, no tier suffix."""
state = { state = {
"player_name": "Rookie Player", "player_name": "Rookie Player",
"card_type": "batter",
"current_tier": 0, "current_tier": 0,
"formula_value": 10, "current_value": 10,
"next_threshold": 50, "next_threshold": 50,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
assert "[BC]" not in result first_line = result.split("\n")[0]
assert "[R]" not in result assert first_line == "**Rookie Player**"
assert "[GR]" not in result
assert "[SF]" not in result
def test_format_entry_badge_before_name(self, batter_state): def test_format_entry_tag_after_name(self, batter_state):
"""Badge appears before the player name in the bold section.""" """Tag appears after the player name in the first line."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
first_line = result.split("\n")[0] first_line = result.split("\n")[0]
badge_pos = first_line.find("[BC]")
name_pos = first_line.find("Mike Trout") name_pos = first_line.find("Mike Trout")
assert badge_pos < name_pos tag_pos = first_line.find("[T1]")
assert name_pos < tag_pos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -271,34 +259,34 @@ class TestApplyCloseFilter:
def test_close_card_included(self): def test_close_card_included(self):
"""Card at exactly 80% is included.""" """Card at exactly 80% is included."""
state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100} state = {"current_tier": 1, "current_value": 80, "next_threshold": 100}
assert apply_close_filter([state]) == [state] assert apply_close_filter([state]) == [state]
def test_above_80_percent_included(self): def test_above_80_percent_included(self):
"""Card above 80% is included.""" """Card above 80% is included."""
state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100} state = {"current_tier": 0, "current_value": 95, "next_threshold": 100}
assert apply_close_filter([state]) == [state] assert apply_close_filter([state]) == [state]
def test_below_80_percent_excluded(self): def test_below_80_percent_excluded(self):
"""Card below 80% threshold is excluded.""" """Card below 80% threshold is excluded."""
state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100} state = {"current_tier": 1, "current_value": 79, "next_threshold": 100}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_fully_evolved_excluded(self): def test_fully_evolved_excluded(self):
"""T4 cards are never returned by close filter.""" """T4 cards are never returned by close filter."""
state = {"current_tier": 4, "formula_value": 300, "next_threshold": None} state = {"current_tier": 4, "current_value": 300, "next_threshold": None}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_none_threshold_excluded(self): def test_none_threshold_excluded(self):
"""Cards with no next_threshold (regardless of tier) are excluded.""" """Cards with no next_threshold (regardless of tier) are excluded."""
state = {"current_tier": 3, "formula_value": 200, "next_threshold": None} state = {"current_tier": 3, "current_value": 200, "next_threshold": None}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_mixed_list(self): def test_mixed_list(self):
"""Only qualifying cards are returned from a mixed list.""" """Only qualifying cards are returned from a mixed list."""
close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100} close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100} not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None} evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
result = apply_close_filter([close, not_close, evolved]) result = apply_close_filter([close, not_close, evolved])
assert result == [close] assert result == [close]
@ -506,9 +494,9 @@ class TestApplyCloseFilterWithAllT4Cards:
the "no cards close to advancement" message rather than an empty embed. the "no cards close to advancement" message rather than an empty embed.
""" """
t4_cards = [ t4_cards = [
{"current_tier": 4, "formula_value": 300, "next_threshold": None}, {"current_tier": 4, "current_value": 300, "next_threshold": None},
{"current_tier": 4, "formula_value": 500, "next_threshold": None}, {"current_tier": 4, "current_value": 500, "next_threshold": None},
{"current_tier": 4, "formula_value": 275, "next_threshold": None}, {"current_tier": 4, "current_value": 275, "next_threshold": None},
] ]
result = apply_close_filter(t4_cards) result = apply_close_filter(t4_cards)
assert result == [], ( assert result == [], (
@ -523,7 +511,7 @@ class TestApplyCloseFilterWithAllT4Cards:
""" """
t4_high_value = { t4_high_value = {
"current_tier": 4, "current_tier": 4,
"formula_value": 9999, "current_value": 9999,
"next_threshold": None, "next_threshold": None,
} }
assert apply_close_filter([t4_high_value]) == [] assert apply_close_filter([t4_high_value]) == []
@ -552,9 +540,9 @@ class TestFormatRefractorEntryMalformedInput:
than crashing with a KeyError. than crashing with a KeyError.
""" """
state = { state = {
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 1, "current_tier": 1,
"formula_value": 100, "current_value": 100,
"next_threshold": 150, "next_threshold": 150,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
@ -562,12 +550,12 @@ class TestFormatRefractorEntryMalformedInput:
def test_missing_formula_value_uses_zero(self): def test_missing_formula_value_uses_zero(self):
""" """
When formula_value is absent, the progress calculation should use 0 When current_value is absent, the progress calculation should use 0
without raising a TypeError. without raising a TypeError.
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 1, "current_tier": 1,
"next_threshold": 150, "next_threshold": 150,
} }
@ -585,20 +573,19 @@ class TestFormatRefractorEntryMalformedInput:
lines = result.split("\n") lines = result.split("\n")
assert len(lines) == 2 assert len(lines) == 2
def test_missing_card_type_uses_raw_fallback(self): def test_missing_card_type_does_not_crash(self):
""" """
When card_type is absent, the code defaults to 'batter' internally When card_type is absent from the track, the code should still
(via .get("card_type", "batter")), so "PA+TB×2" should appear as the produce a valid two-line output without crashing.
formula label.
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"current_tier": 1, "current_tier": 1,
"formula_value": 50, "current_value": 50,
"next_threshold": 100, "next_threshold": 100,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
assert "PA+TB×2" in result assert "50/100" in result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -623,30 +610,27 @@ class TestRenderProgressBarBoundaryPrecision:
rest empty. The bar must not appear more than minimally filled. rest empty. The bar must not appear more than minimally filled.
""" """
bar = render_progress_bar(1, 100) bar = render_progress_bar(1, 100)
# Interior is 10 chars: count '=' vs '-' filled_count = bar.count("")
interior = bar[1:-1] # strip '[' and ']'
filled_count = interior.count("=")
assert filled_count <= 1, ( assert filled_count <= 1, (
f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}" f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}"
) )
def test_ninety_nine_of_hundred_is_nearly_full(self): def test_ninety_nine_of_hundred_is_nearly_full(self):
""" """
99/100 = 99% should produce a bar with 9 or 10 filled segments. 99/100 = 99% should produce a bar with 11 or 12 filled segments.
The bar must NOT be completely empty or show fewer than 9 filled. The bar must NOT be completely empty or show fewer than 11 filled.
""" """
bar = render_progress_bar(99, 100) bar = render_progress_bar(99, 100)
interior = bar[1:-1] filled_count = bar.count("")
filled_count = interior.count("=") assert filled_count >= 11, (
assert filled_count >= 9, ( f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
f"99/100 should show 9 or 10 filled segments, got {filled_count}: {bar!r}"
) )
# But it must not overflow the bar width # Bar width must be exactly 12
assert len(interior) == 10 assert len(bar) == 12
def test_zero_of_hundred_is_completely_empty(self): def test_zero_of_hundred_is_completely_empty(self):
"""0/100 = all dashes — re-verify the all-empty baseline.""" """0/100 = all empty blocks — re-verify the all-empty baseline."""
assert render_progress_bar(0, 100) == "[----------]" assert render_progress_bar(0, 100) == "" * 12
def test_negative_current_does_not_overflow_bar(self): def test_negative_current_does_not_overflow_bar(self):
""" """
@ -656,14 +640,12 @@ class TestRenderProgressBarBoundaryPrecision:
a future refactor removing the clamp. a future refactor removing the clamp.
""" """
bar = render_progress_bar(-5, 100) bar = render_progress_bar(-5, 100)
interior = bar[1:-1] filled_count = bar.count("")
# No filled segments should exist for a negative value
filled_count = interior.count("=")
assert filled_count == 0, ( assert filled_count == 0, (
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}" f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
) )
# Bar width must be exactly 10 # Bar width must be exactly 12
assert len(interior) == 10 assert len(bar) == 12
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -671,79 +653,32 @@ class TestRenderProgressBarBoundaryPrecision:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRPFormulaLabel: class TestCardTypeVariants:
""" """
T3-4: Verify that relief pitchers (card_type="rp") show the "IP+K" formula T3-4/T3-5: Verify that format_refractor_entry produces valid output for
label in format_refractor_entry output. all card types including unknown ones, without crashing.
Why: FORMULA_LABELS maps both "sp" and "rp" to "IP+K". The existing test
suite only verifies "sp" (via the sp_state fixture). Adding "rp" explicitly
prevents a future refactor from accidentally giving RPs a different label
or falling through to the raw card_type fallback.
""" """
def test_rp_formula_label_is_ip_plus_k(self): def test_rp_card_produces_valid_output(self):
""" """Relief pitcher card produces a valid two-line string."""
A card with card_type="rp" must show "IP+K" as the formula label
in its progress line.
"""
rp_state = { rp_state = {
"player_name": "Edwin Diaz", "player_name": "Edwin Diaz",
"card_type": "rp", "track": {"card_type": "rp"},
"current_tier": 1, "current_tier": 1,
"formula_value": 45, "current_value": 45,
"next_threshold": 60, "next_threshold": 60,
} }
result = format_refractor_entry(rp_state) result = format_refractor_entry(rp_state)
assert "IP+K" in result, ( assert "Edwin Diaz" in result
f"Relief pitcher card should show 'IP+K' formula label, got: {result!r}" assert "45/60" in result
)
# ---------------------------------------------------------------------------
# T3-5: Unknown card_type fallback
# ---------------------------------------------------------------------------
class TestUnknownCardTypeFallback:
"""
T3-5: format_refractor_entry should use the raw card_type string as the
formula label when the type is not in FORMULA_LABELS, rather than crashing.
Why: FORMULA_LABELS only covers "batter", "sp", and "rp". If the API
introduces a new card type (e.g. "util" for utility players) before the
bot is updated, FORMULA_LABELS.get(card_type, card_type) will fall back to
the raw string. This test ensures that fallback path produces readable
output rather than an error, and explicitly documents what to expect.
"""
def test_unknown_card_type_uses_raw_string_as_label(self):
"""
card_type="util" is not in FORMULA_LABELS. The output should include
"util" as the formula label (the raw fallback) and must not raise.
"""
util_state = {
"player_name": "Ben Zobrist",
"card_type": "util",
"current_tier": 2,
"formula_value": 80,
"next_threshold": 120,
}
result = format_refractor_entry(util_state)
assert "util" in result, (
f"Unknown card_type should appear verbatim as the formula label, got: {result!r}"
)
def test_unknown_card_type_does_not_crash(self): def test_unknown_card_type_does_not_crash(self):
""" """Unknown card_type produces a valid two-line string."""
Any unknown card_type must produce a valid two-line string without
raising an exception.
"""
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"card_type": "dh", "track": {"card_type": "dh"},
"current_tier": 1, "current_tier": 1,
"formula_value": 30, "current_value": 30,
"next_threshold": 50, "next_threshold": 50,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
@ -794,7 +729,10 @@ async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
team = {"id": 1, "sname": "Test"} team = {"id": 1, "sname": "Test"}
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)): with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
with patch("cogs.refractor.db_get", new=AsyncMock(return_value={"cards": []})): with patch(
"cogs.refractor.db_get",
new=AsyncMock(return_value={"items": [], "count": 0}),
):
await cog.refractor_status.callback(cog, mock_interaction) await cog.refractor_status.callback(cog, mock_interaction)
call_kwargs = mock_interaction.edit_original_response.call_args call_kwargs = mock_interaction.edit_original_response.call_args