Compare commits

..

16 Commits
dev ... main

Author SHA1 Message Date
cal
5cfddaa89a Merge pull request 'feat: refractor card art pipeline — render trigger + /player view' (#139) from feat/refractor-card-art-pipeline into main 2026-04-06 22:34:01 +00:00
Cal Corum
78f313663e fix: review feedback — variant 0 guard, remove dead team param
All checks were successful
Ruff Lint / lint (pull_request) Successful in 11s
- Use `variant is None` instead of `not variant` to avoid skipping
  variant 0 tier-ups (0 is falsy in Python)
- Remove unused `team` parameter from _build_refractor_response
- Update tests to match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:33:36 -05:00
Cal Corum
46744d139c feat: add /player refractor_tier parameter for viewing evolved cards
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
Adds optional refractor_tier parameter to the /player slash command.
When provided: looks up the user's team refractor data, shows the
evolved card image if available, triggers on-demand render if image
not yet generated, or shows top 5 refractor cards as fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:21:28 -05:00
Cal Corum
730d4b4f60 feat: trigger variant card renders after post-game tier-ups
After refractor tier-ups, the bot hits the card render URL for each
variant card to trigger Playwright render + S3 upload as a side
effect. Fire-and-forget — failures are logged but never raised.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:10:13 -05:00
cal
9ee4a76cd6 Merge pull request 'fix: remove stale Superfractor "Rating Boosts" teaser' (#137) from fix/remove-stale-superfractor-text into main 2026-04-06 20:13:36 +00:00
Cal Corum
80e99b075f chore: fix stale docstring referencing removed note field
All checks were successful
Ruff Lint / lint (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:09:21 -05:00
Cal Corum
ef270ec1ab fix: remove stale "Rating Boosts coming soon" from Superfractor notification
All checks were successful
Ruff Lint / lint (pull_request) Successful in 52s
Tier boosts shipped in Phase 2 — the teaser field in the T4 tier-up
embed was outdated. Remove it and update tests + test plan to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:59:56 -05:00
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
13 changed files with 1206 additions and 545 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

@ -28,7 +28,7 @@ import helpers
from in_game.gameplay_queries import get_team_or_none from in_game.gameplay_queries import get_team_or_none
from in_game.simulations import get_pos_embeds, get_result from in_game.simulations import get_pos_embeds, get_result
from in_game.gameplay_models import Lineup, Play, Session, engine from in_game.gameplay_models import Lineup, Play, Session, engine
from api_calls import db_get, db_post, db_patch, get_team_by_abbrev from api_calls import db_get, db_post, db_patch, get_team_by_abbrev, DB_URL
from helpers import ( from helpers import (
ACTIVE_EVENT_LITERAL, ACTIVE_EVENT_LITERAL,
PD_PLAYERS_ROLE_NAME, PD_PLAYERS_ROLE_NAME,
@ -293,29 +293,29 @@ def get_ai_records(short_games, long_games):
if line["away_team"]["is_ai"]: if line["away_team"]["is_ai"]:
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"w" "w"
] += (1 if home_win else 0) ] += 1 if home_win else 0
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"l" "l"
] += (1 if not home_win else 0) ] += 1 if not home_win else 0
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"points" "points"
] += (2 if home_win else 1) ] += 2 if home_win else 1
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"rd" "rd"
] += (line["home_score"] - line["away_score"]) ] += line["home_score"] - line["away_score"]
elif line["home_team"]["is_ai"]: elif line["home_team"]["is_ai"]:
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"w" "w"
] += (1 if not home_win else 0) ] += 1 if not home_win else 0
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"l" "l"
] += (1 if home_win else 0) ] += 1 if home_win else 0
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"points" "points"
] += (2 if not home_win else 1) ] += 2 if not home_win else 1
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"rd" "rd"
] += (line["away_score"] - line["home_score"]) ] += line["away_score"] - line["home_score"]
logger.debug(f"done league games") logger.debug(f"done league games")
return all_results return all_results
@ -367,51 +367,51 @@ def get_record_embed_legacy(embed: discord.Embed, results: dict, league: str):
embed.add_field( embed.add_field(
name=f"AL East ({ale_points} pts)", name=f"AL East ({ale_points} pts)",
value=f'BAL: {results["BAL"][league]["w"]} - {results["BAL"][league]["l"]} ({results["BAL"][league]["rd"]} RD)\n' value=f"BAL: {results['BAL'][league]['w']} - {results['BAL'][league]['l']} ({results['BAL'][league]['rd']} RD)\n"
f'BOS: {results["BOS"][league]["w"]} - {results["BOS"][league]["l"]} ({results["BOS"][league]["rd"]} RD)\n' f"BOS: {results['BOS'][league]['w']} - {results['BOS'][league]['l']} ({results['BOS'][league]['rd']} RD)\n"
f'NYY: {results["NYY"][league]["w"]} - {results["NYY"][league]["l"]} ({results["NYY"][league]["rd"]} RD)\n' f"NYY: {results['NYY'][league]['w']} - {results['NYY'][league]['l']} ({results['NYY'][league]['rd']} RD)\n"
f'TBR: {results["TBR"][league]["w"]} - {results["TBR"][league]["l"]} ({results["TBR"][league]["rd"]} RD)\n' f"TBR: {results['TBR'][league]['w']} - {results['TBR'][league]['l']} ({results['TBR'][league]['rd']} RD)\n"
f'TOR: {results["TOR"][league]["w"]} - {results["TOR"][league]["l"]} ({results["TOR"][league]["rd"]} RD)\n', f"TOR: {results['TOR'][league]['w']} - {results['TOR'][league]['l']} ({results['TOR'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL Central ({alc_points} pts)", name=f"AL Central ({alc_points} pts)",
value=f'CLE: {results["CLE"][league]["w"]} - {results["CLE"][league]["l"]} ({results["CLE"][league]["rd"]} RD)\n' value=f"CLE: {results['CLE'][league]['w']} - {results['CLE'][league]['l']} ({results['CLE'][league]['rd']} RD)\n"
f'CHW: {results["CHW"][league]["w"]} - {results["CHW"][league]["l"]} ({results["CHW"][league]["rd"]} RD)\n' f"CHW: {results['CHW'][league]['w']} - {results['CHW'][league]['l']} ({results['CHW'][league]['rd']} RD)\n"
f'DET: {results["DET"][league]["w"]} - {results["DET"][league]["l"]} ({results["DET"][league]["rd"]} RD)\n' f"DET: {results['DET'][league]['w']} - {results['DET'][league]['l']} ({results['DET'][league]['rd']} RD)\n"
f'KCR: {results["KCR"][league]["w"]} - {results["KCR"][league]["l"]} ({results["KCR"][league]["rd"]} RD)\n' f"KCR: {results['KCR'][league]['w']} - {results['KCR'][league]['l']} ({results['KCR'][league]['rd']} RD)\n"
f'MIN: {results["MIN"][league]["w"]} - {results["MIN"][league]["l"]} ({results["MIN"][league]["rd"]} RD)\n', f"MIN: {results['MIN'][league]['w']} - {results['MIN'][league]['l']} ({results['MIN'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL West ({alw_points} pts)", name=f"AL West ({alw_points} pts)",
value=f'HOU: {results["HOU"][league]["w"]} - {results["HOU"][league]["l"]} ({results["HOU"][league]["rd"]} RD)\n' value=f"HOU: {results['HOU'][league]['w']} - {results['HOU'][league]['l']} ({results['HOU'][league]['rd']} RD)\n"
f'LAA: {results["LAA"][league]["w"]} - {results["LAA"][league]["l"]} ({results["LAA"][league]["rd"]} RD)\n' f"LAA: {results['LAA'][league]['w']} - {results['LAA'][league]['l']} ({results['LAA'][league]['rd']} RD)\n"
f'OAK: {results["OAK"][league]["w"]} - {results["OAK"][league]["l"]} ({results["OAK"][league]["rd"]} RD)\n' f"OAK: {results['OAK'][league]['w']} - {results['OAK'][league]['l']} ({results['OAK'][league]['rd']} RD)\n"
f'SEA: {results["SEA"][league]["w"]} - {results["SEA"][league]["l"]} ({results["SEA"][league]["rd"]} RD)\n' f"SEA: {results['SEA'][league]['w']} - {results['SEA'][league]['l']} ({results['SEA'][league]['rd']} RD)\n"
f'TEX: {results["TEX"][league]["w"]} - {results["TEX"][league]["l"]} ({results["TEX"][league]["rd"]} RD)\n', f"TEX: {results['TEX'][league]['w']} - {results['TEX'][league]['l']} ({results['TEX'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL East ({nle_points} pts)", name=f"NL East ({nle_points} pts)",
value=f'ATL: {results["ATL"][league]["w"]} - {results["ATL"][league]["l"]} ({results["ATL"][league]["rd"]} RD)\n' value=f"ATL: {results['ATL'][league]['w']} - {results['ATL'][league]['l']} ({results['ATL'][league]['rd']} RD)\n"
f'MIA: {results["MIA"][league]["w"]} - {results["MIA"][league]["l"]} ({results["MIA"][league]["rd"]} RD)\n' f"MIA: {results['MIA'][league]['w']} - {results['MIA'][league]['l']} ({results['MIA'][league]['rd']} RD)\n"
f'NYM: {results["NYM"][league]["w"]} - {results["NYM"][league]["l"]} ({results["NYM"][league]["rd"]} RD)\n' f"NYM: {results['NYM'][league]['w']} - {results['NYM'][league]['l']} ({results['NYM'][league]['rd']} RD)\n"
f'PHI: {results["PHI"][league]["w"]} - {results["PHI"][league]["l"]} ({results["PHI"][league]["rd"]} RD)\n' f"PHI: {results['PHI'][league]['w']} - {results['PHI'][league]['l']} ({results['PHI'][league]['rd']} RD)\n"
f'WSN: {results["WSN"][league]["w"]} - {results["WSN"][league]["l"]} ({results["WSN"][league]["rd"]} RD)\n', f"WSN: {results['WSN'][league]['w']} - {results['WSN'][league]['l']} ({results['WSN'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL Central ({nlc_points} pts)", name=f"NL Central ({nlc_points} pts)",
value=f'CHC: {results["CHC"][league]["w"]} - {results["CHC"][league]["l"]} ({results["CHC"][league]["rd"]} RD)\n' value=f"CHC: {results['CHC'][league]['w']} - {results['CHC'][league]['l']} ({results['CHC'][league]['rd']} RD)\n"
f'CHW: {results["CIN"][league]["w"]} - {results["CIN"][league]["l"]} ({results["CIN"][league]["rd"]} RD)\n' f"CHW: {results['CIN'][league]['w']} - {results['CIN'][league]['l']} ({results['CIN'][league]['rd']} RD)\n"
f'MIL: {results["MIL"][league]["w"]} - {results["MIL"][league]["l"]} ({results["MIL"][league]["rd"]} RD)\n' f"MIL: {results['MIL'][league]['w']} - {results['MIL'][league]['l']} ({results['MIL'][league]['rd']} RD)\n"
f'PIT: {results["PIT"][league]["w"]} - {results["PIT"][league]["l"]} ({results["PIT"][league]["rd"]} RD)\n' f"PIT: {results['PIT'][league]['w']} - {results['PIT'][league]['l']} ({results['PIT'][league]['rd']} RD)\n"
f'STL: {results["STL"][league]["w"]} - {results["STL"][league]["l"]} ({results["STL"][league]["rd"]} RD)\n', f"STL: {results['STL'][league]['w']} - {results['STL'][league]['l']} ({results['STL'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL West ({nlw_points} pts)", name=f"NL West ({nlw_points} pts)",
value=f'ARI: {results["ARI"][league]["w"]} - {results["ARI"][league]["l"]} ({results["ARI"][league]["rd"]} RD)\n' value=f"ARI: {results['ARI'][league]['w']} - {results['ARI'][league]['l']} ({results['ARI'][league]['rd']} RD)\n"
f'COL: {results["COL"][league]["w"]} - {results["COL"][league]["l"]} ({results["COL"][league]["rd"]} RD)\n' f"COL: {results['COL'][league]['w']} - {results['COL'][league]['l']} ({results['COL'][league]['rd']} RD)\n"
f'LAD: {results["LAD"][league]["w"]} - {results["LAD"][league]["l"]} ({results["LAD"][league]["rd"]} RD)\n' f"LAD: {results['LAD'][league]['w']} - {results['LAD'][league]['l']} ({results['LAD'][league]['rd']} RD)\n"
f'SDP: {results["SDP"][league]["w"]} - {results["SDP"][league]["l"]} ({results["SDP"][league]["rd"]} RD)\n' f"SDP: {results['SDP'][league]['w']} - {results['SDP'][league]['l']} ({results['SDP'][league]['rd']} RD)\n"
f'SFG: {results["SFG"][league]["w"]} - {results["SFG"][league]["l"]} ({results["SFG"][league]["rd"]} RD)\n', f"SFG: {results['SFG'][league]['w']} - {results['SFG'][league]['l']} ({results['SFG'][league]['rd']} RD)\n",
) )
return embed return embed
@ -421,56 +421,134 @@ def get_record_embed(team: dict, results: dict, league: str):
embed = get_team_embed(league, team) embed = get_team_embed(league, team)
embed.add_field( embed.add_field(
name=f"AL East", name=f"AL East",
value=f'BAL: {results["BAL"][0]} - {results["BAL"][1]} ({results["BAL"][2]} RD)\n' value=f"BAL: {results['BAL'][0]} - {results['BAL'][1]} ({results['BAL'][2]} RD)\n"
f'BOS: {results["BOS"][0]} - {results["BOS"][1]} ({results["BOS"][2]} RD)\n' f"BOS: {results['BOS'][0]} - {results['BOS'][1]} ({results['BOS'][2]} RD)\n"
f'NYY: {results["NYY"][0]} - {results["NYY"][1]} ({results["NYY"][2]} RD)\n' f"NYY: {results['NYY'][0]} - {results['NYY'][1]} ({results['NYY'][2]} RD)\n"
f'TBR: {results["TBR"][0]} - {results["TBR"][1]} ({results["TBR"][2]} RD)\n' f"TBR: {results['TBR'][0]} - {results['TBR'][1]} ({results['TBR'][2]} RD)\n"
f'TOR: {results["TOR"][0]} - {results["TOR"][1]} ({results["TOR"][2]} RD)\n', f"TOR: {results['TOR'][0]} - {results['TOR'][1]} ({results['TOR'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL Central", name=f"AL Central",
value=f'CLE: {results["CLE"][0]} - {results["CLE"][1]} ({results["CLE"][2]} RD)\n' value=f"CLE: {results['CLE'][0]} - {results['CLE'][1]} ({results['CLE'][2]} RD)\n"
f'CHW: {results["CHW"][0]} - {results["CHW"][1]} ({results["CHW"][2]} RD)\n' f"CHW: {results['CHW'][0]} - {results['CHW'][1]} ({results['CHW'][2]} RD)\n"
f'DET: {results["DET"][0]} - {results["DET"][1]} ({results["DET"][2]} RD)\n' f"DET: {results['DET'][0]} - {results['DET'][1]} ({results['DET'][2]} RD)\n"
f'KCR: {results["KCR"][0]} - {results["KCR"][1]} ({results["KCR"][2]} RD)\n' f"KCR: {results['KCR'][0]} - {results['KCR'][1]} ({results['KCR'][2]} RD)\n"
f'MIN: {results["MIN"][0]} - {results["MIN"][1]} ({results["MIN"][2]} RD)\n', f"MIN: {results['MIN'][0]} - {results['MIN'][1]} ({results['MIN'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL West", name=f"AL West",
value=f'HOU: {results["HOU"][0]} - {results["HOU"][1]} ({results["HOU"][2]} RD)\n' value=f"HOU: {results['HOU'][0]} - {results['HOU'][1]} ({results['HOU'][2]} RD)\n"
f'LAA: {results["LAA"][0]} - {results["LAA"][1]} ({results["LAA"][2]} RD)\n' f"LAA: {results['LAA'][0]} - {results['LAA'][1]} ({results['LAA'][2]} RD)\n"
f'OAK: {results["OAK"][0]} - {results["OAK"][1]} ({results["OAK"][2]} RD)\n' f"OAK: {results['OAK'][0]} - {results['OAK'][1]} ({results['OAK'][2]} RD)\n"
f'SEA: {results["SEA"][0]} - {results["SEA"][1]} ({results["SEA"][2]} RD)\n' f"SEA: {results['SEA'][0]} - {results['SEA'][1]} ({results['SEA'][2]} RD)\n"
f'TEX: {results["TEX"][0]} - {results["TEX"][1]} ({results["TEX"][2]} RD)\n', f"TEX: {results['TEX'][0]} - {results['TEX'][1]} ({results['TEX'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL East", name=f"NL East",
value=f'ATL: {results["ATL"][0]} - {results["ATL"][1]} ({results["ATL"][2]} RD)\n' value=f"ATL: {results['ATL'][0]} - {results['ATL'][1]} ({results['ATL'][2]} RD)\n"
f'MIA: {results["MIA"][0]} - {results["MIA"][1]} ({results["MIA"][2]} RD)\n' f"MIA: {results['MIA'][0]} - {results['MIA'][1]} ({results['MIA'][2]} RD)\n"
f'NYM: {results["NYM"][0]} - {results["NYM"][1]} ({results["NYM"][2]} RD)\n' f"NYM: {results['NYM'][0]} - {results['NYM'][1]} ({results['NYM'][2]} RD)\n"
f'PHI: {results["PHI"][0]} - {results["PHI"][1]} ({results["PHI"][2]} RD)\n' f"PHI: {results['PHI'][0]} - {results['PHI'][1]} ({results['PHI'][2]} RD)\n"
f'WSN: {results["WSN"][0]} - {results["WSN"][1]} ({results["WSN"][2]} RD)\n', f"WSN: {results['WSN'][0]} - {results['WSN'][1]} ({results['WSN'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL Central", name=f"NL Central",
value=f'CHC: {results["CHC"][0]} - {results["CHC"][1]} ({results["CHC"][2]} RD)\n' value=f"CHC: {results['CHC'][0]} - {results['CHC'][1]} ({results['CHC'][2]} RD)\n"
f'CIN: {results["CIN"][0]} - {results["CIN"][1]} ({results["CIN"][2]} RD)\n' f"CIN: {results['CIN'][0]} - {results['CIN'][1]} ({results['CIN'][2]} RD)\n"
f'MIL: {results["MIL"][0]} - {results["MIL"][1]} ({results["MIL"][2]} RD)\n' f"MIL: {results['MIL'][0]} - {results['MIL'][1]} ({results['MIL'][2]} RD)\n"
f'PIT: {results["PIT"][0]} - {results["PIT"][1]} ({results["PIT"][2]} RD)\n' f"PIT: {results['PIT'][0]} - {results['PIT'][1]} ({results['PIT'][2]} RD)\n"
f'STL: {results["STL"][0]} - {results["STL"][1]} ({results["STL"][2]} RD)\n', f"STL: {results['STL'][0]} - {results['STL'][1]} ({results['STL'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL West", name=f"NL West",
value=f'ARI: {results["ARI"][0]} - {results["ARI"][1]} ({results["ARI"][2]} RD)\n' value=f"ARI: {results['ARI'][0]} - {results['ARI'][1]} ({results['ARI'][2]} RD)\n"
f'COL: {results["COL"][0]} - {results["COL"][1]} ({results["COL"][2]} RD)\n' f"COL: {results['COL'][0]} - {results['COL'][1]} ({results['COL'][2]} RD)\n"
f'LAD: {results["LAD"][0]} - {results["LAD"][1]} ({results["LAD"][2]} RD)\n' f"LAD: {results['LAD'][0]} - {results['LAD'][1]} ({results['LAD'][2]} RD)\n"
f'SDP: {results["SDP"][0]} - {results["SDP"][1]} ({results["SDP"][2]} RD)\n' f"SDP: {results['SDP'][0]} - {results['SDP'][1]} ({results['SDP'][2]} RD)\n"
f'SFG: {results["SFG"][0]} - {results["SFG"][1]} ({results["SFG"][2]} RD)\n', f"SFG: {results['SFG'][0]} - {results['SFG'][1]} ({results['SFG'][2]} RD)\n",
) )
return embed return embed
REFRACTOR_TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
async def _build_refractor_response(
player_name: str,
player_id: int,
refractor_tier: int,
refractor_data: dict,
) -> dict:
"""Build response data for a /player refractor_tier request.
Returns a dict with:
- found: bool
- image_url: str or None
- needs_render: bool
- variant: int
- card_type: str
- player_name: str
- tier_name: str
- current_tier: int (when found)
- top_cards: list (when not found)
"""
items = refractor_data.get("items", [])
match = None
for item in items:
if item["player_id"] == player_id and item["current_tier"] >= refractor_tier:
match = item
break
if match:
# Map track card_type to the URL card_type format
track_type = match.get("track", {}).get("card_type", "batter")
card_type = "pitching" if track_type in ("sp", "rp") else "batting"
return {
"found": True,
"image_url": match.get("image_url"),
"needs_render": match.get("image_url") is None,
"variant": match.get("variant", 0),
"card_type": card_type,
"player_name": player_name,
"tier_name": REFRACTOR_TIER_NAMES.get(refractor_tier, f"T{refractor_tier}"),
"current_tier": match["current_tier"],
"top_cards": [],
}
sorted_cards = sorted(
items, key=lambda x: (-x["current_tier"], -x.get("current_value", 0))
)
top_cards = []
for card in sorted_cards[:5]:
tier = card["current_tier"]
top_cards.append(
{
"player_name": card.get("player_name", "Unknown"),
"tier": tier,
"tier_name": REFRACTOR_TIER_NAMES.get(tier, f"T{tier}"),
"image_url": card.get("image_url"),
}
)
return {
"found": False,
"image_url": None,
"needs_render": False,
"variant": 0,
"player_name": player_name,
"tier_name": REFRACTOR_TIER_NAMES.get(refractor_tier, f"T{refractor_tier}"),
"top_cards": top_cards,
}
class Players(commands.Cog): class Players(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@ -650,12 +728,83 @@ class Players(commands.Cog):
name="player", description="Display one or more of the player's cards" name="player", description="Display one or more of the player's cards"
) )
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
@app_commands.describe(
refractor_tier="View a refractor tier of this card (1-4)",
)
@app_commands.autocomplete( @app_commands.autocomplete(
player_name=player_autocomplete, cardset=cardset_autocomplete player_name=player_autocomplete, cardset=cardset_autocomplete
) )
async def player_slash_command( async def player_slash_command(
self, interaction: discord.Interaction, player_name: str, cardset: str = "All" self,
interaction: discord.Interaction,
player_name: str,
cardset: str = "All",
refractor_tier: Optional[int] = None,
): ):
if refractor_tier is not None:
await interaction.response.defer()
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.edit_original_response(
content="You don't have a team yet. Join a team first!"
)
return
this_player = fuzzy_search(player_name, self.player_list)
player_data = await db_get("players", params=[("name", this_player)])
if not player_data or not player_data.get("players"):
await interaction.edit_original_response(
content=f"Player '{player_name}' not found."
)
return
pid = player_data["players"][0]["id"]
refractor_data = await db_get(
"refractor/cards", params=[("team_id", team["id"]), ("limit", 100)]
)
if not refractor_data:
refractor_data = {"count": 0, "items": []}
result = await _build_refractor_response(
player_name=this_player,
player_id=pid,
refractor_tier=refractor_tier,
refractor_data=refractor_data,
)
if result["found"]:
embed = discord.Embed(
title=f"{result['player_name']}{result['tier_name']}",
color=discord.Color.gold(),
)
if result["needs_render"]:
today = datetime.date.today().isoformat()
card_type = result.get("card_type", "batting")
render_url = f"{DB_URL}/v2/players/{pid}/{card_type}card/{today}/{result['variant']}"
embed.set_image(url=render_url)
embed.set_footer(text="First render — image generating...")
else:
embed.set_image(url=result["image_url"])
await interaction.edit_original_response(embed=embed)
else:
msg = f"You don't have a T{refractor_tier} refractor of **{this_player}**."
if result["top_cards"]:
msg += "\n\nYour top refractor cards:"
for card in result["top_cards"]:
tier_label = f"T{card['tier']} {card['tier_name']}"
if card["image_url"]:
msg += f"\n> [{card['player_name']}{tier_label}]({card['image_url']})"
else:
msg += f"\n> {card['player_name']}{tier_label}"
else:
msg += "\n\nYou don't have any refractor cards yet. Play games to earn them!"
await interaction.edit_original_response(content=msg)
return
ephemeral = False ephemeral = False
if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]: if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]:
ephemeral = True ephemeral = True
@ -694,7 +843,7 @@ class Players(commands.Cog):
if len(all_embeds) > 1: if len(all_embeds) > 1:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'# {all_players["players"][0]["p_name"]}' content=f"# {all_players['players'][0]['p_name']}"
) )
await embed_pagination( await embed_pagination(
all_embeds, all_embeds,
@ -788,11 +937,11 @@ class Players(commands.Cog):
current = await db_get("current") current = await db_get("current")
await interaction.response.send_message( await interaction.response.send_message(
f'I\'m tallying the {team["lname"]} results now...', ephemeral=ephemeral f"I'm tallying the {team['lname']} results now...", ephemeral=ephemeral
) )
st_query = await db_get( st_query = await db_get(
f'teams/{team["id"]}/season-record', object_id=current["season"] f"teams/{team['id']}/season-record", object_id=current["season"]
) )
minor_embed = get_record_embed(team, st_query["minor-league"], "Minor League") minor_embed = get_record_embed(team, st_query["minor-league"], "Minor League")
@ -812,7 +961,7 @@ class Players(commands.Cog):
start_page = 3 start_page = 3
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'Here are the {team["lname"]} campaign records' content=f"Here are the {team['lname']} campaign records"
) )
await embed_pagination( await embed_pagination(
[minor_embed, major_embed, flashback_embed, hof_embed], [minor_embed, major_embed, flashback_embed, hof_embed],
@ -856,18 +1005,18 @@ class Players(commands.Cog):
c_query = await db_get("cards", object_id=card_id) c_query = await db_get("cards", object_id=card_id)
if c_query: if c_query:
c_string = ( c_string = (
f'Card ID {card_id} is a {helpers.player_desc(c_query["player"])}' f"Card ID {card_id} is a {helpers.player_desc(c_query['player'])}"
) )
if c_query["team"] is not None: if c_query["team"] is not None:
c_string += f' owned by the {c_query["team"]["sname"]}' c_string += f" owned by the {c_query['team']['sname']}"
if c_query["pack"] is not None: if c_query["pack"] is not None:
c_string += ( c_string += (
f' pulled from a {c_query["pack"]["pack_type"]["name"]} pack.' f" pulled from a {c_query['pack']['pack_type']['name']} pack."
) )
else: else:
c_query["team"] = c_query["pack"]["team"] c_query["team"] = c_query["pack"]["team"]
c_string += ( c_string += (
f' used by the {c_query["pack"]["team"]["sname"]} in a gauntlet' f" used by the {c_query['pack']['team']['sname']} in a gauntlet"
) )
await interaction.edit_original_response( await interaction.edit_original_response(
@ -947,7 +1096,7 @@ class Players(commands.Cog):
await ctx.send(f"Who?") await ctx.send(f"Who?")
return return
await ctx.send(f'{t_query["teams"][0]["sname"]} are a bunch of cuties!') await ctx.send(f"{t_query['teams'][0]['sname']} are a bunch of cuties!")
@commands.hybrid_command(name="random", help="Check out a random card") @commands.hybrid_command(name="random", help="Check out a random card")
@commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.has_any_role(PD_PLAYERS_ROLE_NAME)
@ -1074,7 +1223,7 @@ class Players(commands.Cog):
r_query = await db_get("results", params=params) r_query = await db_get("results", params=params)
if not r_query["count"]: if not r_query["count"]:
await ctx.send( await ctx.send(
f'There are no Ranked games on record this {"week" if which == "week" else "season"}.' f"There are no Ranked games on record this {'week' if which == 'week' else 'season'}."
) )
return return
@ -1116,8 +1265,8 @@ class Players(commands.Cog):
# await ctx.send(f'sorted: {sorted_records}') # await ctx.send(f'sorted: {sorted_records}')
embed = get_team_embed( embed = get_team_embed(
title=f'{"Season" if which == "season" else "Week"} ' title=f"{'Season' if which == 'season' else 'Week'} "
f'{current["season"] if which == "season" else current["week"]} Standings' f"{current['season'] if which == 'season' else current['week']} Standings"
) )
chunk_string = "" chunk_string = ""
@ -1126,8 +1275,8 @@ class Players(commands.Cog):
team = await db_get("teams", object_id=record[0]) team = await db_get("teams", object_id=record[0])
if team: if team:
chunk_string += ( chunk_string += (
f'{record[1]["points"]} pt{"s" if record[1]["points"] != 1 else ""} ' f"{record[1]['points']} pt{'s' if record[1]['points'] != 1 else ''} "
f'({record[1]["wins"]}-{record[1]["losses"]}) - {team["sname"]} [{team["ranking"]}]\n' f"({record[1]['wins']}-{record[1]['losses']}) - {team['sname']} [{team['ranking']}]\n"
) )
else: else:
@ -1237,7 +1386,7 @@ class Players(commands.Cog):
this_run = r_query["runs"][0] this_run = r_query["runs"][0]
else: else:
await interaction.channel.send( await interaction.channel.send(
content=f'I do not see an active run for the {this_team["lname"]}.' content=f"I do not see an active run for the {this_team['lname']}."
) )
else: else:
await interaction.channel.send( await interaction.channel.send(
@ -1311,7 +1460,7 @@ class Players(commands.Cog):
if r_query["count"] != 0: if r_query["count"] != 0:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'Looks like you already have a {r_query["runs"][0]["gauntlet"]["name"]} run active! ' content=f"Looks like you already have a {r_query['runs'][0]['gauntlet']['name']} run active! "
f"You can check it out with the `/gauntlets status` command." f"You can check it out with the `/gauntlets status` command."
) )
return return
@ -1322,7 +1471,7 @@ class Players(commands.Cog):
) )
except ZeroDivisionError as e: except ZeroDivisionError as e:
logger.error( logger.error(
f'ZeroDivisionError in {this_event["name"]} draft for the {main_team.sname}: {e}' f"ZeroDivisionError in {this_event['name']} draft for the {main_team.sname}: {e}"
) )
await gauntlets.wipe_team(draft_team, interaction) await gauntlets.wipe_team(draft_team, interaction)
await interaction.channel.send( await interaction.channel.send(
@ -1333,7 +1482,7 @@ class Players(commands.Cog):
return return
except Exception as e: except Exception as e:
logger.error( logger.error(
f'Failed to run {this_event["name"]} draft for the {main_team.sname}: {e}' f"Failed to run {this_event['name']} draft for the {main_team.sname}: {e}"
) )
await gauntlets.wipe_team(draft_team, interaction) await gauntlets.wipe_team(draft_team, interaction)
await interaction.channel.send( await interaction.channel.send(
@ -1348,7 +1497,7 @@ class Players(commands.Cog):
f"Good luck, champ in the making! To start playing, follow these steps:\n\n" f"Good luck, champ in the making! To start playing, follow these steps:\n\n"
f"1) Make a copy of the Team Sheet Template found in `/help-pd links`\n" f"1) Make a copy of the Team Sheet Template found in `/help-pd links`\n"
f"2) Run `/newsheet` to link it to your Gauntlet team\n" f"2) Run `/newsheet` to link it to your Gauntlet team\n"
f'3) Go play your first game with `/new-game gauntlet {this_event["name"]}`' f"3) Go play your first game with `/new-game gauntlet {this_event['name']}`"
) )
else: else:
await interaction.channel.send( await interaction.channel.send(
@ -1360,7 +1509,7 @@ class Players(commands.Cog):
await helpers.send_to_channel( await helpers.send_to_channel(
bot=self.bot, bot=self.bot,
channel_name="pd-news-ticker", channel_name="pd-news-ticker",
content=f'The {main_team.lname} have entered the {this_event["name"]} Gauntlet!', content=f"The {main_team.lname} have entered the {this_event['name']} Gauntlet!",
embed=draft_embed, embed=draft_embed,
) )
@ -1373,7 +1522,7 @@ class Players(commands.Cog):
): # type: ignore ): # type: ignore
await interaction.response.defer() await interaction.response.defer()
main_team = await get_team_by_owner(interaction.user.id) main_team = await get_team_by_owner(interaction.user.id)
draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}') draft_team = await get_team_by_abbrev(f"Gauntlet-{main_team['abbrev']}")
if draft_team is None: if draft_team is None:
await interaction.edit_original_response( await interaction.edit_original_response(
content="Hmm, I can't find a gauntlet team for you. Have you signed up already?" content="Hmm, I can't find a gauntlet team for you. Have you signed up already?"
@ -1404,7 +1553,7 @@ class Players(commands.Cog):
this_run = r_query["runs"][0] this_run = r_query["runs"][0]
else: else:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I do not see an active run for the {draft_team["lname"]}.' content=f"I do not see an active run for the {draft_team['lname']}."
) )
return return

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import copy import copy
import datetime
import logging import logging
import discord import discord
from discord import SelectOption from discord import SelectOption
@ -4243,6 +4244,34 @@ async def get_game_summary_embed(
return game_embed return game_embed
async def _trigger_variant_renders(tier_ups: list) -> None:
"""Fire-and-forget: hit card render URLs to trigger S3 upload for new variants.
Each tier-up with a variant_created value gets a GET request to the card
render endpoint, which triggers Playwright render + S3 upload as a side effect.
Failures are logged but never raised.
"""
today = datetime.date.today().isoformat()
for tier_up in tier_ups:
variant = tier_up.get("variant_created")
if variant is None:
continue
player_id = tier_up["player_id"]
track = tier_up.get("track_name", "Batter")
card_type = "pitching" if track.lower() == "pitcher" else "batting"
try:
await db_get(
f"players/{player_id}/{card_type}card/{today}/{variant}",
none_okay=True,
)
except Exception:
logger.warning(
"Failed to trigger variant render for player %d variant %d (non-fatal)",
player_id,
variant,
)
async def complete_game( async def complete_game(
session: Session, session: Session,
interaction: discord.Interaction, interaction: discord.Interaction,
@ -4353,6 +4382,7 @@ async def complete_game(
if evo_result and evo_result.get("tier_ups"): if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]: for tier_up in evo_result["tier_ups"]:
await notify_tier_completion(interaction.channel, tier_up) await notify_tier_completion(interaction.channel, tier_up)
await _trigger_variant_renders(evo_result["tier_ups"])
except Exception as e: except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}") logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")

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

@ -63,11 +63,6 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
), ),
color=color, color=color,
) )
embed.add_field(
name="Rating Boosts",
value="Rating boosts coming in a future update!",
inline=False,
)
else: else:
embed = discord.Embed( embed = discord.Embed(
title="Refractor Tier Up!", title="Refractor Tier Up!",

View File

@ -542,10 +542,8 @@ REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
| | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") | | | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") |
| | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` | | | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` |
| | - Color: teal (`0x1ABC9C`) | | | - Color: teal (`0x1ABC9C`) |
| | - Extra field: "Rating Boosts" with value "Rating boosts coming in a future update!" |
| **Pass criteria** | 1. Title is "SUPERFRACTOR!" | | **Pass criteria** | 1. Title is "SUPERFRACTOR!" |
| | 2. Description mentions "maximum refractor tier" | | | 2. Description mentions "maximum refractor tier" |
| | 3. "Rating Boosts" field is present |
### REF-63: Multiple tier-ups in one game ### REF-63: Multiple tier-ups in one game
| Field | Value | | Field | Value |

View File

@ -0,0 +1,141 @@
"""Tests for /player refractor_tier view.
Tests cover _build_refractor_response, a module-level helper that processes
raw API refractor data and returns structured response data for the slash command.
The function is pure (no network calls) so tests run without mocks for the
happy path cases, keeping tests readable and fast.
"""
import sys
import os
import pytest
# Make the repo root importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cogs.players import _build_refractor_response
REFRACTOR_CARDS_RESPONSE = {
"count": 3,
"items": [
{
"player_id": 100,
"player_name": "Mike Trout",
"current_tier": 3,
"current_value": 160,
"variant": 7,
"track": {"card_type": "batter"},
"image_url": "https://s3.example.com/cards/cardset-027/player-100/v7/battingcard.png",
},
{
"player_id": 200,
"player_name": "Barry Bonds",
"current_tier": 2,
"current_value": 110,
"variant": 3,
"track": {"card_type": "batter"},
"image_url": "https://s3.example.com/cards/cardset-027/player-200/v3/battingcard.png",
},
{
"player_id": 300,
"player_name": "Ken Griffey Jr.",
"current_tier": 1,
"current_value": 55,
"variant": 1,
"track": {"card_type": "batter"},
"image_url": None,
},
],
}
class TestBuildRefractorResponse:
"""Build embed content for /player refractor_tier views."""
@pytest.mark.asyncio
async def test_happy_path_returns_embed_with_image(self):
"""When user has the refractor at requested tier, embed includes S3 image.
Verifies that when a player_id match is found at or above the requested
tier, the result is marked as found and the image_url is passed through.
"""
result = await _build_refractor_response(
player_name="Mike Trout",
player_id=100,
refractor_tier=3,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is True
assert "s3.example.com" in result["image_url"]
@pytest.mark.asyncio
async def test_not_found_returns_top_5(self):
"""When user doesn't have the refractor, show top 5 cards.
Verifies that when no match is found for the given player_id + tier,
the response includes the top cards sorted by tier descending, and
the highest-tier card appears first.
"""
result = await _build_refractor_response(
player_name="Nobody",
player_id=999,
refractor_tier=2,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is False
assert len(result["top_cards"]) <= 5
assert result["top_cards"][0]["player_name"] == "Mike Trout"
@pytest.mark.asyncio
async def test_image_url_none_triggers_render(self):
"""When refractor exists but image_url is None, result signals render needed.
A card may exist at the requested tier without a cached S3 image URL
if it has never been rendered. The response should set needs_render=True
so the caller can construct a render endpoint URL and show a placeholder.
"""
result = await _build_refractor_response(
player_name="Ken Griffey Jr.",
player_id=300,
refractor_tier=1,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is True
assert result["image_url"] is None
assert result["needs_render"] is True
assert result["variant"] == 1
@pytest.mark.asyncio
async def test_no_refractors_at_all(self):
"""When user has zero refractor cards, clean message.
An empty items list should produce found=False with an empty top_cards
list, allowing the caller to show a "no refractors yet" message.
"""
empty_data = {"count": 0, "items": []}
result = await _build_refractor_response(
player_name="Someone",
player_id=500,
refractor_tier=1,
refractor_data=empty_data,
)
assert result["found"] is False
assert result["top_cards"] == []
@pytest.mark.asyncio
async def test_tier_higher_than_current_not_found(self):
"""Requesting T4 when player is at T3 returns not found.
The match condition requires current_tier >= refractor_tier. Requesting
a tier the player hasn't reached should return found=False so the
caller can show what tier they do have.
"""
result = await _build_refractor_response(
player_name="Mike Trout",
player_id=100,
refractor_tier=4,
refractor_data=REFRACTOR_CARDS_RESPONSE,
)
assert result["found"] is False

View File

@ -0,0 +1,65 @@
"""Tests for post-game refractor card render trigger."""
from unittest.mock import AsyncMock, patch
import pytest
from command_logic.logic_gameplay import _trigger_variant_renders
class TestTriggerVariantRenders:
"""Fire-and-forget card render calls after tier-ups."""
@pytest.mark.asyncio
async def test_calls_render_url_for_each_tier_up(self):
"""Each tier-up with variant_created triggers a card render GET request."""
tier_ups = [
{"player_id": 100, "variant_created": 7, "track_name": "Batter"},
{"player_id": 200, "variant_created": 3, "track_name": "Pitcher"},
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
await _trigger_variant_renders(tier_ups)
assert mock_get.call_count == 2
call_args_list = [call.args[0] for call in mock_get.call_args_list]
assert any("100" in url and "7" in url for url in call_args_list)
assert any("200" in url and "3" in url for url in call_args_list)
@pytest.mark.asyncio
async def test_skips_tier_ups_without_variant(self):
"""Tier-ups without variant_created are skipped."""
tier_ups = [
{"player_id": 100, "track_name": "Batter"},
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
await _trigger_variant_renders(tier_ups)
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_api_failure_does_not_raise(self):
"""Render trigger failures are swallowed — fire-and-forget."""
tier_ups = [
{"player_id": 100, "variant_created": 7, "track_name": "Batter"},
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.side_effect = Exception("API down")
await _trigger_variant_renders(tier_ups)
@pytest.mark.asyncio
async def test_empty_tier_ups_is_noop(self):
"""Empty tier_ups list does nothing."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
await _trigger_variant_renders([])
mock_get.assert_not_called()

View File

@ -3,7 +3,7 @@ Tests for Refractor Tier Completion Notification embeds.
These tests verify that: These tests verify that:
1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color). 1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color).
2. Tier 4 (Superfractor) embeds include the special title, description, and note field. 2. Tier 4 (Superfractor) embeds include the special title, description, and color.
3. Multiple tier-up events each produce a separate embed. 3. Multiple tier-up events each produce a separate embed.
4. An empty tier-up list results in no channel sends. 4. An empty tier-up list results in no channel sends.
@ -143,36 +143,11 @@ class TestBuildTierUpEmbedSuperfractor:
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x1ABC9C assert embed.color.value == 0x1ABC9C
def test_note_field_present(self): def test_no_extra_fields(self):
"""Tier 4 must include a note field about future rating boosts.""" """Tier 4 embed should have no extra fields — boosts are live, no teaser needed."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
field_names = [f.name for f in embed.fields] assert len(embed.fields) == 0
assert any(
"rating" in name.lower()
or "boost" in name.lower()
or "note" in name.lower()
for name in field_names
), "Expected a field mentioning rating boosts for tier 4 embed"
def test_note_field_value_mentions_future_update(self):
"""The note field value must reference the future rating boost update."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
note_field = next(
(
f
for f in embed.fields
if "rating" in f.name.lower()
or "boost" in f.name.lower()
or "note" in f.name.lower()
),
None,
)
assert note_field is not None
assert (
"future" in note_field.value.lower() or "update" in note_field.value.lower()
)
def test_footer_text_is_paper_dynasty_refractor(self): def test_footer_text_is_paper_dynasty_refractor(self):
"""Footer must remain 'Paper Dynasty Refractor' for tier 4 as well.""" """Footer must remain 'Paper Dynasty Refractor' for tier 4 as well."""