Compare commits
57 Commits
fix/refrac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d86641fda | |||
|
|
39424f7157 | ||
| 8842d80f26 | |||
|
|
25b63b407f | ||
| bd6c387902 | |||
|
|
eb022c3d66 | ||
| 63a25ea0ba | |||
| e5ec88f794 | |||
| ff57b8fea3 | |||
|
|
2f22a11e17 | ||
|
|
8ddd58101c | ||
| 24420268cf | |||
|
|
21bad7af51 | ||
| 224250b03d | |||
|
|
1a3f8994a9 | ||
| f67c1c41a7 | |||
| 435bfd376f | |||
| c01167b097 | |||
|
|
59a41e0c39 | ||
| 3210d5d6a4 | |||
|
|
8e5242a6b7 | ||
| 8f9242bed8 | |||
|
|
cb17b99220 | ||
|
|
ddc9a28023 | ||
|
|
f488cb66e0 | ||
| 5cfddaa89a | |||
|
|
78f313663e | ||
|
|
46744d139c | ||
|
|
730d4b4f60 | ||
| 9ee4a76cd6 | |||
|
|
80e99b075f | ||
|
|
ef270ec1ab | ||
| b65d91a65b | |||
| 4bda3bf0de | |||
| ff768c95f5 | |||
|
|
fb545ef34a | ||
|
|
f704b09933 | ||
|
|
94f3b1dc97 | ||
| fca85d583f | |||
| b6592b8a70 | |||
|
|
01f6fb50d5 | ||
| f843b45099 | |||
|
|
bbad1daba2 | ||
| 2d7c19814e | |||
|
|
c3ff85fd2d | ||
| 64c656ce91 | |||
|
|
cd822857bf | ||
| 34774290b8 | |||
|
|
6239f1177c | ||
| dea6316201 | |||
|
|
b9deb14b62 | ||
| 48392a9bbe | |||
|
|
a53cc5cac3 | ||
| a8b4d6cdbb | |||
|
|
8d2cdc81fe | ||
| 27ce8b3617 | |||
|
|
17d124feb4 |
54
.env.example
Normal file
54
.env.example
Normal 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
1
.gitignore
vendored
@ -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/**
|
||||||
|
|||||||
@ -31,7 +31,7 @@ pip install -r requirements.txt # Install dependencies
|
|||||||
- **Path**: `/home/cal/container-data/paper-dynasty`
|
- **Path**: `/home/cal/container-data/paper-dynasty`
|
||||||
- **Container**: `paper-dynasty_discord-app_1`
|
- **Container**: `paper-dynasty_discord-app_1`
|
||||||
- **Image**: `manticorum67/paper-dynasty-discordapp`
|
- **Image**: `manticorum67/paper-dynasty-discordapp`
|
||||||
- **Health**: `GET http://localhost:8080/health` (HTTP server in `health_server.py`)
|
- **Health**: `GET http://localhost:8081/health` (HTTP server in `health_server.py`)
|
||||||
- **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release
|
- **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release
|
||||||
|
|
||||||
### Logs
|
### Logs
|
||||||
@ -46,7 +46,7 @@ pip install -r requirements.txt # Install dependencies
|
|||||||
- Bot not responding → check `docker logs`, verify `BOT_TOKEN` and `GUILD_ID`
|
- Bot not responding → check `docker logs`, verify `BOT_TOKEN` and `GUILD_ID`
|
||||||
- API errors → verify `DATABASE` is set to `Prod` or `Dev`, check `API_TOKEN` matches the database API
|
- API errors → verify `DATABASE` is set to `Prod` or `Dev`, check `API_TOKEN` matches the database API
|
||||||
- Game engine errors → check `/usr/src/app/logs/discord.log` for detailed tracebacks
|
- Game engine errors → check `/usr/src/app/logs/discord.log` for detailed tracebacks
|
||||||
- Health endpoint not responding → `health_server.py` runs on port 8080 inside the container
|
- Health endpoint not responding → `health_server.py` runs on port 8081 inside the container
|
||||||
|
|
||||||
### CI/CD
|
### CI/CD
|
||||||
Ruff lint on PRs. Docker image built on CalVer tag push only.
|
Ruff lint on PRs. Docker image built on CalVer tag push only.
|
||||||
|
|||||||
@ -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])}")
|
||||||
|
|||||||
@ -9,17 +9,25 @@ 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):
|
||||||
@ -28,78 +36,108 @@ class Packs(commands.Cog):
|
|||||||
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,31 +388,38 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
326
cogs/players.py
326
cogs/players.py
@ -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,
|
||||||
@ -60,6 +60,7 @@ from helpers import (
|
|||||||
)
|
)
|
||||||
from utilities.buttons import ask_with_buttons
|
from utilities.buttons import ask_with_buttons
|
||||||
from utilities.autocomplete import cardset_autocomplete, player_autocomplete
|
from utilities.autocomplete import cardset_autocomplete, player_autocomplete
|
||||||
|
from helpers.refractor_constants import TIER_NAMES as REFRACTOR_TIER_NAMES
|
||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
@ -293,29 +294,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 +368,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 +422,126 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_refractor_response(
|
||||||
|
player_name: str,
|
||||||
|
player_id: int,
|
||||||
|
refractor_tier: int,
|
||||||
|
refractor_data: dict,
|
||||||
|
team: Optional[dict] = None,
|
||||||
|
) -> 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 +721,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 +836,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 +930,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 +954,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 +998,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 +1089,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 +1216,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 +1258,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 +1268,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 +1379,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 +1453,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 +1464,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 +1475,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 +1490,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 +1502,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 +1515,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 +1546,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
|
||||||
|
|
||||||
|
|||||||
@ -15,89 +15,148 @@ from typing import Optional
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
|
from discord.app_commands import Choice
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from api_calls import db_get
|
from api_calls import db_get
|
||||||
|
from helpers.discord_utils import get_team_embed
|
||||||
from helpers.main import get_team_by_owner
|
from helpers.main import get_team_by_owner
|
||||||
|
from helpers.refractor_constants import TIER_NAMES, STATUS_TIER_COLORS as TIER_COLORS
|
||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
PAGE_SIZE = 10
|
PAGE_SIZE = 10
|
||||||
|
|
||||||
TIER_NAMES = {
|
# Tier-specific labels for the status display.
|
||||||
0: "Base Card",
|
TIER_SYMBOLS = {
|
||||||
1: "Base Chrome",
|
0: "Base", # Base Card — used in summary only, not in per-card display
|
||||||
2: "Refractor",
|
1: "T1", # Base Chrome
|
||||||
3: "Gold Refractor",
|
2: "T2", # Refractor
|
||||||
4: "Superfractor",
|
3: "T3", # Gold Refractor
|
||||||
|
4: "T4★", # Superfractor
|
||||||
}
|
}
|
||||||
|
|
||||||
FORMULA_LABELS = {
|
_FULL_BAR = "▰" * 12
|
||||||
"batter": "PA+TB×2",
|
|
||||||
"sp": "IP+K",
|
|
||||||
"rp": "IP+K",
|
|
||||||
}
|
|
||||||
|
|
||||||
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}
|
|
||||||
|
|
||||||
|
|
||||||
def render_progress_bar(current: int, threshold: int, width: int = 10) -> str:
|
def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
|
||||||
"""
|
"""
|
||||||
Render a fixed-width ASCII progress bar.
|
Render a Unicode block progress bar.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
render_progress_bar(120, 149) -> '[========--]'
|
render_progress_bar(120, 149) -> '▰▰▰▰▰▰▰▰▰▰▱▱'
|
||||||
render_progress_bar(0, 100) -> '[----------]'
|
render_progress_bar(0, 100) -> '▱▱▱▱▱▱▱▱▱▱▱▱'
|
||||||
render_progress_bar(100, 100) -> '[==========]'
|
render_progress_bar(100, 100) -> '▰▰▰▰▰▰▰▰▰▰▰▰'
|
||||||
"""
|
"""
|
||||||
if threshold <= 0:
|
if threshold <= 0:
|
||||||
filled = width
|
filled = width
|
||||||
else:
|
else:
|
||||||
ratio = min(current / threshold, 1.0)
|
ratio = max(0.0, min(current / threshold, 1.0))
|
||||||
filled = round(ratio * width)
|
filled = round(ratio * width)
|
||||||
empty = width - filled
|
empty = width - filled
|
||||||
return f"[{'=' * filled}{'-' * empty}]"
|
return f"{'▰' * filled}{'▱' * empty}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_label(current: int, threshold: int) -> str:
|
||||||
|
"""Return a percentage string like '80%'."""
|
||||||
|
if threshold <= 0:
|
||||||
|
return "100%"
|
||||||
|
return f"{min(current / threshold, 1.0):.0%}"
|
||||||
|
|
||||||
|
|
||||||
def format_refractor_entry(card_state: dict) -> str:
|
def format_refractor_entry(card_state: dict) -> str:
|
||||||
"""
|
"""
|
||||||
Format a single card state dict as a display string.
|
Format a single card state dict as a compact two-line display string.
|
||||||
|
|
||||||
Expected keys: player_name, card_type, current_tier, formula_value,
|
Output example (base card — no suffix):
|
||||||
next_threshold (None if fully evolved).
|
**Mike Trout**
|
||||||
|
▰▰▰▰▰▰▰▰▰▰▱▱ 120/149 (80%)
|
||||||
|
|
||||||
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the
|
Output example (evolved — suffix tag):
|
||||||
player name for tiers 1-4. T0 cards have no badge.
|
**Mike Trout** — Base Chrome [T1]
|
||||||
|
▰▰▰▰▰▰▰▰▰▰▱▱ 120/149 (80%)
|
||||||
|
|
||||||
Output example:
|
Output example (fully evolved):
|
||||||
**[BC] Mike Trout** (Base Chrome)
|
**Barry Bonds** — Superfractor [T4★]
|
||||||
[========--] 120/149 (PA+TB×2) — T1 → T2
|
▰▰▰▰▰▰▰▰▰▰▰▰ `MAX`
|
||||||
"""
|
"""
|
||||||
player_name = card_state.get("player_name", "Unknown")
|
player_name = card_state.get("player_name", "Unknown")
|
||||||
track = card_state.get("track", {})
|
|
||||||
card_type = track.get("card_type", "batter")
|
|
||||||
current_tier = card_state.get("current_tier", 0)
|
current_tier = card_state.get("current_tier", 0)
|
||||||
formula_value = card_state.get("current_value", 0)
|
formula_value = int(card_state.get("current_value", 0))
|
||||||
next_threshold = card_state.get("next_threshold")
|
next_threshold = int(card_state.get("next_threshold") or 0) or None
|
||||||
|
|
||||||
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
|
if current_tier == 0:
|
||||||
formula_label = FORMULA_LABELS.get(card_type, card_type)
|
first_line = f"**{player_name}**"
|
||||||
|
else:
|
||||||
badge = TIER_BADGES.get(current_tier, "")
|
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
|
||||||
display_name = f"{badge} {player_name}" if badge else player_name
|
symbol = TIER_SYMBOLS.get(current_tier, "")
|
||||||
|
first_line = f"**{player_name}** — {tier_label} [{symbol}]"
|
||||||
|
|
||||||
if current_tier >= 4 or next_threshold is None:
|
if current_tier >= 4 or next_threshold is None:
|
||||||
bar = "[==========]"
|
second_line = f"{_FULL_BAR} `MAX`"
|
||||||
detail = "FULLY EVOLVED ★"
|
|
||||||
else:
|
else:
|
||||||
bar = render_progress_bar(formula_value, next_threshold)
|
bar = render_progress_bar(formula_value, next_threshold)
|
||||||
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}"
|
pct = _pct_label(formula_value, next_threshold)
|
||||||
|
second_line = f"{bar} {formula_value}/{next_threshold} ({pct})"
|
||||||
|
|
||||||
first_line = f"**{display_name}** ({tier_label})"
|
|
||||||
second_line = f"{bar} {detail}"
|
|
||||||
return f"{first_line}\n{second_line}"
|
return f"{first_line}\n{second_line}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_tier_summary(items: list, total_count: int) -> str:
|
||||||
|
"""
|
||||||
|
Build a one-line summary of tier distribution from the current page items.
|
||||||
|
|
||||||
|
Returns something like: 'T0: 3 T1: 12 T2: 8 T3: 5 T4★: 2 — 30 total'
|
||||||
|
"""
|
||||||
|
counts = {t: 0 for t in range(5)}
|
||||||
|
for item in items:
|
||||||
|
t = item.get("current_tier", 0)
|
||||||
|
if t in counts:
|
||||||
|
counts[t] += 1
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for t in range(5):
|
||||||
|
if counts[t] > 0:
|
||||||
|
parts.append(f"{TIER_SYMBOLS[t]}: {counts[t]}")
|
||||||
|
summary = " ".join(parts) if parts else "No cards"
|
||||||
|
return f"{summary} — {total_count} total"
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_embed(
|
||||||
|
team: dict,
|
||||||
|
items: list,
|
||||||
|
page: int,
|
||||||
|
total_pages: int,
|
||||||
|
total_count: int,
|
||||||
|
tier_filter: Optional[int] = None,
|
||||||
|
) -> discord.Embed:
|
||||||
|
"""
|
||||||
|
Build the refractor status embed with team branding.
|
||||||
|
|
||||||
|
Uses get_team_embed for consistent team color/logo/footer, then layers
|
||||||
|
on the refractor-specific content.
|
||||||
|
"""
|
||||||
|
embed = get_team_embed(f"{team['sname']} — Refractor Status", team=team)
|
||||||
|
|
||||||
|
# Override color for single-tier views to match the tier's identity.
|
||||||
|
if tier_filter is not None and tier_filter in TIER_COLORS:
|
||||||
|
embed.color = TIER_COLORS[tier_filter]
|
||||||
|
|
||||||
|
header = build_tier_summary(items, total_count)
|
||||||
|
lines = [format_refractor_entry(state) for state in items]
|
||||||
|
body = "\n\n".join(lines) if lines else "*No cards found.*"
|
||||||
|
embed.description = f"```{header}```\n{body}"
|
||||||
|
|
||||||
|
existing_footer = embed.footer.text or ""
|
||||||
|
page_text = f"Page {page}/{total_pages}"
|
||||||
|
embed.set_footer(
|
||||||
|
text=f"{page_text} · {existing_footer}" if existing_footer else page_text,
|
||||||
|
icon_url=embed.footer.icon_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
def apply_close_filter(card_states: list) -> list:
|
def apply_close_filter(card_states: list) -> list:
|
||||||
"""
|
"""
|
||||||
Return only cards within 80% of their next tier threshold.
|
Return only cards within 80% of their next tier threshold.
|
||||||
@ -107,11 +166,11 @@ def apply_close_filter(card_states: list) -> list:
|
|||||||
result = []
|
result = []
|
||||||
for state in card_states:
|
for state in card_states:
|
||||||
current_tier = state.get("current_tier", 0)
|
current_tier = state.get("current_tier", 0)
|
||||||
formula_value = state.get("current_value", 0)
|
formula_value = int(state.get("current_value", 0))
|
||||||
next_threshold = state.get("next_threshold")
|
next_threshold = state.get("next_threshold")
|
||||||
if current_tier >= 4 or not next_threshold:
|
if current_tier >= 4 or not next_threshold:
|
||||||
continue
|
continue
|
||||||
if formula_value >= 0.8 * next_threshold:
|
if formula_value >= 0.8 * int(next_threshold):
|
||||||
result.append(state)
|
result.append(state)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -128,6 +187,83 @@ def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
|
|||||||
return items[start : start + page_size], total_pages
|
return items[start : start + page_size], total_pages
|
||||||
|
|
||||||
|
|
||||||
|
class RefractorPaginationView(discord.ui.View):
|
||||||
|
"""Prev/Next buttons for refractor status pagination."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
team: dict,
|
||||||
|
page: int,
|
||||||
|
total_pages: int,
|
||||||
|
total_count: int,
|
||||||
|
params: list,
|
||||||
|
owner_id: int,
|
||||||
|
tier_filter: Optional[int] = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
):
|
||||||
|
super().__init__(timeout=timeout)
|
||||||
|
self.team = team
|
||||||
|
self.page = page
|
||||||
|
self.total_pages = total_pages
|
||||||
|
self.total_count = total_count
|
||||||
|
self.base_params = params
|
||||||
|
self.owner_id = owner_id
|
||||||
|
self.tier_filter = tier_filter
|
||||||
|
self._update_buttons()
|
||||||
|
|
||||||
|
def _update_buttons(self):
|
||||||
|
self.prev_btn.disabled = self.page <= 1
|
||||||
|
self.next_btn.disabled = self.page >= self.total_pages
|
||||||
|
|
||||||
|
async def _fetch_and_update(self, interaction: discord.Interaction):
|
||||||
|
offset = (self.page - 1) * PAGE_SIZE
|
||||||
|
params = [(k, v) for k, v in self.base_params if k != "offset"]
|
||||||
|
params.append(("offset", offset))
|
||||||
|
|
||||||
|
data = await db_get("refractor/cards", params=params)
|
||||||
|
items = data.get("items", []) if isinstance(data, dict) else []
|
||||||
|
self.total_count = (
|
||||||
|
data.get("count", self.total_count)
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else self.total_count
|
||||||
|
)
|
||||||
|
self.total_pages = max(1, (self.total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
self.page = min(self.page, self.total_pages)
|
||||||
|
|
||||||
|
embed = build_status_embed(
|
||||||
|
self.team,
|
||||||
|
items,
|
||||||
|
self.page,
|
||||||
|
self.total_pages,
|
||||||
|
self.total_count,
|
||||||
|
tier_filter=self.tier_filter,
|
||||||
|
)
|
||||||
|
self._update_buttons()
|
||||||
|
await interaction.response.edit_message(embed=embed, view=self)
|
||||||
|
|
||||||
|
@discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.grey)
|
||||||
|
async def prev_btn(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
|
if interaction.user.id != self.owner_id:
|
||||||
|
return
|
||||||
|
self.page = max(1, self.page - 1)
|
||||||
|
await self._fetch_and_update(interaction)
|
||||||
|
|
||||||
|
@discord.ui.button(label="Next ▶", style=discord.ButtonStyle.grey)
|
||||||
|
async def next_btn(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
|
if interaction.user.id != self.owner_id:
|
||||||
|
return
|
||||||
|
self.page = min(self.total_pages, self.page + 1)
|
||||||
|
await self._fetch_and_update(interaction)
|
||||||
|
|
||||||
|
async def on_timeout(self):
|
||||||
|
self.prev_btn.disabled = True
|
||||||
|
self.next_btn.disabled = True
|
||||||
|
|
||||||
|
|
||||||
class Refractor(commands.Cog):
|
class Refractor(commands.Cog):
|
||||||
"""Refractor progress tracking slash commands."""
|
"""Refractor progress tracking slash commands."""
|
||||||
|
|
||||||
@ -142,19 +278,34 @@ class Refractor(commands.Cog):
|
|||||||
name="status", description="Show your team's refractor progress"
|
name="status", description="Show your team's refractor progress"
|
||||||
)
|
)
|
||||||
@app_commands.describe(
|
@app_commands.describe(
|
||||||
card_type="Card type filter (batter, sp, rp)",
|
card_type="Filter by card type",
|
||||||
season="Season number (default: current)",
|
tier="Filter by current tier",
|
||||||
tier="Filter by current tier (0-4)",
|
progress="Filter by advancement progress",
|
||||||
progress='Use "close" to show cards within 80% of their next tier',
|
|
||||||
page="Page number (default: 1, 10 cards per page)",
|
page="Page number (default: 1, 10 cards per page)",
|
||||||
)
|
)
|
||||||
|
@app_commands.choices(
|
||||||
|
card_type=[
|
||||||
|
Choice(value="batter", name="Batter"),
|
||||||
|
Choice(value="sp", name="Starting Pitcher"),
|
||||||
|
Choice(value="rp", name="Relief Pitcher"),
|
||||||
|
],
|
||||||
|
tier=[
|
||||||
|
Choice(value="0", name="T0 — Base Card"),
|
||||||
|
Choice(value="1", name="T1 — Base Chrome"),
|
||||||
|
Choice(value="2", name="T2 — Refractor"),
|
||||||
|
Choice(value="3", name="T3 — Gold Refractor"),
|
||||||
|
Choice(value="4", name="T4 — Superfractor"),
|
||||||
|
],
|
||||||
|
progress=[
|
||||||
|
Choice(value="close", name="Close to next tier (≥80%)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
async def refractor_status(
|
async def refractor_status(
|
||||||
self,
|
self,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
card_type: Optional[str] = None,
|
card_type: Optional[Choice[str]] = None,
|
||||||
season: Optional[int] = None,
|
tier: Optional[Choice[str]] = None,
|
||||||
tier: Optional[int] = None,
|
progress: Optional[Choice[str]] = None,
|
||||||
progress: Optional[str] = None,
|
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
):
|
):
|
||||||
"""Show a paginated view of the invoking user's team refractor progress."""
|
"""Show a paginated view of the invoking user's team refractor progress."""
|
||||||
@ -171,13 +322,13 @@ class Refractor(commands.Cog):
|
|||||||
offset = (page - 1) * PAGE_SIZE
|
offset = (page - 1) * PAGE_SIZE
|
||||||
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
|
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
|
||||||
if card_type:
|
if card_type:
|
||||||
params.append(("card_type", card_type))
|
params.append(("card_type", card_type.value))
|
||||||
if season is not None:
|
|
||||||
params.append(("season", season))
|
|
||||||
if tier is not None:
|
if tier is not None:
|
||||||
params.append(("tier", tier))
|
params.append(("tier", tier.value))
|
||||||
if progress:
|
if progress:
|
||||||
params.append(("progress", progress))
|
params.append(("progress", progress.value))
|
||||||
|
|
||||||
|
tier_filter = int(tier.value) if tier is not None else None
|
||||||
|
|
||||||
data = await db_get("refractor/cards", params=params)
|
data = await db_get("refractor/cards", params=params)
|
||||||
if not data:
|
if not data:
|
||||||
@ -203,6 +354,18 @@ class Refractor(commands.Cog):
|
|||||||
total_count = (
|
total_count = (
|
||||||
data.get("count", len(items)) if isinstance(data, dict) else len(items)
|
data.get("count", len(items)) if isinstance(data, dict) else len(items)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If the requested page is beyond the last page, clamp and re-fetch.
|
||||||
|
if not items and total_count > 0:
|
||||||
|
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
page = total_pages
|
||||||
|
clamped_params = [(k, v) for k, v in params if k != "offset"]
|
||||||
|
clamped_params.append(("offset", (page - 1) * PAGE_SIZE))
|
||||||
|
data = await db_get("refractor/cards", params=clamped_params)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
items = data.get("items", [])
|
||||||
|
total_count = data.get("count", total_count)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Refractor status for team %s: %d items returned, %d total (page %d)",
|
"Refractor status for team %s: %d items returned, %d total (page %d)",
|
||||||
team["id"],
|
team["id"],
|
||||||
@ -211,9 +374,18 @@ class Refractor(commands.Cog):
|
|||||||
page,
|
page,
|
||||||
)
|
)
|
||||||
if not items:
|
if not items:
|
||||||
if progress == "close":
|
has_filters = card_type or tier is not None or progress
|
||||||
|
if has_filters:
|
||||||
|
parts = []
|
||||||
|
if card_type:
|
||||||
|
parts.append(f"**{card_type.name}**")
|
||||||
|
if tier is not None:
|
||||||
|
parts.append(f"**{tier.name}**")
|
||||||
|
if progress:
|
||||||
|
parts.append(f"progress: **{progress.name}**")
|
||||||
|
filter_str = ", ".join(parts)
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content="No cards are currently close to a tier advancement."
|
content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
@ -223,19 +395,24 @@ class Refractor(commands.Cog):
|
|||||||
|
|
||||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
page = min(page, total_pages)
|
page = min(page, total_pages)
|
||||||
page_items = items
|
|
||||||
lines = [format_refractor_entry(state) for state in page_items]
|
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = build_status_embed(
|
||||||
title=f"{team['sname']} Refractor Status",
|
team, items, page, total_pages, total_count, tier_filter=tier_filter
|
||||||
description="\n\n".join(lines),
|
|
||||||
color=0x6F42C1,
|
|
||||||
)
|
|
||||||
embed.set_footer(
|
|
||||||
text=f"Page {page}/{total_pages} · {total_count} card(s) total"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await interaction.edit_original_response(embed=embed)
|
if total_pages > 1:
|
||||||
|
view = RefractorPaginationView(
|
||||||
|
team=team,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
total_count=total_count,
|
||||||
|
params=params,
|
||||||
|
owner_id=interaction.user.id,
|
||||||
|
tier_filter=tier_filter,
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(embed=embed, view=view)
|
||||||
|
else:
|
||||||
|
await interaction.edit_original_response(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
|
|||||||
@ -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
|
||||||
@ -23,6 +24,7 @@ from helpers import (
|
|||||||
position_name_to_abbrev,
|
position_name_to_abbrev,
|
||||||
team_role,
|
team_role,
|
||||||
)
|
)
|
||||||
|
from helpers.refractor_constants import TIER_NAMES
|
||||||
from helpers.refractor_notifs import notify_tier_completion
|
from helpers.refractor_notifs import notify_tier_completion
|
||||||
from in_game.ai_manager import get_starting_lineup
|
from in_game.ai_manager import get_starting_lineup
|
||||||
from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check
|
from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check
|
||||||
@ -4243,6 +4245,118 @@ async def get_game_summary_embed(
|
|||||||
return game_embed
|
return game_embed
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_post_game_refractor_hook(db_game_id: int, channel) -> dict | None:
|
||||||
|
"""Post-game refractor processing — non-fatal.
|
||||||
|
|
||||||
|
Updates season stats then evaluates refractor milestones for all
|
||||||
|
participating players. Triggers variant card renders first to obtain
|
||||||
|
image URLs, then fires tier-up notifications with card art included.
|
||||||
|
Wrapped in try/except so any failure here is non-fatal — the game is
|
||||||
|
already saved and refractor will self-heal on the next evaluate call.
|
||||||
|
|
||||||
|
Returns the evaluate-game API response dict, or None on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await db_post(f"season-stats/update-game/{db_game_id}")
|
||||||
|
evo_result = await db_post(f"refractor/evaluate-game/{db_game_id}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
tier_ups = evo_result["tier_ups"]
|
||||||
|
image_url_map = await _trigger_variant_renders(tier_ups)
|
||||||
|
for tier_up in tier_ups:
|
||||||
|
img = image_url_map.get(tier_up.get("player_id"))
|
||||||
|
await notify_tier_completion(channel, tier_up, image_url=img)
|
||||||
|
return evo_result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _trigger_variant_renders(tier_ups: list) -> dict:
|
||||||
|
"""Trigger S3 card renders for each tier-up variant and return image URLs.
|
||||||
|
|
||||||
|
Each tier-up with a variant_created value gets a GET request to the card
|
||||||
|
render endpoint, which triggers Playwright render + S3 upload. The
|
||||||
|
response image_url (if present) is captured and returned so callers can
|
||||||
|
include the card art in tier-up notifications.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict
|
||||||
|
Mapping of player_id -> image_url. Players whose render failed or
|
||||||
|
returned no image_url are omitted; callers should treat a missing
|
||||||
|
key as None.
|
||||||
|
"""
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
image_urls = {}
|
||||||
|
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:
|
||||||
|
result = await db_get(
|
||||||
|
f"players/{player_id}/{card_type}card/{today}/{variant}",
|
||||||
|
none_okay=True,
|
||||||
|
)
|
||||||
|
if result and isinstance(result, dict):
|
||||||
|
image_urls[player_id] = result.get("image_url")
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to trigger variant render for player %d variant %d (non-fatal)",
|
||||||
|
player_id,
|
||||||
|
variant,
|
||||||
|
)
|
||||||
|
return image_urls
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_refractor_progress_text(
|
||||||
|
evo_result: dict | None,
|
||||||
|
winning_team_id: int,
|
||||||
|
losing_team_id: int,
|
||||||
|
) -> str | None:
|
||||||
|
"""Build the Refractor Progress embed field value for the post-game summary.
|
||||||
|
|
||||||
|
Shows tier-ups that occurred this game (from the evaluate-game response)
|
||||||
|
and any cards currently close (≥80%) to their next tier on either team.
|
||||||
|
Returns None when there is nothing to show so the caller can skip the field.
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
name = tier_up.get("player_name", "Unknown")
|
||||||
|
new_tier = tier_up.get("new_tier", 0)
|
||||||
|
tier_name = TIER_NAMES.get(new_tier, f"T{new_tier}")
|
||||||
|
lines.append(f"⬆ **{name}** → {tier_name}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
close_lines = []
|
||||||
|
for team_id in (winning_team_id, losing_team_id):
|
||||||
|
data = await db_get(
|
||||||
|
"refractor/cards",
|
||||||
|
params=[("team_id", team_id), ("progress", "close"), ("limit", 5)],
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
continue
|
||||||
|
items = data if isinstance(data, list) else data.get("items", [])
|
||||||
|
for card in items:
|
||||||
|
name = card.get("player_name", "Unknown")
|
||||||
|
current_value = int(card.get("current_value", 0))
|
||||||
|
next_threshold = int(card.get("next_threshold") or 0)
|
||||||
|
if next_threshold:
|
||||||
|
pct = f"{min(current_value / next_threshold, 1.0):.0%}"
|
||||||
|
else:
|
||||||
|
pct = "100%"
|
||||||
|
close_lines.append(f"◈ {name} ({pct})")
|
||||||
|
lines.extend(close_lines[:5])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return "\n".join(lines) if lines else None
|
||||||
|
|
||||||
|
|
||||||
async def complete_game(
|
async def complete_game(
|
||||||
session: Session,
|
session: Session,
|
||||||
interaction: discord.Interaction,
|
interaction: discord.Interaction,
|
||||||
@ -4343,18 +4457,8 @@ async def complete_game(
|
|||||||
log_exception(e, msg="Error while posting game rewards")
|
log_exception(e, msg="Error while posting game rewards")
|
||||||
|
|
||||||
# Post-game refractor processing (non-blocking)
|
# Post-game refractor processing (non-blocking)
|
||||||
# WP-13: update season stats then evaluate refractor milestones for all
|
# WP-13: season stats update + refractor milestone evaluation.
|
||||||
# participating players. Wrapped in try/except so any failure here is
|
evo_result = await _run_post_game_refractor_hook(db_game["id"], interaction.channel)
|
||||||
# non-fatal — the game is already saved and refractor will catch up on the
|
|
||||||
# next evaluate call.
|
|
||||||
try:
|
|
||||||
await db_post(f"season-stats/update-game/{db_game['id']}")
|
|
||||||
evo_result = await db_post(f"refractor/evaluate-game/{db_game['id']}")
|
|
||||||
if evo_result and evo_result.get("tier_ups"):
|
|
||||||
for tier_up in evo_result["tier_ups"]:
|
|
||||||
await notify_tier_completion(interaction.channel, tier_up)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
|
|
||||||
|
|
||||||
session.delete(this_play)
|
session.delete(this_play)
|
||||||
session.commit()
|
session.commit()
|
||||||
@ -4373,6 +4477,15 @@ async def complete_game(
|
|||||||
|
|
||||||
summary_embed.add_field(name=f"{winning_team.abbrev} Rewards", value=win_reward)
|
summary_embed.add_field(name=f"{winning_team.abbrev} Rewards", value=win_reward)
|
||||||
summary_embed.add_field(name=f"{losing_team.abbrev} Rewards", value=loss_reward)
|
summary_embed.add_field(name=f"{losing_team.abbrev} Rewards", value=loss_reward)
|
||||||
|
|
||||||
|
refractor_text = await _build_refractor_progress_text(
|
||||||
|
evo_result, winning_team.id, losing_team.id
|
||||||
|
)
|
||||||
|
if refractor_text:
|
||||||
|
summary_embed.add_field(
|
||||||
|
name="Refractor Progress", value=refractor_text, inline=False
|
||||||
|
)
|
||||||
|
|
||||||
summary_embed.add_field(
|
summary_embed.add_field(
|
||||||
name="Highlights",
|
name="Highlights",
|
||||||
value=f"Please share the highlights in {get_channel(interaction, 'pd-news-ticker').mention}!",
|
value=f"Please share the highlights in {get_channel(interaction, 'pd-news-ticker').mention}!",
|
||||||
|
|||||||
@ -3,102 +3,115 @@ 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"
|
||||||
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
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
|
||||||
@ -107,22 +120,31 @@ class SelectChoicePackTeam(discord.ui.Select):
|
|||||||
|
|
||||||
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]))
|
params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
|
||||||
else:
|
else:
|
||||||
if 'Team' in pack_vals:
|
if "Team" in pack_vals:
|
||||||
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
|
params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
|
||||||
if 'Cardset' in pack_vals:
|
if "Cardset" in pack_vals:
|
||||||
cardset_id = pack_vals[pack_vals.index('Cardset') + 1]
|
cardset_id = pack_vals[pack_vals.index("Cardset") + 1]
|
||||||
params.append(('pack_cardset_id', cardset_id))
|
params.append(("pack_cardset_id", 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}")
|
||||||
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,56 +277,63 @@ 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"
|
||||||
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
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
|
||||||
@ -293,94 +342,110 @@ class SelectPaperdexTeam(discord.ui.Select):
|
|||||||
|
|
||||||
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':
|
if self.values[0] == "Pokemon - Brilliant Stars":
|
||||||
self.pack_embed.set_image(url=IMAGES['pack-pkmnbs'])
|
self.pack_embed.set_image(url=IMAGES["pack-pkmnbs"])
|
||||||
|
|
||||||
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_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
|
||||||
@ -388,14 +453,20 @@ class SelectBuyPacksTeam(discord.ui.Select):
|
|||||||
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"
|
||||||
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
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
|
||||||
@ -404,60 +475,67 @@ class SelectBuyPacksTeam(discord.ui.Select):
|
|||||||
|
|
||||||
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"
|
||||||
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
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
|
||||||
@ -467,43 +545,49 @@ class SelectUpdatePlayerTeam(discord.ui.Select):
|
|||||||
|
|
||||||
# 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):
|
||||||
|
|||||||
67
docker-compose.example.yml
Normal file
67
docker-compose.example.yml
Normal 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:
|
||||||
@ -17,14 +17,14 @@ logger = logging.getLogger("discord_app.health")
|
|||||||
class HealthServer:
|
class HealthServer:
|
||||||
"""HTTP server for health checks and metrics."""
|
"""HTTP server for health checks and metrics."""
|
||||||
|
|
||||||
def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
|
def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8081):
|
||||||
"""
|
"""
|
||||||
Initialize health server.
|
Initialize health server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
bot: Discord bot instance to monitor
|
bot: Discord bot instance to monitor
|
||||||
host: Host to bind to (default: 0.0.0.0 for container access)
|
host: Host to bind to (default: 0.0.0.0 for container access)
|
||||||
port: Port to listen on (default: 8080)
|
port: Port to listen on (default: 8081)
|
||||||
"""
|
"""
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.host = host
|
self.host = host
|
||||||
@ -148,7 +148,7 @@ class HealthServer:
|
|||||||
logger.info("Health check server stopped")
|
logger.info("Health check server stopped")
|
||||||
|
|
||||||
|
|
||||||
async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
|
async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8081):
|
||||||
"""
|
"""
|
||||||
Run health server as a background task.
|
Run health server as a background task.
|
||||||
|
|
||||||
|
|||||||
@ -120,8 +120,10 @@ async def get_card_embeds(card, include_stats=False) -> list:
|
|||||||
tier = evo_state["current_tier"]
|
tier = evo_state["current_tier"]
|
||||||
badge = TIER_BADGES.get(tier)
|
badge = TIER_BADGES.get(tier)
|
||||||
tier_badge = f"[{badge}] " if badge else ""
|
tier_badge = f"[{badge}] " if badge else ""
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logging.debug(
|
||||||
|
f"badge lookup failed for card {card.get('id')}: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"{tier_badge}{card['player']['p_name']}",
|
title=f"{tier_badge}{card['player']['p_name']}",
|
||||||
@ -337,7 +339,7 @@ async def display_cards(
|
|||||||
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
|
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
|
||||||
logger.debug("Cards sorted successfully")
|
logger.debug("Cards sorted successfully")
|
||||||
|
|
||||||
card_embeds = [await get_card_embeds(x) for x in cards]
|
card_embeds = list(await asyncio.gather(*[get_card_embeds(x) for x in cards]))
|
||||||
logger.debug(f"Created {len(card_embeds)} card embeds")
|
logger.debug(f"Created {len(card_embeds)} card embeds")
|
||||||
|
|
||||||
page_num = 0 if pack_cover is None else -1
|
page_num = 0 if pack_cover is None else -1
|
||||||
@ -1784,14 +1786,18 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
|
|||||||
|
|
||||||
pack_type_name = all_packs[0].get("pack_type", {}).get("name")
|
pack_type_name = all_packs[0].get("pack_type", {}).get("name")
|
||||||
if pack_type_name in SCOUTABLE_PACK_TYPES:
|
if pack_type_name in SCOUTABLE_PACK_TYPES:
|
||||||
for p_id in pack_ids:
|
await asyncio.gather(
|
||||||
pack_cards = [c for c in all_cards if c.get("pack_id") == p_id]
|
*[
|
||||||
if pack_cards:
|
create_scout_opportunity(
|
||||||
await create_scout_opportunity(
|
[c for c in all_cards if c.get("pack_id") == p_id],
|
||||||
pack_cards, team, pack_channel, author, context
|
team,
|
||||||
|
pack_channel,
|
||||||
|
author,
|
||||||
|
context,
|
||||||
)
|
)
|
||||||
if len(pack_ids) > 1:
|
for p_id in pack_ids
|
||||||
await asyncio.sleep(2)
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_choice_from_cards(
|
async def get_choice_from_cards(
|
||||||
|
|||||||
36
helpers/refractor_constants.py
Normal file
36
helpers/refractor_constants.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Shared Refractor Constants
|
||||||
|
|
||||||
|
Single source of truth for tier names and colors used across the refractor
|
||||||
|
system. All consumers (status view, notifications, player view) import from
|
||||||
|
here to prevent silent divergence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Human-readable display names for each tier number.
|
||||||
|
TIER_NAMES = {
|
||||||
|
0: "Base Card",
|
||||||
|
1: "Base Chrome",
|
||||||
|
2: "Refractor",
|
||||||
|
3: "Gold Refractor",
|
||||||
|
4: "Superfractor",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Embed accent colors for the /refractor status view.
|
||||||
|
# These use muted/metallic tones suited to a card-list display.
|
||||||
|
STATUS_TIER_COLORS = {
|
||||||
|
0: 0x95A5A6, # slate grey
|
||||||
|
1: 0xBDC3C7, # silver/chrome
|
||||||
|
2: 0x3498DB, # refractor blue
|
||||||
|
3: 0xF1C40F, # gold
|
||||||
|
4: 0x1ABC9C, # teal superfractor
|
||||||
|
}
|
||||||
|
|
||||||
|
# Embed accent colors for tier-up notification embeds.
|
||||||
|
# These use brighter/more celebratory tones to signal a milestone event.
|
||||||
|
# T2 is gold (not blue) to feel like an achievement unlock, not a status indicator.
|
||||||
|
NOTIF_TIER_COLORS = {
|
||||||
|
1: 0x2ECC71, # green
|
||||||
|
2: 0xF1C40F, # gold
|
||||||
|
3: 0x9B59B6, # purple
|
||||||
|
4: 0x1ABC9C, # teal (superfractor)
|
||||||
|
}
|
||||||
@ -12,35 +12,23 @@ import logging
|
|||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
|
from helpers.refractor_constants import TIER_NAMES, NOTIF_TIER_COLORS as TIER_COLORS
|
||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
# Human-readable display names for each tier number.
|
|
||||||
TIER_NAMES = {
|
|
||||||
0: "Base Card",
|
|
||||||
1: "Base Chrome",
|
|
||||||
2: "Refractor",
|
|
||||||
3: "Gold Refractor",
|
|
||||||
4: "Superfractor",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tier-specific embed colors.
|
|
||||||
TIER_COLORS = {
|
|
||||||
1: 0x2ECC71, # green
|
|
||||||
2: 0xF1C40F, # gold
|
|
||||||
3: 0x9B59B6, # purple
|
|
||||||
4: 0x1ABC9C, # teal (superfractor)
|
|
||||||
}
|
|
||||||
|
|
||||||
FOOTER_TEXT = "Paper Dynasty Refractor"
|
FOOTER_TEXT = "Paper Dynasty Refractor"
|
||||||
|
|
||||||
|
|
||||||
def build_tier_up_embed(tier_up: dict) -> discord.Embed:
|
def build_tier_up_embed(tier_up: dict, image_url: str | None = None) -> discord.Embed:
|
||||||
"""Build a Discord embed for a tier-up event.
|
"""Build a Discord embed for a tier-up event.
|
||||||
|
|
||||||
Parameters
|
Parameters
|
||||||
----------
|
----------
|
||||||
tier_up:
|
tier_up:
|
||||||
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
||||||
|
image_url:
|
||||||
|
Optional S3 URL for the newly rendered refractor card image. When
|
||||||
|
provided, the card art is shown as the embed image.
|
||||||
|
|
||||||
Returns
|
Returns
|
||||||
-------
|
-------
|
||||||
@ -63,11 +51,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!",
|
||||||
@ -77,12 +60,14 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
|
|||||||
color=color,
|
color=color,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
embed.set_image(url=image_url)
|
||||||
embed.set_footer(text=FOOTER_TEXT)
|
embed.set_footer(text=FOOTER_TEXT)
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
|
|
||||||
async def notify_tier_completion(
|
async def notify_tier_completion(
|
||||||
channel: discord.abc.Messageable, tier_up: dict
|
channel: discord.abc.Messageable, tier_up: dict, image_url: str | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a tier-up notification embed to the given channel.
|
"""Send a tier-up notification embed to the given channel.
|
||||||
|
|
||||||
@ -95,9 +80,12 @@ async def notify_tier_completion(
|
|||||||
A discord.abc.Messageable (e.g. discord.TextChannel).
|
A discord.abc.Messageable (e.g. discord.TextChannel).
|
||||||
tier_up:
|
tier_up:
|
||||||
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
||||||
|
image_url:
|
||||||
|
Optional S3 URL for the refractor card image. Passed through to
|
||||||
|
build_tier_up_embed so the card art appears in the notification.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
embed = build_tier_up_embed(tier_up)
|
embed = build_tier_up_embed(tier_up, image_url=image_url)
|
||||||
await channel.send(embed=embed)
|
await channel.send(embed=embed)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
@ -32,7 +32,7 @@ the expected bot response, and pass/fail criteria.
|
|||||||
Before running these tests, ensure the following state exists:
|
Before running these tests, ensure the following state exists:
|
||||||
|
|
||||||
### Bot State
|
### Bot State
|
||||||
- [ ] Bot is online and healthy: `GET http://sba-bots:8080/health` returns 200
|
- [ ] Bot is online and healthy: `GET http://sba-bots:8081/health` returns 200
|
||||||
- [ ] Refractor cog is loaded: check bot logs for `Loaded extension 'cogs.refractor'`
|
- [ ] Refractor cog is loaded: check bot logs for `Loaded extension 'cogs.refractor'`
|
||||||
- [ ] Test user has the `PD Players` role on the dev server
|
- [ ] Test user has the `PD Players` role on the dev server
|
||||||
|
|
||||||
@ -72,7 +72,7 @@ API layer is functional. Execute via shell or Playwright network interception.
|
|||||||
### REF-API-01: Bot health endpoint
|
### REF-API-01: Bot health endpoint
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Command** | `curl -sf http://sba-bots:8080/health` |
|
| **Command** | `curl -sf http://sba-bots:8081/health` |
|
||||||
| **Expected** | HTTP 200, body contains health status |
|
| **Expected** | HTTP 200, body contains health status |
|
||||||
| **Pass criteria** | Non-empty 200 response |
|
| **Pass criteria** | Non-empty 200 response |
|
||||||
|
|
||||||
@ -382,11 +382,11 @@ API layer is functional. Execute via shell or Playwright network interception.
|
|||||||
These tests verify that tier badges appear in card embed titles across all
|
These tests verify that tier badges appear in card embed titles across all
|
||||||
commands that display card embeds via `get_card_embeds()`.
|
commands that display card embeds via `get_card_embeds()`.
|
||||||
|
|
||||||
### REF-40: Tier badge on /card command (player lookup)
|
### REF-40: Tier badge on /player command (player lookup)
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Description** | Look up a card that has a refractor tier > 0 |
|
| **Description** | Look up a card that has a refractor tier > 0 |
|
||||||
| **Discord command** | `/card {player_name}` (use a player known to have refractor state) |
|
| **Discord command** | `/player {player_name}` (use a player known to have refractor state) |
|
||||||
| **Expected result** | Embed title is `[BC] Player Name` (or appropriate badge for their tier) |
|
| **Expected result** | Embed title is `[BC] Player Name` (or appropriate badge for their tier) |
|
||||||
| **Pass criteria** | 1. Embed title starts with the correct tier badge in brackets |
|
| **Pass criteria** | 1. Embed title starts with the correct tier badge in brackets |
|
||||||
| | 2. Player name follows the badge |
|
| | 2. Player name follows the badge |
|
||||||
@ -396,7 +396,7 @@ commands that display card embeds via `get_card_embeds()`.
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Description** | Look up a card with current_tier=0 |
|
| **Description** | Look up a card with current_tier=0 |
|
||||||
| **Discord command** | `/card {player_name}` (use a player at T0) |
|
| **Discord command** | `/player {player_name}` (use a player at T0) |
|
||||||
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
||||||
| **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` |
|
| **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` |
|
||||||
|
|
||||||
@ -404,7 +404,7 @@ commands that display card embeds via `get_card_embeds()`.
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Description** | Look up a card that has no RefractorCardState row |
|
| **Description** | Look up a card that has no RefractorCardState row |
|
||||||
| **Discord command** | `/card {player_name}` (use a player with no refractor state) |
|
| **Discord command** | `/player {player_name}` (use a player with no refractor state) |
|
||||||
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
||||||
| **Pass criteria** | 1. Title has no badge prefix |
|
| **Pass criteria** | 1. Title has no badge prefix |
|
||||||
| | 2. No error in bot logs about the refractor API call |
|
| | 2. No error in bot logs about the refractor API call |
|
||||||
@ -414,7 +414,7 @@ commands that display card embeds via `get_card_embeds()`.
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Description** | Start a card purchase for a player with refractor state |
|
| **Description** | Start a card purchase for a player with refractor state |
|
||||||
| **Discord command** | `/buy {player_name}` |
|
| **Discord command** | `/buy card-by-name {player_name}` |
|
||||||
| **Expected result** | The card embed shown during purchase confirmation includes the tier badge |
|
| **Expected result** | The card embed shown during purchase confirmation includes the tier badge |
|
||||||
| **Pass criteria** | Embed title includes tier badge if the player has refractor state |
|
| **Pass criteria** | Embed title includes tier badge if the player has refractor state |
|
||||||
| **Notes** | The buy flow uses `get_card_embeds(get_blank_team_card(...))`. Since blank team cards have no team association, the refractor lookup by card_id may 404. Verify graceful fallback. |
|
| **Notes** | The buy flow uses `get_card_embeds(get_blank_team_card(...))`. Since blank team cards have no team association, the refractor lookup by card_id may 404. Verify graceful fallback. |
|
||||||
@ -423,16 +423,16 @@ commands that display card embeds via `get_card_embeds()`.
|
|||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Description** | Open a pack and check if revealed cards show tier badges |
|
| **Description** | Open a pack and check if revealed cards show tier badges |
|
||||||
| **Discord command** | `/openpack` (or equivalent pack opening command) |
|
| **Discord command** | `/open-packs` |
|
||||||
| **Expected result** | Cards displayed via `display_cards()` -> `get_card_embeds()` show tier badges if applicable |
|
| **Expected result** | Cards displayed via `display_cards()` -> `get_card_embeds()` show tier badges if applicable |
|
||||||
| **Pass criteria** | Cards with refractor state show badges; cards without state show no badge and no error |
|
| **Pass criteria** | Cards with refractor state show badges; cards without state show no badge and no error |
|
||||||
|
|
||||||
### REF-45: Badge consistency between /card and /refractor status
|
### REF-45: Badge consistency between /player and /refractor status
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Description** | Compare the badge shown for the same player in both views |
|
| **Description** | Compare the badge shown for the same player in both views |
|
||||||
| **Discord command** | Run both `/card {player}` and `/refractor status` for the same player |
|
| **Discord command** | Run both `/player {player}` and `/refractor status` for the same player |
|
||||||
| **Expected result** | The badge in the `/card` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` |
|
| **Expected result** | The badge in the `/player` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` |
|
||||||
| **Pass criteria** | Tier badge letter matches: T1=[BC], T2=[R], T3=[GR], T4=[SF] |
|
| **Pass criteria** | Tier badge letter matches: T1=[BC], T2=[R], T3=[GR], T4=[SF] |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -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 |
|
||||||
@ -570,11 +568,11 @@ REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
|
|||||||
These tests verify that tier badges appear (or correctly do not appear) in all
|
These tests verify that tier badges appear (or correctly do not appear) in all
|
||||||
commands that display card information.
|
commands that display card information.
|
||||||
|
|
||||||
### REF-70: /roster command -- cards show tier badges
|
### REF-70: /team command -- cards show tier badges
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Discord command** | `/roster` or equivalent command that lists team cards |
|
| **Discord command** | `/team` |
|
||||||
| **Expected result** | If roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
|
| **Expected result** | If team/roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
|
||||||
| **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
|
| **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
|
||||||
|
|
||||||
### REF-71: /show-card defense (in-game) -- no badge expected
|
### REF-71: /show-card defense (in-game) -- no badge expected
|
||||||
@ -586,12 +584,13 @@ commands that display card information.
|
|||||||
| **Pass criteria** | This is EXPECTED behavior -- in-game card display does not fetch refractor state |
|
| **Pass criteria** | This is EXPECTED behavior -- in-game card display does not fetch refractor state |
|
||||||
| **Notes** | This is a known limitation, not a bug. Document for future consideration. |
|
| **Notes** | This is a known limitation, not a bug. Document for future consideration. |
|
||||||
|
|
||||||
### REF-72: /scouting view -- badge on scouted cards
|
### REF-72: /scout-tokens -- no badge expected
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Discord command** | `/scout {player_name}` (if the scouting cog uses get_card_embeds) |
|
| **Discord command** | `/scout-tokens` |
|
||||||
| **Expected result** | If the scouting view calls get_card_embeds, badges should appear |
|
| **Expected result** | Scout tokens display does not show card embeds, so no badges are expected |
|
||||||
| **Pass criteria** | Verify whether scouting uses get_card_embeds or its own embed builder |
|
| **Pass criteria** | Command responds with token count; no card embeds or badges displayed |
|
||||||
|
| **Notes** | `/scout-tokens` shows remaining daily tokens, not card embeds. Badge propagation is not applicable here. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -665,16 +664,38 @@ design but means tier-up notifications are best-effort.
|
|||||||
|
|
||||||
Run order for Playwright automation:
|
Run order for Playwright automation:
|
||||||
|
|
||||||
1. [ ] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
1. [x] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
||||||
2. [ ] Execute REF-01 through REF-06 (basic /refractor status)
|
- Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
|
||||||
3. [ ] Execute REF-10 through REF-19 (filters)
|
- Tested 2026-04-07: REF-API-02 (tracks ✓), REF-API-04 (404 nonexistent ✓), REF-API-05 (evolution removed ✓), REF-API-08 (tier filter ✓), REF-API-09 (progress=close ✓)
|
||||||
4. [ ] Execute REF-20 through REF-23 (pagination)
|
- REF-API-01 (bot health) not tested via API (port conflict with adminer on localhost:8080), but bot confirmed healthy via logs
|
||||||
5. [ ] Execute REF-30 through REF-34 (edge cases)
|
2. [x] Execute REF-01 through REF-06 (basic /refractor status)
|
||||||
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds)
|
- Tested 2026-03-25: REF-01 (embed appears ✓), REF-02 (batter entry format ✓), REF-05 (tier badges [BC] ✓)
|
||||||
|
- Tested 2026-04-07: REF-03 (SP format ✓), REF-04 (RP format ✓)
|
||||||
|
- Bugs found and fixed (2026-03-25): wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
|
||||||
|
- Note: formula labels (IP+K, PA+TB x 2) from test spec are not rendered; format is value/threshold (pct%) only
|
||||||
|
- REF-06 (fully evolved) not testable — no T4 cards exist in test data
|
||||||
|
3. [x] Execute REF-10 through REF-19 (filters)
|
||||||
|
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
|
||||||
|
- Tested 2026-04-07: REF-11 (sp ✓), REF-12 (rp ✓), REF-13 (tier=0 ✓), REF-14 (tier=1 ✓), REF-15 (tier=4 empty ✓), REF-16 (progress=close ✓), REF-17 (batter+T1 combined ✓), REF-18 (T4+close empty ✓)
|
||||||
|
- Choice dropdown menus added for all filter params (PR #126)
|
||||||
|
- REF-19 (season filter): N/A — season param not implemented in the slash command
|
||||||
|
4. [x] Execute REF-20 through REF-23 (pagination)
|
||||||
|
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
|
||||||
|
- Tested 2026-04-07: REF-21 (page 2 ✓), REF-22 (page=999 clamps to last page ✓ — fixed in discord#141/#142), REF-23 (page 0 clamps to 1 ✓), Prev/Next buttons (✓)
|
||||||
|
5. [x] Execute REF-30 through REF-34 (edge cases)
|
||||||
|
- Tested 2026-04-07: REF-34 (page=-5 clamps to 1 ✓)
|
||||||
|
- REF-30 (no team), REF-31 (no refractor data), REF-32 (invalid card_type), REF-33 (negative tier): not tested — require alt account or manual API state manipulation
|
||||||
|
6. [N/A] Execute REF-40 through REF-45 (tier badges on card embeds)
|
||||||
|
- **Design gap**: `get_card_embeds()` looks up refractor state via `card['id']`, but all user-facing commands (`/player`, `/buy`) use `get_blank_team_card()` which has no `id` field. The `except Exception: pass` silently swallows the KeyError. Badges never appear outside `/refractor status`. `/open-packs` uses real card objects but results are random. No command currently surfaces badges on card embeds in practice.
|
||||||
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
|
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
|
||||||
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
|
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
|
||||||
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation)
|
9. [N/A] Execute REF-70 through REF-72 (cross-command badge propagation)
|
||||||
10. [ ] Execute REF-80 through REF-82 (force-evaluate API)
|
- REF-70: `/team` shows team overview, not card embeds — badges not applicable
|
||||||
|
- REF-71: `/show-card defense` only works during active games — expected no badge (by design)
|
||||||
|
- REF-72: `/scout-tokens` shows token count, not card embeds — badges not applicable
|
||||||
|
10. [x] Execute REF-80 through REF-82 (force-evaluate API)
|
||||||
|
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
|
||||||
|
- Tested 2026-04-07: REF-81 (no stats → 404 ✓), REF-82 (nonexistent card → 404 ✓)
|
||||||
|
|
||||||
### Approximate Time Estimates
|
### Approximate Time Estimates
|
||||||
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
|
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
|
||||||
|
|||||||
@ -14,7 +14,7 @@ STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/ap
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=== Discord Bot ==="
|
echo "=== Discord Bot ==="
|
||||||
# Health check
|
# Health check
|
||||||
curl -sf http://sba-bots:8080/health >/dev/null 2>&1 && echo "PASS: bot health OK" || echo "FAIL: bot health endpoint"
|
curl -sf http://sba-bots:8081/health >/dev/null 2>&1 && echo "PASS: bot health OK" || echo "FAIL: bot health endpoint"
|
||||||
|
|
||||||
# Recent refractor activity in logs
|
# Recent refractor activity in logs
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -251,78 +251,36 @@ class TestEmbedColorUnchanged:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestTierBadgesFormatConsistency:
|
class TestTierSymbolsCompleteness:
|
||||||
"""
|
"""
|
||||||
T1-7: Assert that TIER_BADGES in cogs.refractor (format: "[BC]") and
|
T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
|
||||||
helpers.main (format: "BC") are consistent — wrapping the helpers.main
|
and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
|
||||||
value in brackets must produce the cogs.refractor value.
|
|
||||||
|
|
||||||
Why: The two modules intentionally use different formats for different
|
Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
|
||||||
rendering contexts:
|
while card embed titles use helpers.main TIER_BADGES in bracket format.
|
||||||
- helpers.main uses bare strings ("BC") because get_card_embeds
|
Both must cover the full tier range for their respective contexts.
|
||||||
wraps them in brackets when building the embed title.
|
|
||||||
- cogs.refractor uses bracket strings ("[BC]") because
|
|
||||||
format_refractor_entry inlines them directly into the display string.
|
|
||||||
|
|
||||||
If either definition is updated without updating the other, embed titles
|
|
||||||
and /refractor status output will display inconsistent badges. This test
|
|
||||||
acts as an explicit contract check so any future change to either dict
|
|
||||||
is immediately surfaced here.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_cogs_badge_equals_bracketed_helpers_badge_for_all_tiers(self):
|
def test_tier_symbols_covers_all_tiers(self):
|
||||||
"""
|
"""TIER_SYMBOLS must have entries for T0 through T4."""
|
||||||
For every tier in cogs.refractor TIER_BADGES, wrapping the
|
from cogs.refractor import TIER_SYMBOLS
|
||||||
helpers.main TIER_BADGES value in square brackets must produce
|
|
||||||
the cogs.refractor value.
|
|
||||||
|
|
||||||
i.e., f"[{helpers_badge}]" == cog_badge for all tiers.
|
for tier in range(5):
|
||||||
"""
|
assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
|
||||||
from cogs.refractor import TIER_BADGES as cog_badges
|
|
||||||
from helpers.main import TIER_BADGES as helpers_badges
|
|
||||||
|
|
||||||
assert set(cog_badges.keys()) == set(helpers_badges.keys()), (
|
def test_tier_badges_covers_evolved_tiers(self):
|
||||||
"TIER_BADGES key sets differ between cogs.refractor and helpers.main. "
|
"""helpers.main TIER_BADGES must have entries for T1 through T4."""
|
||||||
f"cogs keys: {set(cog_badges.keys())}, helpers keys: {set(helpers_badges.keys())}"
|
from helpers.main import TIER_BADGES
|
||||||
)
|
|
||||||
|
|
||||||
for tier, cog_badge in cog_badges.items():
|
for tier in range(1, 5):
|
||||||
helpers_badge = helpers_badges[tier]
|
assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
|
||||||
expected = f"[{helpers_badge}]"
|
|
||||||
assert cog_badge == expected, (
|
|
||||||
f"Tier {tier} badge mismatch: "
|
|
||||||
f"cogs.refractor={cog_badge!r}, "
|
|
||||||
f"helpers.main={helpers_badge!r} "
|
|
||||||
f"(expected cog badge to equal '[{helpers_badge}]')"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_t1_badge_relationship(self):
|
def test_tier_symbols_are_unique(self):
|
||||||
"""T1: helpers.main 'BC' wrapped in brackets equals cogs.refractor '[BC]'."""
|
"""Each tier must have a distinct symbol."""
|
||||||
from cogs.refractor import TIER_BADGES as cog_badges
|
from cogs.refractor import TIER_SYMBOLS
|
||||||
from helpers.main import TIER_BADGES as helpers_badges
|
|
||||||
|
|
||||||
assert f"[{helpers_badges[1]}]" == cog_badges[1]
|
values = list(TIER_SYMBOLS.values())
|
||||||
|
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
|
||||||
def test_t2_badge_relationship(self):
|
|
||||||
"""T2: helpers.main 'R' wrapped in brackets equals cogs.refractor '[R]'."""
|
|
||||||
from cogs.refractor import TIER_BADGES as cog_badges
|
|
||||||
from helpers.main import TIER_BADGES as helpers_badges
|
|
||||||
|
|
||||||
assert f"[{helpers_badges[2]}]" == cog_badges[2]
|
|
||||||
|
|
||||||
def test_t3_badge_relationship(self):
|
|
||||||
"""T3: helpers.main 'GR' wrapped in brackets equals cogs.refractor '[GR]'."""
|
|
||||||
from cogs.refractor import TIER_BADGES as cog_badges
|
|
||||||
from helpers.main import TIER_BADGES as helpers_badges
|
|
||||||
|
|
||||||
assert f"[{helpers_badges[3]}]" == cog_badges[3]
|
|
||||||
|
|
||||||
def test_t4_badge_relationship(self):
|
|
||||||
"""T4: helpers.main 'SF' wrapped in brackets equals cogs.refractor '[SF]'."""
|
|
||||||
from cogs.refractor import TIER_BADGES as cog_badges
|
|
||||||
from helpers.main import TIER_BADGES as helpers_badges
|
|
||||||
|
|
||||||
assert f"[{helpers_badges[4]}]" == cog_badges[4]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
141
tests/test_player_refractor_view.py
Normal file
141
tests/test_player_refractor_view.py
Normal 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
|
||||||
387
tests/test_post_game_refractor_hook.py
Normal file
387
tests/test_post_game_refractor_hook.py
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
"""
|
||||||
|
Mock-based integration tests for the post-game refractor hook.
|
||||||
|
|
||||||
|
Tests _run_post_game_refractor_hook() which orchestrates:
|
||||||
|
1. POST season-stats/update-game/{game_id} — update player season stats
|
||||||
|
2. POST refractor/evaluate-game/{game_id} — evaluate refractor milestones
|
||||||
|
3. _trigger_variant_renders() with the full tier_ups list (returns image_url map)
|
||||||
|
4. notify_tier_completion() once per tier-up, with image_url from render
|
||||||
|
|
||||||
|
The hook is wrapped in try/except so failures are non-fatal — the game
|
||||||
|
result is already persisted before this block runs. These tests cover the
|
||||||
|
orchestration logic (REF-50+ scenarios) without requiring a live game.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
from command_logic.logic_gameplay import _run_post_game_refractor_hook
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel(channel_id: int = 999) -> MagicMock:
|
||||||
|
ch = MagicMock()
|
||||||
|
ch.id = channel_id
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoint ordering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEndpointOrder:
|
||||||
|
"""Season-stats must be POSTed before refractor evaluate."""
|
||||||
|
|
||||||
|
async def test_calls_both_endpoints(self):
|
||||||
|
"""Both POST endpoints are called for every game completion."""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||||
|
) as mock_post:
|
||||||
|
mock_post.return_value = {}
|
||||||
|
await _run_post_game_refractor_hook(42, _make_channel())
|
||||||
|
|
||||||
|
assert mock_post.call_count == 2
|
||||||
|
|
||||||
|
async def test_season_stats_before_evaluate(self):
|
||||||
|
"""Season stats must be updated before refractor evaluate runs.
|
||||||
|
|
||||||
|
player_season_stats must exist before the refractor engine reads them
|
||||||
|
for milestone evaluation — wrong order yields stale data.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||||
|
) as mock_post:
|
||||||
|
mock_post.return_value = {}
|
||||||
|
await _run_post_game_refractor_hook(42, _make_channel())
|
||||||
|
|
||||||
|
calls = mock_post.call_args_list
|
||||||
|
assert calls[0] == call("season-stats/update-game/42")
|
||||||
|
assert calls[1] == call("refractor/evaluate-game/42")
|
||||||
|
|
||||||
|
async def test_game_id_interpolated_correctly(self):
|
||||||
|
"""The game ID is interpolated into both endpoint URLs."""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||||
|
) as mock_post:
|
||||||
|
mock_post.return_value = {}
|
||||||
|
await _run_post_game_refractor_hook(99, _make_channel())
|
||||||
|
|
||||||
|
urls = [c.args[0] for c in mock_post.call_args_list]
|
||||||
|
assert "season-stats/update-game/99" in urls
|
||||||
|
assert "refractor/evaluate-game/99" in urls
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tier-up notifications
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierUpNotifications:
|
||||||
|
"""notify_tier_completion is called once per tier-up in the API response."""
|
||||||
|
|
||||||
|
async def test_notifies_for_each_tier_up(self):
|
||||||
|
"""Each tier_up dict is forwarded to notify_tier_completion."""
|
||||||
|
tier_ups = [
|
||||||
|
{
|
||||||
|
"player_id": 101,
|
||||||
|
"player_name": "Mike Trout",
|
||||||
|
"old_tier": 0,
|
||||||
|
"new_tier": 1,
|
||||||
|
"current_value": 30.0,
|
||||||
|
"track_name": "Batter Track",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player_id": 202,
|
||||||
|
"player_name": "Shohei Ohtani",
|
||||||
|
"old_tier": 1,
|
||||||
|
"new_tier": 2,
|
||||||
|
"current_value": 60.0,
|
||||||
|
"track_name": "Pitcher Track",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": tier_ups}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
channel = _make_channel()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify,
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(99, channel)
|
||||||
|
|
||||||
|
assert mock_notify.call_count == 2
|
||||||
|
forwarded = [c.args[1] for c in mock_notify.call_args_list]
|
||||||
|
assert tier_ups[0] in forwarded
|
||||||
|
assert tier_ups[1] in forwarded
|
||||||
|
|
||||||
|
async def test_channel_passed_to_notify(self):
|
||||||
|
"""notify_tier_completion receives the channel from complete_game."""
|
||||||
|
tier_up = {
|
||||||
|
"player_id": 1,
|
||||||
|
"player_name": "Mike Trout",
|
||||||
|
"old_tier": 0,
|
||||||
|
"new_tier": 1,
|
||||||
|
"current_value": 30.0,
|
||||||
|
"track_name": "Batter Track",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": [tier_up]}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
channel = _make_channel()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify,
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(1, channel)
|
||||||
|
|
||||||
|
mock_notify.assert_called_once_with(channel, tier_up, image_url=None)
|
||||||
|
|
||||||
|
async def test_no_notify_when_empty_tier_ups(self):
|
||||||
|
"""No notifications sent when evaluate returns an empty tier_ups list."""
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": []}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify,
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(55, _make_channel())
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
async def test_no_notify_when_tier_ups_key_absent(self):
|
||||||
|
"""No notifications when evaluate response has no tier_ups key."""
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify,
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(55, _make_channel())
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Variant render triggers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestVariantRenderTriggers:
|
||||||
|
"""_trigger_variant_renders receives the full tier_ups list."""
|
||||||
|
|
||||||
|
async def test_trigger_renders_called_with_all_tier_ups(self):
|
||||||
|
"""_trigger_variant_renders is called once with the complete tier_ups list."""
|
||||||
|
tier_ups = [
|
||||||
|
{"player_id": 101, "variant_created": 7, "track_name": "Batter"},
|
||||||
|
{"player_id": 202, "variant_created": 3, "track_name": "Pitcher"},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": tier_ups}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={},
|
||||||
|
) as mock_render,
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(42, _make_channel())
|
||||||
|
|
||||||
|
mock_render.assert_called_once_with(tier_ups)
|
||||||
|
|
||||||
|
async def test_no_trigger_when_no_tier_ups(self):
|
||||||
|
"""_trigger_variant_renders is not called when tier_ups is empty."""
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": []}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_render,
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(42, _make_channel())
|
||||||
|
|
||||||
|
mock_render.assert_not_called()
|
||||||
|
|
||||||
|
async def test_render_before_notification(self):
|
||||||
|
"""_trigger_variant_renders is called before notify_tier_completion.
|
||||||
|
|
||||||
|
Renders run first so that image URLs are available to include in the
|
||||||
|
tier-up notification embed. The player sees the card art immediately
|
||||||
|
rather than receiving a text-only notification.
|
||||||
|
"""
|
||||||
|
call_order = []
|
||||||
|
tier_up = {"player_id": 1, "variant_created": 5, "track_name": "Batter"}
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": [tier_up]}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def fake_notify(ch, tu, image_url=None):
|
||||||
|
call_order.append("notify")
|
||||||
|
|
||||||
|
async def fake_render(tier_ups):
|
||||||
|
call_order.append("render")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
side_effect=fake_notify,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
side_effect=fake_render,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(1, _make_channel())
|
||||||
|
|
||||||
|
assert call_order == ["render", "notify"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Non-fatal error handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonFatalErrors:
|
||||||
|
"""Hook failures must never propagate to the caller."""
|
||||||
|
|
||||||
|
async def test_nonfatal_when_season_stats_raises(self):
|
||||||
|
"""Exception from season-stats update does not propagate.
|
||||||
|
|
||||||
|
The game is already saved — refractor failure must not interrupt
|
||||||
|
the completion flow or show an error to the user.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("stats API down"),
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(7, _make_channel())
|
||||||
|
|
||||||
|
async def test_nonfatal_when_evaluate_game_raises(self):
|
||||||
|
"""Exception from refractor evaluate does not propagate."""
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
raise Exception("refractor API unavailable")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(7, _make_channel())
|
||||||
|
|
||||||
|
async def test_nonfatal_when_evaluate_returns_none(self):
|
||||||
|
"""None response from evaluate-game does not raise or notify."""
|
||||||
|
|
||||||
|
async def fake_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return None
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=fake_post,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify,
|
||||||
|
):
|
||||||
|
await _run_post_game_refractor_hook(7, _make_channel())
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
65
tests/test_post_game_render.py
Normal file
65
tests/test_post_game_render.py
Normal 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()
|
||||||
@ -29,7 +29,7 @@ from cogs.refractor import (
|
|||||||
apply_close_filter,
|
apply_close_filter,
|
||||||
paginate,
|
paginate,
|
||||||
TIER_NAMES,
|
TIER_NAMES,
|
||||||
TIER_BADGES,
|
TIER_SYMBOLS,
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -40,12 +40,12 @@ from cogs.refractor import (
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def batter_state():
|
def batter_state():
|
||||||
"""A mid-progress batter card state."""
|
"""A mid-progress batter card state (API response shape)."""
|
||||||
return {
|
return {
|
||||||
"player_name": "Mike Trout",
|
"player_name": "Mike Trout",
|
||||||
"card_type": "batter",
|
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
||||||
"current_tier": 1,
|
"current_tier": 1,
|
||||||
"formula_value": 120,
|
"current_value": 120,
|
||||||
"next_threshold": 149,
|
"next_threshold": 149,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,9 +55,9 @@ def evolved_state():
|
|||||||
"""A fully evolved card state (T4)."""
|
"""A fully evolved card state (T4)."""
|
||||||
return {
|
return {
|
||||||
"player_name": "Shohei Ohtani",
|
"player_name": "Shohei Ohtani",
|
||||||
"card_type": "batter",
|
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
||||||
"current_tier": 4,
|
"current_tier": 4,
|
||||||
"formula_value": 300,
|
"current_value": 300,
|
||||||
"next_threshold": None,
|
"next_threshold": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,9 +67,9 @@ def sp_state():
|
|||||||
"""A starting pitcher card state at T2."""
|
"""A starting pitcher card state at T2."""
|
||||||
return {
|
return {
|
||||||
"player_name": "Sandy Alcantara",
|
"player_name": "Sandy Alcantara",
|
||||||
"card_type": "sp",
|
"track": {"card_type": "sp", "formula": "ip + k"},
|
||||||
"current_tier": 2,
|
"current_tier": 2,
|
||||||
"formula_value": 95,
|
"current_value": 95,
|
||||||
"next_threshold": 120,
|
"next_threshold": 120,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,38 +84,44 @@ class TestRenderProgressBar:
|
|||||||
Tests for render_progress_bar().
|
Tests for render_progress_bar().
|
||||||
|
|
||||||
Verifies width, fill character, empty character, boundary conditions,
|
Verifies width, fill character, empty character, boundary conditions,
|
||||||
and clamping when current exceeds threshold.
|
and clamping when current exceeds threshold. Default width is 12.
|
||||||
|
Uses Unicode block chars: ▰ (filled) and ▱ (empty).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_empty_bar(self):
|
def test_empty_bar(self):
|
||||||
"""current=0 → all dashes."""
|
"""current=0 → all empty blocks."""
|
||||||
assert render_progress_bar(0, 100) == "[----------]"
|
assert render_progress_bar(0, 100) == "▱" * 12
|
||||||
|
|
||||||
def test_full_bar(self):
|
def test_full_bar(self):
|
||||||
"""current == threshold → all equals."""
|
"""current == threshold → all filled blocks."""
|
||||||
assert render_progress_bar(100, 100) == "[==========]"
|
assert render_progress_bar(100, 100) == "▰" * 12
|
||||||
|
|
||||||
def test_partial_fill(self):
|
def test_partial_fill(self):
|
||||||
"""120/149 ≈ 80.5% → 8 filled of 10."""
|
"""120/149 ≈ 80.5% → ~10 filled of 12."""
|
||||||
bar = render_progress_bar(120, 149)
|
bar = render_progress_bar(120, 149)
|
||||||
assert bar == "[========--]"
|
filled = bar.count("▰")
|
||||||
|
empty = bar.count("▱")
|
||||||
|
assert filled + empty == 12
|
||||||
|
assert filled == 10 # round(0.805 * 12) = 10
|
||||||
|
|
||||||
def test_half_fill(self):
|
def test_half_fill(self):
|
||||||
"""50/100 = 50% → 5 filled."""
|
"""50/100 = 50% → 6 filled."""
|
||||||
assert render_progress_bar(50, 100) == "[=====-----]"
|
bar = render_progress_bar(50, 100)
|
||||||
|
assert bar.count("▰") == 6
|
||||||
|
assert bar.count("▱") == 6
|
||||||
|
|
||||||
def test_over_threshold_clamps_to_full(self):
|
def test_over_threshold_clamps_to_full(self):
|
||||||
"""current > threshold should not overflow the bar."""
|
"""current > threshold should not overflow the bar."""
|
||||||
assert render_progress_bar(200, 100) == "[==========]"
|
assert render_progress_bar(200, 100) == "▰" * 12
|
||||||
|
|
||||||
def test_zero_threshold_returns_full_bar(self):
|
def test_zero_threshold_returns_full_bar(self):
|
||||||
"""threshold=0 avoids division by zero and returns full bar."""
|
"""threshold=0 avoids division by zero and returns full bar."""
|
||||||
assert render_progress_bar(0, 0) == "[==========]"
|
assert render_progress_bar(0, 0) == "▰" * 12
|
||||||
|
|
||||||
def test_custom_width(self):
|
def test_custom_width(self):
|
||||||
"""Width parameter controls bar length."""
|
"""Width parameter controls bar length."""
|
||||||
bar = render_progress_bar(5, 10, width=4)
|
bar = render_progress_bar(5, 10, width=4)
|
||||||
assert bar == "[==--]"
|
assert bar == "▰▰▱▱"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -140,44 +146,29 @@ class TestFormatRefractorEntry:
|
|||||||
def test_tier_label_in_output(self, batter_state):
|
def test_tier_label_in_output(self, batter_state):
|
||||||
"""Current tier name (Base Chrome for T1) appears in output."""
|
"""Current tier name (Base Chrome for T1) appears in output."""
|
||||||
result = format_refractor_entry(batter_state)
|
result = format_refractor_entry(batter_state)
|
||||||
assert "(Base Chrome)" in result
|
assert "Base Chrome" in result
|
||||||
|
|
||||||
def test_progress_values_in_output(self, batter_state):
|
def test_progress_values_in_output(self, batter_state):
|
||||||
"""current/threshold values appear in output."""
|
"""current/threshold values appear in output."""
|
||||||
result = format_refractor_entry(batter_state)
|
result = format_refractor_entry(batter_state)
|
||||||
assert "120/149" in result
|
assert "120/149" in result
|
||||||
|
|
||||||
def test_formula_label_batter(self, batter_state):
|
def test_percentage_in_output(self, batter_state):
|
||||||
"""Batter formula label PA+TB×2 appears in output."""
|
"""Percentage appears in parentheses in output."""
|
||||||
result = format_refractor_entry(batter_state)
|
result = format_refractor_entry(batter_state)
|
||||||
assert "PA+TB×2" in result
|
assert "(80%)" in result or "(81%)" in result
|
||||||
|
|
||||||
def test_tier_progression_arrow(self, batter_state):
|
|
||||||
"""T1 → T2 arrow progression appears for non-evolved cards."""
|
|
||||||
result = format_refractor_entry(batter_state)
|
|
||||||
assert "T1 → T2" in result
|
|
||||||
|
|
||||||
def test_sp_formula_label(self, sp_state):
|
|
||||||
"""SP formula label IP+K appears for starting pitchers."""
|
|
||||||
result = format_refractor_entry(sp_state)
|
|
||||||
assert "IP+K" in result
|
|
||||||
|
|
||||||
def test_fully_evolved_no_threshold(self, evolved_state):
|
def test_fully_evolved_no_threshold(self, evolved_state):
|
||||||
"""T4 card with next_threshold=None shows FULLY EVOLVED."""
|
"""T4 card with next_threshold=None shows MAX."""
|
||||||
result = format_refractor_entry(evolved_state)
|
result = format_refractor_entry(evolved_state)
|
||||||
assert "FULLY EVOLVED" in result
|
assert "`MAX`" in result
|
||||||
|
|
||||||
def test_fully_evolved_by_tier(self, batter_state):
|
def test_fully_evolved_by_tier(self, batter_state):
|
||||||
"""current_tier=4 triggers fully evolved display even with a threshold."""
|
"""current_tier=4 triggers fully evolved display even with a threshold."""
|
||||||
batter_state["current_tier"] = 4
|
batter_state["current_tier"] = 4
|
||||||
batter_state["next_threshold"] = 200
|
batter_state["next_threshold"] = 200
|
||||||
result = format_refractor_entry(batter_state)
|
result = format_refractor_entry(batter_state)
|
||||||
assert "FULLY EVOLVED" in result
|
assert "`MAX`" in result
|
||||||
|
|
||||||
def test_fully_evolved_no_arrow(self, evolved_state):
|
|
||||||
"""Fully evolved cards don't show a tier arrow."""
|
|
||||||
result = format_refractor_entry(evolved_state)
|
|
||||||
assert "→" not in result
|
|
||||||
|
|
||||||
def test_two_line_output(self, batter_state):
|
def test_two_line_output(self, batter_state):
|
||||||
"""Output always has exactly two lines (name line + bar line)."""
|
"""Output always has exactly two lines (name line + bar line)."""
|
||||||
@ -191,69 +182,66 @@ class TestFormatRefractorEntry:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestTierBadges:
|
class TestTierSymbols:
|
||||||
"""
|
"""
|
||||||
Verify TIER_BADGES values and that format_refractor_entry prepends badges
|
Verify TIER_SYMBOLS values and that format_refractor_entry prepends
|
||||||
correctly for T1-T4. T0 cards should have no badge prefix.
|
the correct label for each tier. Labels use short readable text (T0-T4).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_t1_badge_value(self):
|
def test_t0_symbol(self):
|
||||||
"""T1 badge is [BC] (Base Chrome)."""
|
"""T0 label is empty (base cards get no prefix)."""
|
||||||
assert TIER_BADGES[1] == "[BC]"
|
assert TIER_SYMBOLS[0] == "Base"
|
||||||
|
|
||||||
def test_t2_badge_value(self):
|
def test_t1_symbol(self):
|
||||||
"""T2 badge is [R] (Refractor)."""
|
"""T1 label is 'T1'."""
|
||||||
assert TIER_BADGES[2] == "[R]"
|
assert TIER_SYMBOLS[1] == "T1"
|
||||||
|
|
||||||
def test_t3_badge_value(self):
|
def test_t2_symbol(self):
|
||||||
"""T3 badge is [GR] (Gold Refractor)."""
|
"""T2 label is 'T2'."""
|
||||||
assert TIER_BADGES[3] == "[GR]"
|
assert TIER_SYMBOLS[2] == "T2"
|
||||||
|
|
||||||
def test_t4_badge_value(self):
|
def test_t3_symbol(self):
|
||||||
"""T4 badge is [SF] (Superfractor)."""
|
"""T3 label is 'T3'."""
|
||||||
assert TIER_BADGES[4] == "[SF]"
|
assert TIER_SYMBOLS[3] == "T3"
|
||||||
|
|
||||||
def test_t0_no_badge(self):
|
def test_t4_symbol(self):
|
||||||
"""T0 has no badge entry in TIER_BADGES."""
|
"""T4 label is 'T4★'."""
|
||||||
assert 0 not in TIER_BADGES
|
assert TIER_SYMBOLS[4] == "T4★"
|
||||||
|
|
||||||
def test_format_entry_t1_badge_present(self, batter_state):
|
def test_format_entry_t1_suffix_tag(self, batter_state):
|
||||||
"""format_refractor_entry prepends [BC] badge for T1 cards."""
|
"""T1 cards show [T1] suffix tag after the tier name."""
|
||||||
result = format_refractor_entry(batter_state)
|
result = format_refractor_entry(batter_state)
|
||||||
assert "[BC]" in result
|
assert "[T1]" in result
|
||||||
|
|
||||||
def test_format_entry_t2_badge_present(self, sp_state):
|
def test_format_entry_t2_suffix_tag(self, sp_state):
|
||||||
"""format_refractor_entry prepends [R] badge for T2 cards."""
|
"""T2 cards show [T2] suffix tag."""
|
||||||
result = format_refractor_entry(sp_state)
|
result = format_refractor_entry(sp_state)
|
||||||
assert "[R]" in result
|
assert "[T2]" in result
|
||||||
|
|
||||||
def test_format_entry_t4_badge_present(self, evolved_state):
|
def test_format_entry_t4_suffix_tag(self, evolved_state):
|
||||||
"""format_refractor_entry prepends [SF] badge for T4 cards."""
|
"""T4 cards show [T4★] suffix tag."""
|
||||||
result = format_refractor_entry(evolved_state)
|
result = format_refractor_entry(evolved_state)
|
||||||
assert "[SF]" in result
|
assert "[T4★]" in result
|
||||||
|
|
||||||
def test_format_entry_t0_no_badge(self):
|
def test_format_entry_t0_name_only(self):
|
||||||
"""format_refractor_entry does not prepend any badge for T0 cards."""
|
"""T0 cards show just the bold name, no tier suffix."""
|
||||||
state = {
|
state = {
|
||||||
"player_name": "Rookie Player",
|
"player_name": "Rookie Player",
|
||||||
"card_type": "batter",
|
|
||||||
"current_tier": 0,
|
"current_tier": 0,
|
||||||
"formula_value": 10,
|
"current_value": 10,
|
||||||
"next_threshold": 50,
|
"next_threshold": 50,
|
||||||
}
|
}
|
||||||
result = format_refractor_entry(state)
|
result = format_refractor_entry(state)
|
||||||
assert "[BC]" not in result
|
first_line = result.split("\n")[0]
|
||||||
assert "[R]" not in result
|
assert first_line == "**Rookie Player**"
|
||||||
assert "[GR]" not in result
|
|
||||||
assert "[SF]" not in result
|
|
||||||
|
|
||||||
def test_format_entry_badge_before_name(self, batter_state):
|
def test_format_entry_tag_after_name(self, batter_state):
|
||||||
"""Badge appears before the player name in the bold section."""
|
"""Tag appears after the player name in the first line."""
|
||||||
result = format_refractor_entry(batter_state)
|
result = format_refractor_entry(batter_state)
|
||||||
first_line = result.split("\n")[0]
|
first_line = result.split("\n")[0]
|
||||||
badge_pos = first_line.find("[BC]")
|
|
||||||
name_pos = first_line.find("Mike Trout")
|
name_pos = first_line.find("Mike Trout")
|
||||||
assert badge_pos < name_pos
|
tag_pos = first_line.find("[T1]")
|
||||||
|
assert name_pos < tag_pos
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -271,34 +259,34 @@ class TestApplyCloseFilter:
|
|||||||
|
|
||||||
def test_close_card_included(self):
|
def test_close_card_included(self):
|
||||||
"""Card at exactly 80% is included."""
|
"""Card at exactly 80% is included."""
|
||||||
state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100}
|
state = {"current_tier": 1, "current_value": 80, "next_threshold": 100}
|
||||||
assert apply_close_filter([state]) == [state]
|
assert apply_close_filter([state]) == [state]
|
||||||
|
|
||||||
def test_above_80_percent_included(self):
|
def test_above_80_percent_included(self):
|
||||||
"""Card above 80% is included."""
|
"""Card above 80% is included."""
|
||||||
state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100}
|
state = {"current_tier": 0, "current_value": 95, "next_threshold": 100}
|
||||||
assert apply_close_filter([state]) == [state]
|
assert apply_close_filter([state]) == [state]
|
||||||
|
|
||||||
def test_below_80_percent_excluded(self):
|
def test_below_80_percent_excluded(self):
|
||||||
"""Card below 80% threshold is excluded."""
|
"""Card below 80% threshold is excluded."""
|
||||||
state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100}
|
state = {"current_tier": 1, "current_value": 79, "next_threshold": 100}
|
||||||
assert apply_close_filter([state]) == []
|
assert apply_close_filter([state]) == []
|
||||||
|
|
||||||
def test_fully_evolved_excluded(self):
|
def test_fully_evolved_excluded(self):
|
||||||
"""T4 cards are never returned by close filter."""
|
"""T4 cards are never returned by close filter."""
|
||||||
state = {"current_tier": 4, "formula_value": 300, "next_threshold": None}
|
state = {"current_tier": 4, "current_value": 300, "next_threshold": None}
|
||||||
assert apply_close_filter([state]) == []
|
assert apply_close_filter([state]) == []
|
||||||
|
|
||||||
def test_none_threshold_excluded(self):
|
def test_none_threshold_excluded(self):
|
||||||
"""Cards with no next_threshold (regardless of tier) are excluded."""
|
"""Cards with no next_threshold (regardless of tier) are excluded."""
|
||||||
state = {"current_tier": 3, "formula_value": 200, "next_threshold": None}
|
state = {"current_tier": 3, "current_value": 200, "next_threshold": None}
|
||||||
assert apply_close_filter([state]) == []
|
assert apply_close_filter([state]) == []
|
||||||
|
|
||||||
def test_mixed_list(self):
|
def test_mixed_list(self):
|
||||||
"""Only qualifying cards are returned from a mixed list."""
|
"""Only qualifying cards are returned from a mixed list."""
|
||||||
close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100}
|
close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
|
||||||
not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100}
|
not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
|
||||||
evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None}
|
evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
|
||||||
result = apply_close_filter([close, not_close, evolved])
|
result = apply_close_filter([close, not_close, evolved])
|
||||||
assert result == [close]
|
assert result == [close]
|
||||||
|
|
||||||
@ -427,47 +415,51 @@ def mock_interaction():
|
|||||||
|
|
||||||
class TestTierNamesDivergenceCheck:
|
class TestTierNamesDivergenceCheck:
|
||||||
"""
|
"""
|
||||||
T1-6: Assert that TIER_NAMES in cogs.refractor and helpers.refractor_notifs
|
T1-6: Assert that TIER_NAMES in all three consumers (cogs.refractor,
|
||||||
are identical (same keys, same values).
|
helpers.refractor_notifs, cogs.players) is identical.
|
||||||
|
|
||||||
Why: TIER_NAMES is duplicated in two modules. If one is updated and the
|
All three consumers now import from helpers.refractor_constants, so this
|
||||||
other is not (e.g. a tier is renamed or a new tier is added), tier labels
|
test acts as a tripwire against accidental re-localization of the constant.
|
||||||
in the /refractor status embed and the tier-up notification embed will
|
If any consumer re-declares a local copy that diverges, these tests will
|
||||||
diverge silently. This test acts as a divergence tripwire — it will fail
|
catch it.
|
||||||
the moment the two copies fall out of sync, forcing an explicit fix.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_tier_names_are_identical_across_modules(self):
|
def test_tier_names_are_identical_across_modules(self):
|
||||||
"""
|
"""
|
||||||
Import TIER_NAMES from both modules and assert deep equality.
|
Import TIER_NAMES from all three consumers and assert deep equality.
|
||||||
|
|
||||||
The test imports the name at call-time rather than at module level to
|
The test imports at call-time rather than module level to ensure it
|
||||||
ensure it always reads the current definition and is not affected by
|
always reads the current definition and is not affected by caching or
|
||||||
module-level caching or monkeypatching in other tests.
|
monkeypatching in other tests.
|
||||||
"""
|
"""
|
||||||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||||
|
from helpers.refractor_constants import TIER_NAMES as constants_tier_names
|
||||||
|
|
||||||
assert cog_tier_names == notifs_tier_names, (
|
assert cog_tier_names == notifs_tier_names == constants_tier_names, (
|
||||||
"TIER_NAMES differs between cogs.refractor and helpers.refractor_notifs. "
|
"TIER_NAMES differs across consumers. "
|
||||||
"Both copies must be kept in sync. "
|
|
||||||
f"cogs.refractor: {cog_tier_names!r} "
|
f"cogs.refractor: {cog_tier_names!r} "
|
||||||
f"helpers.refractor_notifs: {notifs_tier_names!r}"
|
f"helpers.refractor_notifs: {notifs_tier_names!r} "
|
||||||
|
f"helpers.refractor_constants: {constants_tier_names!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_tier_names_have_same_keys(self):
|
def test_tier_names_have_same_keys(self):
|
||||||
"""Keys (tier numbers) must be identical in both modules."""
|
"""Keys (tier numbers) must be identical in all consumers."""
|
||||||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||||
|
from cogs.players import REFRACTOR_TIER_NAMES
|
||||||
|
|
||||||
assert set(cog_tier_names.keys()) == set(notifs_tier_names.keys()), (
|
assert (
|
||||||
"TIER_NAMES key sets differ between modules."
|
set(cog_tier_names.keys())
|
||||||
)
|
== set(notifs_tier_names.keys())
|
||||||
|
== set(REFRACTOR_TIER_NAMES.keys())
|
||||||
|
), "TIER_NAMES key sets differ between consumers."
|
||||||
|
|
||||||
def test_tier_names_have_same_values(self):
|
def test_tier_names_have_same_values(self):
|
||||||
"""Display strings (values) must be identical for every shared key."""
|
"""Display strings (values) must be identical for every shared key."""
|
||||||
from cogs.refractor import TIER_NAMES as cog_tier_names
|
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||||
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||||
|
from cogs.players import REFRACTOR_TIER_NAMES
|
||||||
|
|
||||||
for tier, name in cog_tier_names.items():
|
for tier, name in cog_tier_names.items():
|
||||||
assert notifs_tier_names.get(tier) == name, (
|
assert notifs_tier_names.get(tier) == name, (
|
||||||
@ -475,6 +467,11 @@ class TestTierNamesDivergenceCheck:
|
|||||||
f"cogs.refractor={name!r}, "
|
f"cogs.refractor={name!r}, "
|
||||||
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!r}"
|
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!r}"
|
||||||
)
|
)
|
||||||
|
assert REFRACTOR_TIER_NAMES.get(tier) == name, (
|
||||||
|
f"Tier {tier} name mismatch: "
|
||||||
|
f"cogs.refractor={name!r}, "
|
||||||
|
f"cogs.players.REFRACTOR_TIER_NAMES={REFRACTOR_TIER_NAMES.get(tier)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -506,9 +503,9 @@ class TestApplyCloseFilterWithAllT4Cards:
|
|||||||
the "no cards close to advancement" message rather than an empty embed.
|
the "no cards close to advancement" message rather than an empty embed.
|
||||||
"""
|
"""
|
||||||
t4_cards = [
|
t4_cards = [
|
||||||
{"current_tier": 4, "formula_value": 300, "next_threshold": None},
|
{"current_tier": 4, "current_value": 300, "next_threshold": None},
|
||||||
{"current_tier": 4, "formula_value": 500, "next_threshold": None},
|
{"current_tier": 4, "current_value": 500, "next_threshold": None},
|
||||||
{"current_tier": 4, "formula_value": 275, "next_threshold": None},
|
{"current_tier": 4, "current_value": 275, "next_threshold": None},
|
||||||
]
|
]
|
||||||
result = apply_close_filter(t4_cards)
|
result = apply_close_filter(t4_cards)
|
||||||
assert result == [], (
|
assert result == [], (
|
||||||
@ -523,7 +520,7 @@ class TestApplyCloseFilterWithAllT4Cards:
|
|||||||
"""
|
"""
|
||||||
t4_high_value = {
|
t4_high_value = {
|
||||||
"current_tier": 4,
|
"current_tier": 4,
|
||||||
"formula_value": 9999,
|
"current_value": 9999,
|
||||||
"next_threshold": None,
|
"next_threshold": None,
|
||||||
}
|
}
|
||||||
assert apply_close_filter([t4_high_value]) == []
|
assert apply_close_filter([t4_high_value]) == []
|
||||||
@ -552,9 +549,9 @@ class TestFormatRefractorEntryMalformedInput:
|
|||||||
than crashing with a KeyError.
|
than crashing with a KeyError.
|
||||||
"""
|
"""
|
||||||
state = {
|
state = {
|
||||||
"card_type": "batter",
|
"track": {"card_type": "batter"},
|
||||||
"current_tier": 1,
|
"current_tier": 1,
|
||||||
"formula_value": 100,
|
"current_value": 100,
|
||||||
"next_threshold": 150,
|
"next_threshold": 150,
|
||||||
}
|
}
|
||||||
result = format_refractor_entry(state)
|
result = format_refractor_entry(state)
|
||||||
@ -562,12 +559,12 @@ class TestFormatRefractorEntryMalformedInput:
|
|||||||
|
|
||||||
def test_missing_formula_value_uses_zero(self):
|
def test_missing_formula_value_uses_zero(self):
|
||||||
"""
|
"""
|
||||||
When formula_value is absent, the progress calculation should use 0
|
When current_value is absent, the progress calculation should use 0
|
||||||
without raising a TypeError.
|
without raising a TypeError.
|
||||||
"""
|
"""
|
||||||
state = {
|
state = {
|
||||||
"player_name": "Test Player",
|
"player_name": "Test Player",
|
||||||
"card_type": "batter",
|
"track": {"card_type": "batter"},
|
||||||
"current_tier": 1,
|
"current_tier": 1,
|
||||||
"next_threshold": 150,
|
"next_threshold": 150,
|
||||||
}
|
}
|
||||||
@ -585,20 +582,19 @@ class TestFormatRefractorEntryMalformedInput:
|
|||||||
lines = result.split("\n")
|
lines = result.split("\n")
|
||||||
assert len(lines) == 2
|
assert len(lines) == 2
|
||||||
|
|
||||||
def test_missing_card_type_uses_raw_fallback(self):
|
def test_missing_card_type_does_not_crash(self):
|
||||||
"""
|
"""
|
||||||
When card_type is absent, the code defaults to 'batter' internally
|
When card_type is absent from the track, the code should still
|
||||||
(via .get("card_type", "batter")), so "PA+TB×2" should appear as the
|
produce a valid two-line output without crashing.
|
||||||
formula label.
|
|
||||||
"""
|
"""
|
||||||
state = {
|
state = {
|
||||||
"player_name": "Test Player",
|
"player_name": "Test Player",
|
||||||
"current_tier": 1,
|
"current_tier": 1,
|
||||||
"formula_value": 50,
|
"current_value": 50,
|
||||||
"next_threshold": 100,
|
"next_threshold": 100,
|
||||||
}
|
}
|
||||||
result = format_refractor_entry(state)
|
result = format_refractor_entry(state)
|
||||||
assert "PA+TB×2" in result
|
assert "50/100" in result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -623,30 +619,27 @@ class TestRenderProgressBarBoundaryPrecision:
|
|||||||
rest empty. The bar must not appear more than minimally filled.
|
rest empty. The bar must not appear more than minimally filled.
|
||||||
"""
|
"""
|
||||||
bar = render_progress_bar(1, 100)
|
bar = render_progress_bar(1, 100)
|
||||||
# Interior is 10 chars: count '=' vs '-'
|
filled_count = bar.count("▰")
|
||||||
interior = bar[1:-1] # strip '[' and ']'
|
|
||||||
filled_count = interior.count("=")
|
|
||||||
assert filled_count <= 1, (
|
assert filled_count <= 1, (
|
||||||
f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}"
|
f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_ninety_nine_of_hundred_is_nearly_full(self):
|
def test_ninety_nine_of_hundred_is_nearly_full(self):
|
||||||
"""
|
"""
|
||||||
99/100 = 99% — should produce a bar with 9 or 10 filled segments.
|
99/100 = 99% — should produce a bar with 11 or 12 filled segments.
|
||||||
The bar must NOT be completely empty or show fewer than 9 filled.
|
The bar must NOT be completely empty or show fewer than 11 filled.
|
||||||
"""
|
"""
|
||||||
bar = render_progress_bar(99, 100)
|
bar = render_progress_bar(99, 100)
|
||||||
interior = bar[1:-1]
|
filled_count = bar.count("▰")
|
||||||
filled_count = interior.count("=")
|
assert filled_count >= 11, (
|
||||||
assert filled_count >= 9, (
|
f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
|
||||||
f"99/100 should show 9 or 10 filled segments, got {filled_count}: {bar!r}"
|
|
||||||
)
|
)
|
||||||
# But it must not overflow the bar width
|
# Bar width must be exactly 12
|
||||||
assert len(interior) == 10
|
assert len(bar) == 12
|
||||||
|
|
||||||
def test_zero_of_hundred_is_completely_empty(self):
|
def test_zero_of_hundred_is_completely_empty(self):
|
||||||
"""0/100 = all dashes — re-verify the all-empty baseline."""
|
"""0/100 = all empty blocks — re-verify the all-empty baseline."""
|
||||||
assert render_progress_bar(0, 100) == "[----------]"
|
assert render_progress_bar(0, 100) == "▱" * 12
|
||||||
|
|
||||||
def test_negative_current_does_not_overflow_bar(self):
|
def test_negative_current_does_not_overflow_bar(self):
|
||||||
"""
|
"""
|
||||||
@ -656,14 +649,12 @@ class TestRenderProgressBarBoundaryPrecision:
|
|||||||
a future refactor removing the clamp.
|
a future refactor removing the clamp.
|
||||||
"""
|
"""
|
||||||
bar = render_progress_bar(-5, 100)
|
bar = render_progress_bar(-5, 100)
|
||||||
interior = bar[1:-1]
|
filled_count = bar.count("▰")
|
||||||
# No filled segments should exist for a negative value
|
|
||||||
filled_count = interior.count("=")
|
|
||||||
assert filled_count == 0, (
|
assert filled_count == 0, (
|
||||||
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
|
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
|
||||||
)
|
)
|
||||||
# Bar width must be exactly 10
|
# Bar width must be exactly 12
|
||||||
assert len(interior) == 10
|
assert len(bar) == 12
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -671,79 +662,32 @@ class TestRenderProgressBarBoundaryPrecision:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestRPFormulaLabel:
|
class TestCardTypeVariants:
|
||||||
"""
|
"""
|
||||||
T3-4: Verify that relief pitchers (card_type="rp") show the "IP+K" formula
|
T3-4/T3-5: Verify that format_refractor_entry produces valid output for
|
||||||
label in format_refractor_entry output.
|
all card types including unknown ones, without crashing.
|
||||||
|
|
||||||
Why: FORMULA_LABELS maps both "sp" and "rp" to "IP+K". The existing test
|
|
||||||
suite only verifies "sp" (via the sp_state fixture). Adding "rp" explicitly
|
|
||||||
prevents a future refactor from accidentally giving RPs a different label
|
|
||||||
or falling through to the raw card_type fallback.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def test_rp_formula_label_is_ip_plus_k(self):
|
def test_rp_card_produces_valid_output(self):
|
||||||
"""
|
"""Relief pitcher card produces a valid two-line string."""
|
||||||
A card with card_type="rp" must show "IP+K" as the formula label
|
|
||||||
in its progress line.
|
|
||||||
"""
|
|
||||||
rp_state = {
|
rp_state = {
|
||||||
"player_name": "Edwin Diaz",
|
"player_name": "Edwin Diaz",
|
||||||
"card_type": "rp",
|
"track": {"card_type": "rp"},
|
||||||
"current_tier": 1,
|
"current_tier": 1,
|
||||||
"formula_value": 45,
|
"current_value": 45,
|
||||||
"next_threshold": 60,
|
"next_threshold": 60,
|
||||||
}
|
}
|
||||||
result = format_refractor_entry(rp_state)
|
result = format_refractor_entry(rp_state)
|
||||||
assert "IP+K" in result, (
|
assert "Edwin Diaz" in result
|
||||||
f"Relief pitcher card should show 'IP+K' formula label, got: {result!r}"
|
assert "45/60" in result
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# T3-5: Unknown card_type fallback
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class TestUnknownCardTypeFallback:
|
|
||||||
"""
|
|
||||||
T3-5: format_refractor_entry should use the raw card_type string as the
|
|
||||||
formula label when the type is not in FORMULA_LABELS, rather than crashing.
|
|
||||||
|
|
||||||
Why: FORMULA_LABELS only covers "batter", "sp", and "rp". If the API
|
|
||||||
introduces a new card type (e.g. "util" for utility players) before the
|
|
||||||
bot is updated, FORMULA_LABELS.get(card_type, card_type) will fall back to
|
|
||||||
the raw string. This test ensures that fallback path produces readable
|
|
||||||
output rather than an error, and explicitly documents what to expect.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_unknown_card_type_uses_raw_string_as_label(self):
|
|
||||||
"""
|
|
||||||
card_type="util" is not in FORMULA_LABELS. The output should include
|
|
||||||
"util" as the formula label (the raw fallback) and must not raise.
|
|
||||||
"""
|
|
||||||
util_state = {
|
|
||||||
"player_name": "Ben Zobrist",
|
|
||||||
"card_type": "util",
|
|
||||||
"current_tier": 2,
|
|
||||||
"formula_value": 80,
|
|
||||||
"next_threshold": 120,
|
|
||||||
}
|
|
||||||
result = format_refractor_entry(util_state)
|
|
||||||
assert "util" in result, (
|
|
||||||
f"Unknown card_type should appear verbatim as the formula label, got: {result!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_unknown_card_type_does_not_crash(self):
|
def test_unknown_card_type_does_not_crash(self):
|
||||||
"""
|
"""Unknown card_type produces a valid two-line string."""
|
||||||
Any unknown card_type must produce a valid two-line string without
|
|
||||||
raising an exception.
|
|
||||||
"""
|
|
||||||
state = {
|
state = {
|
||||||
"player_name": "Test Player",
|
"player_name": "Test Player",
|
||||||
"card_type": "dh",
|
"track": {"card_type": "dh"},
|
||||||
"current_tier": 1,
|
"current_tier": 1,
|
||||||
"formula_value": 30,
|
"current_value": 30,
|
||||||
"next_threshold": 50,
|
"next_threshold": 50,
|
||||||
}
|
}
|
||||||
result = format_refractor_entry(state)
|
result = format_refractor_entry(state)
|
||||||
@ -794,7 +738,10 @@ async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
|
|||||||
team = {"id": 1, "sname": "Test"}
|
team = {"id": 1, "sname": "Test"}
|
||||||
|
|
||||||
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
|
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
|
||||||
with patch("cogs.refractor.db_get", new=AsyncMock(return_value={"cards": []})):
|
with patch(
|
||||||
|
"cogs.refractor.db_get",
|
||||||
|
new=AsyncMock(return_value={"items": [], "count": 0}),
|
||||||
|
):
|
||||||
await cog.refractor_status.callback(cog, mock_interaction)
|
await cog.refractor_status.callback(cog, mock_interaction)
|
||||||
|
|
||||||
call_kwargs = mock_interaction.edit_original_response.call_args
|
call_kwargs = mock_interaction.edit_original_response.call_args
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
257
tests/test_refractor_progress_embed.py
Normal file
257
tests/test_refractor_progress_embed.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"""
|
||||||
|
Tests for the post-game Refractor Progress embed field (#147).
|
||||||
|
|
||||||
|
Covers _build_refractor_progress_text() which formats tier-ups and
|
||||||
|
near-threshold cards into the summary embed field value, and the updated
|
||||||
|
_run_post_game_refractor_hook() return value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
from command_logic.logic_gameplay import (
|
||||||
|
_build_refractor_progress_text,
|
||||||
|
_run_post_game_refractor_hook,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_refractor_progress_text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildRefractorProgressText:
|
||||||
|
"""_build_refractor_progress_text formats tier-ups and close cards."""
|
||||||
|
|
||||||
|
async def test_returns_none_when_no_tier_ups_and_no_close_cards(self):
|
||||||
|
"""Returns None when evaluate-game had no tier-ups and refractor/cards returns empty.
|
||||||
|
|
||||||
|
Caller uses None to skip adding the field to the embed entirely.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = {"items": [], "count": 0}
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result={"tier_ups": []},
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_when_evo_result_is_none(self):
|
||||||
|
"""Returns None gracefully when the hook returned None (e.g. on API failure).
|
||||||
|
|
||||||
|
Near-threshold fetch still runs; returns None when that also yields nothing.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = None
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result=None,
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_tier_up_shows_player_name_and_tier_name(self):
|
||||||
|
"""Tier-ups are formatted as '⬆ **Name** → Tier Name'.
|
||||||
|
|
||||||
|
The tier name comes from TIER_NAMES (e.g. new_tier=1 → 'Base Chrome').
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = None
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result={
|
||||||
|
"tier_ups": [
|
||||||
|
{
|
||||||
|
"player_id": 10,
|
||||||
|
"player_name": "Mike Trout",
|
||||||
|
"new_tier": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert "⬆" in result
|
||||||
|
assert "Mike Trout" in result
|
||||||
|
assert "Base Chrome" in result
|
||||||
|
|
||||||
|
async def test_multiple_tier_ups_each_on_own_line(self):
|
||||||
|
"""Each tier-up gets its own line in the output."""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = None
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result={
|
||||||
|
"tier_ups": [
|
||||||
|
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1},
|
||||||
|
{
|
||||||
|
"player_id": 11,
|
||||||
|
"player_name": "Shohei Ohtani",
|
||||||
|
"new_tier": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert "Mike Trout" in result
|
||||||
|
assert "Shohei Ohtani" in result
|
||||||
|
assert "Base Chrome" in result
|
||||||
|
assert "Refractor" in result
|
||||||
|
|
||||||
|
async def test_near_threshold_card_shows_percentage(self):
|
||||||
|
"""Near-threshold cards appear as '◈ Name (pct%)'.
|
||||||
|
|
||||||
|
The percentage is current_value / next_threshold rounded to nearest integer.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"player_name": "Sandy Koufax",
|
||||||
|
"current_value": 120,
|
||||||
|
"next_threshold": 149,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
}
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result=None,
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert "◈" in result
|
||||||
|
assert "Sandy Koufax" in result
|
||||||
|
assert "81%" in result # 120/149 = ~80.5% → 81%
|
||||||
|
|
||||||
|
async def test_near_threshold_fetch_queried_for_both_teams(self):
|
||||||
|
"""refractor/cards is called once per team with progress=close."""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = None
|
||||||
|
await _build_refractor_progress_text(
|
||||||
|
evo_result=None,
|
||||||
|
winning_team_id=3,
|
||||||
|
losing_team_id=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
team_ids_queried = []
|
||||||
|
for call in mock_get.call_args_list:
|
||||||
|
params = dict(call.kwargs.get("params", []))
|
||||||
|
if "team_id" in params:
|
||||||
|
team_ids_queried.append(params["team_id"])
|
||||||
|
|
||||||
|
assert 3 in team_ids_queried
|
||||||
|
assert 7 in team_ids_queried
|
||||||
|
|
||||||
|
async def test_near_threshold_api_failure_is_non_fatal(self):
|
||||||
|
"""An exception during the near-threshold fetch does not propagate.
|
||||||
|
|
||||||
|
Tier-ups are still shown; close cards silently dropped.
|
||||||
|
"""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.side_effect = RuntimeError("API down")
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result={
|
||||||
|
"tier_ups": [
|
||||||
|
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert "Mike Trout" in result
|
||||||
|
|
||||||
|
async def test_close_cards_capped_at_five(self):
|
||||||
|
"""At most 5 near-threshold entries are included across both teams."""
|
||||||
|
many_cards = [
|
||||||
|
{"player_name": f"Player {i}", "current_value": 90, "next_threshold": 100}
|
||||||
|
for i in range(10)
|
||||||
|
]
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
|
||||||
|
) as mock_get:
|
||||||
|
mock_get.return_value = {"items": many_cards, "count": len(many_cards)}
|
||||||
|
result = await _build_refractor_progress_text(
|
||||||
|
evo_result=None,
|
||||||
|
winning_team_id=1,
|
||||||
|
losing_team_id=2,
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result.count("◈") <= 5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _run_post_game_refractor_hook return value
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefractorHookReturnValue:
|
||||||
|
"""_run_post_game_refractor_hook returns evo_result on success, None on failure."""
|
||||||
|
|
||||||
|
async def test_returns_evo_result_when_successful(self):
|
||||||
|
"""The evaluate-game response dict is returned so complete_game can use it."""
|
||||||
|
evo_response = {
|
||||||
|
"tier_ups": [{"player_id": 1, "player_name": "Babe Ruth", "new_tier": 2}]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _side_effect(url, *args, **kwargs):
|
||||||
|
if url.startswith("season-stats"):
|
||||||
|
return None
|
||||||
|
return evo_response
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||||
|
) as mock_post,
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay._trigger_variant_renders",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
mock_post.side_effect = _side_effect
|
||||||
|
result = await _run_post_game_refractor_hook(42, MagicMock())
|
||||||
|
|
||||||
|
assert result == evo_response
|
||||||
|
|
||||||
|
async def test_returns_none_on_exception(self):
|
||||||
|
"""Hook returns None when an exception occurs (game result is unaffected)."""
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||||
|
) as mock_post:
|
||||||
|
mock_post.side_effect = RuntimeError("db unreachable")
|
||||||
|
result = await _run_post_game_refractor_hook(42, MagicMock())
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_evo_result_when_no_tier_ups(self):
|
||||||
|
"""Returns the full evo_result even when tier_ups is empty or absent."""
|
||||||
|
evo_response = {"tier_ups": []}
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
|
||||||
|
) as mock_post:
|
||||||
|
mock_post.return_value = evo_response
|
||||||
|
result = await _run_post_game_refractor_hook(42, MagicMock())
|
||||||
|
|
||||||
|
assert result == evo_response
|
||||||
Loading…
Reference in New Issue
Block a user