Merge branch 'main' into hotfix/open-packs-checkin
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s

This commit is contained in:
cal 2026-03-26 13:50:01 +00:00
commit b6592b8a70
31 changed files with 7814 additions and 5498 deletions

View File

@ -1,31 +1,48 @@
# Gitea Actions: Docker Build, Push, and Notify
#
# CI/CD pipeline for Paper Dynasty Discord Bot:
# - Builds Docker images on every push/PR
# - Auto-generates CalVer version (YYYY.MM.BUILD) on main branch merges
# - Pushes to Docker Hub and creates git tag on main
# - Triggered by pushing a CalVer tag (e.g., 2026.3.11) or "dev" tag
# - CalVer tags push with version + "production" Docker tags
# - "dev" tag pushes with "dev" Docker tag for the dev environment
# - Sends Discord notifications on success/failure
#
# To release: git tag 2026.3.11 && git push origin 2026.3.11
# To deploy dev: git tag -f dev && git push origin dev --force
name: Build Docker Image
on:
push:
branches:
- main
- next-release
pull_request:
branches:
- main
tags:
- '20*' # matches CalVer tags like 2026.3.11
- 'dev' # dev environment builds
jobs:
build:
runs-on: ubuntu-latest
container:
volumes:
- pd-buildx-cache:/opt/buildx-cache
steps:
- name: Checkout code
uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0 # Full history for tag counting
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
VERSION=${GITHUB_REF#refs/tags/}
SHA_SHORT=$(git rev-parse --short HEAD)
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "sha_short=$SHA_SHORT" >> $GITHUB_OUTPUT
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
if [ "$VERSION" = "dev" ]; then
echo "environment=dev" >> $GITHUB_OUTPUT
else
echo "environment=production" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
@ -36,67 +53,52 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate CalVer version
id: calver
uses: cal/gitea-actions/calver@main
- name: Resolve Docker tags
id: tags
uses: cal/gitea-actions/docker-tags@main
with:
image: manticorum67/paper-dynasty-discordapp
version: ${{ steps.calver.outputs.version }}
sha_short: ${{ steps.calver.outputs.sha_short }}
- name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache
cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max
tags: |
manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.version }}
manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.environment }}
cache-from: type=local,src=/opt/buildx-cache/pd-discord
cache-to: type=local,dest=/opt/buildx-cache/pd-discord-new,mode=max
- name: Tag release
if: success() && steps.tags.outputs.channel == 'stable'
uses: cal/gitea-actions/gitea-tag@main
with:
version: ${{ steps.calver.outputs.version }}
token: ${{ github.token }}
- name: Rotate cache
run: |
rm -rf /opt/buildx-cache/pd-discord
mv /opt/buildx-cache/pd-discord-new /opt/buildx-cache/pd-discord
- name: Build Summary
run: |
echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY
echo "**Version:** \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
for tag in "${TAG_ARRAY[@]}"; do
echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY
done
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.environment }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Timestamp: \`${{ steps.version.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
- name: Discord Notification - Success
if: success() && steps.tags.outputs.channel != 'dev'
if: success()
uses: cal/gitea-actions/discord-notify@main
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
title: "Paper Dynasty Bot"
status: success
version: ${{ steps.calver.outputs.version }}
image_tag: ${{ steps.calver.outputs.version_sha }}
commit_sha: ${{ steps.calver.outputs.sha_short }}
timestamp: ${{ steps.calver.outputs.timestamp }}
version: ${{ steps.version.outputs.version }}
image_tag: ${{ steps.version.outputs.version }}
commit_sha: ${{ steps.version.outputs.sha_short }}
timestamp: ${{ steps.version.outputs.timestamp }}
- name: Discord Notification - Failure
if: failure() && steps.tags.outputs.channel != 'dev'
if: failure()
uses: cal/gitea-actions/discord-notify@main
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}

View File

@ -0,0 +1,31 @@
# Gitea Actions: Ruff Lint Check
#
# Runs ruff on every PR to main to catch violations before merge.
# Complements the local pre-commit hook — violations blocked here even if
# the developer bypassed the hook with --no-verify.
name: Ruff Lint
on:
pull_request:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: https://github.com/actions/checkout@v4
- name: Set up Python
uses: https://github.com/actions/setup-python@v5
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: Run ruff check
run: ruff check .

View File

@ -32,7 +32,7 @@ pip install -r requirements.txt # Install dependencies
- **Container**: `paper-dynasty_discord-app_1`
- **Image**: `manticorum67/paper-dynasty-discordapp`
- **Health**: `GET http://localhost:8080/health` (HTTP server in `health_server.py`)
- **Versioning**: CalVer (`YYYY.MM.BUILD`) — auto-generated on merge to `main`
- **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release
### Logs
- **Container logs**: `ssh sba-bots "docker logs --since 1h paper-dynasty_discord-app_1"`
@ -49,8 +49,9 @@ pip install -r requirements.txt # Install dependencies
- Health endpoint not responding → `health_server.py` runs on port 8080 inside the container
### CI/CD
Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version on merge.
Ruff lint on PRs. Docker image built on CalVer tag push only.
```bash
# Release: git tag YYYY.M.BUILD && git push origin YYYY.M.BUILD
tea pulls create --repo cal/paper-dynasty --head <branch> --base main --title "title" --description "description"
```

View File

@ -1,4 +1,4 @@
FROM python:3.12-slim
FROM python:3.12.13-slim
WORKDIR /usr/src/app

View File

@ -17,7 +17,6 @@ DB_URL = (
if "prod" in ENV_DATABASE
else "https://pddev.manticorum.com/api"
)
PLAYER_CACHE = {}
logger = logging.getLogger("discord_app")

File diff suppressed because it is too large Load Diff

View File

@ -15,131 +15,161 @@ from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev
from help_text import SHEET_SHARE_STEPS, HELP_SHEET_SCRIPTS
from helpers.constants import PD_PLAYERS, ALL_MLB_TEAMS
from helpers import (
get_team_by_owner, share_channel, get_role, get_cal_user, get_or_create_role,
display_cards, give_packs, get_all_pos, get_sheets, refresh_sheet,
post_ratings_guide, team_summary_embed, get_roster_sheet, Question, Confirm,
ButtonOptions, legal_channel, get_channel, create_channel, get_context_user
get_team_by_owner,
share_channel,
get_role,
get_cal_user,
get_or_create_role,
display_cards,
give_packs,
get_all_pos,
get_sheets,
refresh_sheet,
post_ratings_guide,
team_summary_embed,
get_roster_sheet,
Question,
Confirm,
ButtonOptions,
legal_channel,
get_channel,
create_channel,
get_context_user,
)
from api_calls import team_hash
from helpers.discord_utils import get_team_embed, send_to_channel
logger = logging.getLogger('discord_app')
logger = logging.getLogger("discord_app")
class TeamSetup(commands.Cog):
"""Team creation and Google Sheets integration functionality for Paper Dynasty."""
def __init__(self, bot):
self.bot = bot
@app_commands.command(name='newteam', description='Get your fresh team for a new season')
@app_commands.command(
name="newteam", description="Get your fresh team for a new season"
)
@app_commands.checks.has_any_role(PD_PLAYERS)
@app_commands.describe(
gm_name='The fictional name of your team\'s GM',
team_abbrev='2, 3, or 4 character abbreviation (e.g. WV, ATL, MAD)',
team_full_name='City/location and name (e.g. Baltimore Orioles)',
team_short_name='Name of team (e.g. Yankees)',
mlb_anchor_team='2 or 3 character abbreviation of your anchor MLB team (e.g. NYM, MKE)',
team_logo_url='[Optional] URL ending in .png or .jpg for your team logo',
color='[Optional] Hex color code to highlight your team'
gm_name="The fictional name of your team's GM",
team_abbrev="2, 3, or 4 character abbreviation (e.g. WV, ATL, MAD)",
team_full_name="City/location and name (e.g. Baltimore Orioles)",
team_short_name="Name of team (e.g. Yankees)",
mlb_anchor_team="2 or 3 character abbreviation of your anchor MLB team (e.g. NYM, MKE)",
team_logo_url="[Optional] URL ending in .png or .jpg for your team logo",
color="[Optional] Hex color code to highlight your team",
)
async def new_team_slash(
self, interaction: discord.Interaction, gm_name: str, team_abbrev: str, team_full_name: str,
team_short_name: str, mlb_anchor_team: str, team_logo_url: str = None, color: str = None):
self,
interaction: discord.Interaction,
gm_name: str,
team_abbrev: str,
team_full_name: str,
team_short_name: str,
mlb_anchor_team: str,
team_logo_url: str = None,
color: str = None,
):
owner_team = await get_team_by_owner(interaction.user.id)
current = await db_get('current')
current = await db_get("current")
# Check for existing team
if owner_team and not os.environ.get('TESTING'):
if owner_team and not os.environ.get("TESTING"):
await interaction.response.send_message(
f'Whoa there, bucko. I already have you down as GM of the {owner_team["sname"]}.'
f"Whoa there, bucko. I already have you down as GM of the {owner_team['sname']}."
)
return
# Check for duplicate team data
dupes = await db_get('teams', params=[('abbrev', team_abbrev)])
if dupes['count']:
dupes = await db_get("teams", params=[("abbrev", team_abbrev)])
if dupes["count"]:
await interaction.response.send_message(
f'Yikes! {team_abbrev.upper()} is a popular abbreviation - it\'s already in use by the '
f'{dupes["teams"][0]["sname"]}. No worries, though, you can run the `/newteam` command again to get '
f'started!'
f"Yikes! {team_abbrev.upper()} is a popular abbreviation - it's already in use by the "
f"{dupes['teams'][0]['sname']}. No worries, though, you can run the `/newteam` command again to get "
f"started!"
)
return
# Check for duplicate team data
dupes = await db_get('teams', params=[('lname', team_full_name)])
if dupes['count']:
dupes = await db_get("teams", params=[("lname", team_full_name)])
if dupes["count"]:
await interaction.response.send_message(
f'Yikes! {team_full_name.title()} is a popular name - it\'s already in use by '
f'{dupes["teams"][0]["abbrev"]}. No worries, though, you can run the `/newteam` command again to get '
f'started!'
f"Yikes! {team_full_name.title()} is a popular name - it's already in use by "
f"{dupes['teams'][0]['abbrev']}. No worries, though, you can run the `/newteam` command again to get "
f"started!"
)
return
# Get personal bot channel
hello_channel = discord.utils.get(
interaction.guild.text_channels,
name=f'hello-{interaction.user.name.lower()}'
name=f"hello-{interaction.user.name.lower()}",
)
if hello_channel:
op_ch = hello_channel
else:
op_ch = await create_channel(
interaction,
channel_name=f'hello-{interaction.user.name}',
category_name='Paper Dynasty Team',
channel_name=f"hello-{interaction.user.name}",
category_name="Paper Dynasty Team",
everyone_read=False,
read_send_members=[interaction.user]
read_send_members=[interaction.user],
)
await share_channel(op_ch, interaction.guild.me)
await share_channel(op_ch, interaction.user)
try:
poke_role = get_role(interaction, 'Pokétwo')
poke_role = get_role(interaction, "Pokétwo")
await share_channel(op_ch, poke_role, read_only=True)
except Exception as e:
logger.error(f'unable to share sheet with Poketwo')
logger.error(f"unable to share sheet with Poketwo")
await interaction.response.send_message(
f'Let\'s head down to your private channel: {op_ch.mention}',
ephemeral=True
f"Let's head down to your private channel: {op_ch.mention}", ephemeral=True
)
await op_ch.send(
f"Hey there, {interaction.user.mention}! I am Paper Domo - welcome to season "
f"{current['season']} of Paper Dynasty! We've got a lot of special updates in store for this "
f"season including live cards, throwback cards, and special events."
)
await op_ch.send(f'Hey there, {interaction.user.mention}! I am Paper Domo - welcome to season '
f'{current["season"]} of Paper Dynasty! We\'ve got a lot of special updates in store for this '
f'season including live cards, throwback cards, and special events.')
# Confirm user is happy with branding
embed = get_team_embed(
f'Branding Check',
f"Branding Check",
{
'logo': team_logo_url if team_logo_url else None,
'color': color if color else 'a6ce39',
'season': 4
}
"logo": team_logo_url if team_logo_url else None,
"color": color if color else "a6ce39",
"season": 4,
},
)
embed.add_field(name='GM Name', value=gm_name, inline=False)
embed.add_field(name='Full Team Name', value=team_full_name)
embed.add_field(name='Short Team Name', value=team_short_name)
embed.add_field(name='Team Abbrev', value=team_abbrev.upper())
embed.add_field(name="GM Name", value=gm_name, inline=False)
embed.add_field(name="Full Team Name", value=team_full_name)
embed.add_field(name="Short Team Name", value=team_short_name)
embed.add_field(name="Team Abbrev", value=team_abbrev.upper())
view = Confirm(responders=[interaction.user])
question = await op_ch.send('Are you happy with this branding? Don\'t worry - you can update it later!',
embed=embed, view=view)
question = await op_ch.send(
"Are you happy with this branding? Don't worry - you can update it later!",
embed=embed,
view=view,
)
await view.wait()
if not view.value:
await question.edit(
content='~~Are you happy with this branding?~~\n\nI gotta go, but when you\'re ready to start again '
'run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the '
'command from last time and make edits.',
view=None
content="~~Are you happy with this branding?~~\n\nI gotta go, but when you're ready to start again "
"run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the "
"command from last time and make edits.",
view=None,
)
return
await question.edit(
content='Looking good, champ in the making! Let\'s get you your starter team!',
view=None
content="Looking good, champ in the making! Let's get you your starter team!",
view=None,
)
team_choice = None
@ -147,26 +177,31 @@ class TeamSetup(commands.Cog):
team_choice = mlb_anchor_team.title()
else:
for x in ALL_MLB_TEAMS:
if mlb_anchor_team.upper() in ALL_MLB_TEAMS[x] or mlb_anchor_team.title() in ALL_MLB_TEAMS[x]:
if (
mlb_anchor_team.upper() in ALL_MLB_TEAMS[x]
or mlb_anchor_team.title() in ALL_MLB_TEAMS[x]
):
team_choice = x
break
team_string = mlb_anchor_team
logger.debug(f'team_string: {team_string} / team_choice: {team_choice}')
logger.debug(f"team_string: {team_string} / team_choice: {team_choice}")
if not team_choice:
# Get MLB anchor team
while True:
prompt = f'I don\'t recognize **{team_string}**. I try to recognize abbreviations (BAL), ' \
f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you ' \
f'like to use as your anchor team?'
this_q = Question(self.bot, op_ch, prompt, 'text', 120)
prompt = (
f"I don't recognize **{team_string}**. I try to recognize abbreviations (BAL), "
f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you '
f"like to use as your anchor team?"
)
this_q = Question(self.bot, op_ch, prompt, "text", 120)
team_string = await this_q.ask([interaction.user])
if not team_string:
await op_ch.send(
f'Tell you hwat. You think on it and come back I gotta go, but when you\'re ready to start again '
'run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the '
'command from last time and make edits.'
f"Tell you hwat. You think on it and come back I gotta go, but when you're ready to start again "
"run the `/newteam` command again and we can get rolling! Hint: you can copy and paste the "
"command from last time and make edits."
)
return
@ -176,166 +211,257 @@ class TeamSetup(commands.Cog):
else:
match = False
for x in ALL_MLB_TEAMS:
if team_string.upper() in ALL_MLB_TEAMS[x] or team_string.title() in ALL_MLB_TEAMS[x]:
if (
team_string.upper() in ALL_MLB_TEAMS[x]
or team_string.title() in ALL_MLB_TEAMS[x]
):
team_choice = x
match = True
break
if not match:
await op_ch.send(f'Got it!')
await op_ch.send(f"Got it!")
team = await db_post('teams', payload={
'abbrev': team_abbrev.upper(),
'sname': team_short_name,
'lname': team_full_name,
'gmid': interaction.user.id,
'gmname': gm_name,
'gsheet': 'None',
'season': current['season'],
'wallet': 100,
'color': color if color else 'a6ce39',
'logo': team_logo_url if team_logo_url else None
})
team = await db_post(
"teams",
payload={
"abbrev": team_abbrev.upper(),
"sname": team_short_name,
"lname": team_full_name,
"gmid": interaction.user.id,
"gmname": gm_name,
"gsheet": "None",
"season": current["season"],
"wallet": 100,
"color": color if color else "a6ce39",
"logo": team_logo_url if team_logo_url else None,
},
)
if not team:
await op_ch.send(f'Frick. {get_cal_user(interaction).mention}, can you help? I can\'t find this team.')
await op_ch.send(
f"Frick. {get_cal_user(interaction).mention}, can you help? I can't find this team."
)
return
t_role = await get_or_create_role(interaction, f'{team_abbrev} - {team_full_name}')
t_role = await get_or_create_role(
interaction, f"{team_abbrev} - {team_full_name}"
)
await interaction.user.add_roles(t_role)
anchor_players = []
anchor_all_stars = await db_get(
'players/random',
"players/random",
params=[
('min_rarity', 3), ('max_rarity', 3), ('franchise', team_choice), ('pos_exclude', 'RP'), ('limit', 1),
('in_packs', True)
]
("min_rarity", 3),
("max_rarity", 3),
("franchise", team_choice),
("pos_exclude", "RP"),
("limit", 1),
("in_packs", True),
],
)
anchor_starters = await db_get(
'players/random',
"players/random",
params=[
('min_rarity', 2), ('max_rarity', 2), ('franchise', team_choice), ('pos_exclude', 'RP'), ('limit', 2),
('in_packs', True)
]
("min_rarity", 2),
("max_rarity", 2),
("franchise", team_choice),
("pos_exclude", "RP"),
("limit", 2),
("in_packs", True),
],
)
if not anchor_all_stars:
await op_ch.send(f'I am so sorry, but the {team_choice} do not have an All-Star to '
f'provide as your anchor player. Let\'s start this process over - will you please '
f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the '
'command from last time and make edits.')
await db_delete('teams', object_id=team['id'])
await op_ch.send(
f"I am so sorry, but the {team_choice} do not have an All-Star to "
f"provide as your anchor player. Let's start this process over - will you please "
f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the "
"command from last time and make edits."
)
await db_delete("teams", object_id=team["id"])
return
if not anchor_starters or anchor_starters['count'] <= 1:
await op_ch.send(f'I am so sorry, but the {team_choice} do not have two Starters to '
f'provide as your anchor players. Let\'s start this process over - will you please '
f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the '
'command from last time and make edits.')
await db_delete('teams', object_id=team['id'])
if not anchor_starters or anchor_starters["count"] <= 1:
await op_ch.send(
f"I am so sorry, but the {team_choice} do not have two Starters to "
f"provide as your anchor players. Let's start this process over - will you please "
f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the "
"command from last time and make edits."
)
await db_delete("teams", object_id=team["id"])
return
anchor_players.append(anchor_all_stars['players'][0])
anchor_players.append(anchor_starters['players'][0])
anchor_players.append(anchor_starters['players'][1])
anchor_players.append(anchor_all_stars["players"][0])
anchor_players.append(anchor_starters["players"][0])
anchor_players.append(anchor_starters["players"][1])
this_pack = await db_post('packs/one',
payload={'team_id': team['id'], 'pack_type_id': 2,
'open_time': datetime.datetime.timestamp(datetime.datetime.now())*1000})
this_pack = await db_post(
"packs/one",
payload={
"team_id": team["id"],
"pack_type_id": 2,
"open_time": datetime.datetime.timestamp(datetime.datetime.now())
* 1000,
},
)
roster_counts = {
'SP': 0,
'RP': 0,
'CP': 0,
'C': 0,
'1B': 0,
'2B': 0,
'3B': 0,
'SS': 0,
'LF': 0,
'CF': 0,
'RF': 0,
'DH': 0,
'All-Star': 0,
'Starter': 0,
'Reserve': 0,
'Replacement': 0,
"SP": 0,
"RP": 0,
"CP": 0,
"C": 0,
"1B": 0,
"2B": 0,
"3B": 0,
"SS": 0,
"LF": 0,
"CF": 0,
"RF": 0,
"DH": 0,
"All-Star": 0,
"Starter": 0,
"Reserve": 0,
"Replacement": 0,
}
def update_roster_counts(players: list):
for pl in players:
roster_counts[pl['rarity']['name']] += 1
roster_counts[pl["rarity"]["name"]] += 1
for x in get_all_pos(pl):
roster_counts[x] += 1
logger.warning(f'Roster counts for {team["sname"]}: {roster_counts}')
logger.warning(f"Roster counts for {team['sname']}: {roster_counts}")
# Add anchor position coverage
update_roster_counts(anchor_players)
await db_post('cards', payload={'cards': [
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in anchor_players]
}, timeout=10)
await db_post(
"cards",
payload={
"cards": [
{
"player_id": x["player_id"],
"team_id": team["id"],
"pack_id": this_pack["id"],
}
for x in anchor_players
]
},
timeout=10,
)
# Get 10 pitchers to seed team
five_sps = await db_get('players/random', params=[('pos_include', 'SP'), ('max_rarity', 1), ('limit', 5)])
five_rps = await db_get('players/random', params=[('pos_include', 'RP'), ('max_rarity', 1), ('limit', 5)])
team_sp = [x for x in five_sps['players']]
team_rp = [x for x in five_rps['players']]
five_sps = await db_get(
"players/random",
params=[("pos_include", "SP"), ("max_rarity", 1), ("limit", 5)],
)
five_rps = await db_get(
"players/random",
params=[("pos_include", "RP"), ("max_rarity", 1), ("limit", 5)],
)
team_sp = [x for x in five_sps["players"]]
team_rp = [x for x in five_rps["players"]]
update_roster_counts([*team_sp, *team_rp])
await db_post('cards', payload={'cards': [
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in [*team_sp, *team_rp]]
}, timeout=10)
await db_post(
"cards",
payload={
"cards": [
{
"player_id": x["player_id"],
"team_id": team["id"],
"pack_id": this_pack["id"],
}
for x in [*team_sp, *team_rp]
]
},
timeout=10,
)
# TODO: track reserve vs replacement and if rep < res, get rep, else get res
# Collect infielders
team_infielders = []
for pos in ['C', '1B', '2B', '3B', 'SS']:
max_rar = 1
if roster_counts['Replacement'] < roster_counts['Reserve']:
max_rar = 0
for pos in ["C", "1B", "2B", "3B", "SS"]:
if roster_counts["Replacement"] < roster_counts["Reserve"]:
rarity_params = [("min_rarity", 0), ("max_rarity", 0)]
else:
rarity_params = [("min_rarity", 1), ("max_rarity", 1)]
r_draw = await db_get(
'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False
"players/random",
params=[("pos_include", pos), *rarity_params, ("limit", 2)],
none_okay=False,
)
team_infielders.extend(r_draw['players'])
team_infielders.extend(r_draw["players"])
update_roster_counts(team_infielders)
await db_post('cards', payload={'cards': [
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_infielders]
}, timeout=10)
await db_post(
"cards",
payload={
"cards": [
{
"player_id": x["player_id"],
"team_id": team["id"],
"pack_id": this_pack["id"],
}
for x in team_infielders
]
},
timeout=10,
)
# Collect outfielders
team_outfielders = []
for pos in ['LF', 'CF', 'RF']:
max_rar = 1
if roster_counts['Replacement'] < roster_counts['Reserve']:
max_rar = 0
for pos in ["LF", "CF", "RF"]:
if roster_counts["Replacement"] < roster_counts["Reserve"]:
rarity_params = [("min_rarity", 0), ("max_rarity", 0)]
else:
rarity_params = [("min_rarity", 1), ("max_rarity", 1)]
r_draw = await db_get(
'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False
"players/random",
params=[("pos_include", pos), *rarity_params, ("limit", 2)],
none_okay=False,
)
team_outfielders.extend(r_draw['players'])
team_outfielders.extend(r_draw["players"])
update_roster_counts(team_outfielders)
await db_post('cards', payload={'cards': [
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_outfielders]
}, timeout=10)
await db_post(
"cards",
payload={
"cards": [
{
"player_id": x["player_id"],
"team_id": team["id"],
"pack_id": this_pack["id"],
}
for x in team_outfielders
]
},
timeout=10,
)
async with op_ch.typing():
done_anc = await display_cards(
[{'player': x, 'team': team} for x in anchor_players], team, op_ch, interaction.user, self.bot,
cust_message=f'Let\'s take a look at your three {team_choice} anchor players.\n'
f'Press `Close Pack` to continue.',
add_roster=False
[{"player": x, "team": team} for x in anchor_players],
team,
op_ch,
interaction.user,
self.bot,
cust_message=f"Let's take a look at your three {team_choice} anchor players.\n"
f"Press `Close Pack` to continue.",
add_roster=False,
)
error_text = f'Yikes - I can\'t display the rest of your team. {get_cal_user(interaction).mention} plz halp'
error_text = f"Yikes - I can't display the rest of your team. {get_cal_user(interaction).mention} plz halp"
if not done_anc:
await op_ch.send(error_text)
async with op_ch.typing():
done_sp = await display_cards(
[{'player': x, 'team': team} for x in team_sp], team, op_ch, interaction.user, self.bot,
cust_message=f'Here are your starting pitchers.\n'
f'Press `Close Pack` to continue.',
add_roster=False
[{"player": x, "team": team} for x in team_sp],
team,
op_ch,
interaction.user,
self.bot,
cust_message=f"Here are your starting pitchers.\n"
f"Press `Close Pack` to continue.",
add_roster=False,
)
if not done_sp:
@ -343,10 +469,14 @@ class TeamSetup(commands.Cog):
async with op_ch.typing():
done_rp = await display_cards(
[{'player': x, 'team': team} for x in team_rp], team, op_ch, interaction.user, self.bot,
cust_message=f'And now for your bullpen.\n'
f'Press `Close Pack` to continue.',
add_roster=False
[{"player": x, "team": team} for x in team_rp],
team,
op_ch,
interaction.user,
self.bot,
cust_message=f"And now for your bullpen.\n"
f"Press `Close Pack` to continue.",
add_roster=False,
)
if not done_rp:
@ -354,10 +484,14 @@ class TeamSetup(commands.Cog):
async with op_ch.typing():
done_inf = await display_cards(
[{'player': x, 'team': team} for x in team_infielders], team, op_ch, interaction.user, self.bot,
cust_message=f'Next let\'s take a look at your infielders.\n'
f'Press `Close Pack` to continue.',
add_roster=False
[{"player": x, "team": team} for x in team_infielders],
team,
op_ch,
interaction.user,
self.bot,
cust_message=f"Next let's take a look at your infielders.\n"
f"Press `Close Pack` to continue.",
add_roster=False,
)
if not done_inf:
@ -365,10 +499,14 @@ class TeamSetup(commands.Cog):
async with op_ch.typing():
done_out = await display_cards(
[{'player': x, 'team': team} for x in team_outfielders], team, op_ch, interaction.user, self.bot,
cust_message=f'Now let\'s take a look at your outfielders.\n'
f'Press `Close Pack` to continue.',
add_roster=False
[{"player": x, "team": team} for x in team_outfielders],
team,
op_ch,
interaction.user,
self.bot,
cust_message=f"Now let's take a look at your outfielders.\n"
f"Press `Close Pack` to continue.",
add_roster=False,
)
if not done_out:
@ -376,129 +514,154 @@ class TeamSetup(commands.Cog):
await give_packs(team, 1)
await op_ch.send(
f'To get you started, I\'ve spotted you 100₼ and a pack of cards. You can rip that with the '
f'`/open` command once your google sheet is set up!'
f"To get you started, I've spotted you 100₼ and a pack of cards. You can rip that with the "
f"`/open` command once your google sheet is set up!"
)
await op_ch.send(
f'{t_role.mention}\n\n'
f'There\'s your roster! We have one more step and you will be ready to play.\n\n{SHEET_SHARE_STEPS}\n\n'
f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}'
f"{t_role.mention}\n\n"
f"There's your roster! We have one more step and you will be ready to play.\n\n{SHEET_SHARE_STEPS}\n\n"
f"{get_roster_sheet({'gsheet': current['gsheet_template']})}"
)
new_team_embed = await team_summary_embed(team, interaction, include_roster=False)
new_team_embed = await team_summary_embed(
team, interaction, include_roster=False
)
await send_to_channel(
self.bot, "pd-network-news", content='A new challenger approaches...', embed=new_team_embed
self.bot,
"pd-network-news",
content="A new challenger approaches...",
embed=new_team_embed,
)
@commands.hybrid_command(name='newsheet', help='Link a new team sheet with your team')
@commands.hybrid_command(
name="newsheet", help="Link a new team sheet with your team"
)
@commands.has_any_role(PD_PLAYERS)
async def share_sheet_command(
self, ctx, google_sheet_url: str, team_abbrev: Optional[str], copy_rosters: Optional[bool] = True):
self,
ctx,
google_sheet_url: str,
team_abbrev: Optional[str],
copy_rosters: Optional[bool] = True,
):
owner_team = await get_team_by_owner(get_context_user(ctx).id)
if not owner_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(
f"I don't see a team for you, yet. You can sign up with the `/newteam` command!"
)
return
team = owner_team
if team_abbrev and team_abbrev != owner_team['abbrev']:
if team_abbrev and team_abbrev != owner_team["abbrev"]:
if get_context_user(ctx).id != 258104532423147520:
await ctx.send(f'You can only update the team sheet for your own team, you goober.')
await ctx.send(
f"You can only update the team sheet for your own team, you goober."
)
return
else:
team = await get_team_by_abbrev(team_abbrev)
current = await db_get('current')
if current['gsheet_template'] in google_sheet_url:
await ctx.send(f'Ope, looks like that is the template sheet. Would you please make a copy and then share?')
current = await db_get("current")
if current["gsheet_template"] in google_sheet_url:
await ctx.send(
f"Ope, looks like that is the template sheet. Would you please make a copy and then share?"
)
return
gauntlet_team = await get_team_by_abbrev(f'Gauntlet-{owner_team["abbrev"]}')
gauntlet_team = await get_team_by_abbrev(f"Gauntlet-{owner_team['abbrev']}")
if gauntlet_team:
view = ButtonOptions([ctx.author], timeout=30, labels=['Main Team', 'Gauntlet Team', None, None, None])
question = await ctx.send(f'Is this sheet for your main PD team or your active Gauntlet team?', view=view)
view = ButtonOptions(
[ctx.author],
timeout=30,
labels=["Main Team", "Gauntlet Team", None, None, None],
)
question = await ctx.send(
f"Is this sheet for your main PD team or your active Gauntlet team?",
view=view,
)
await view.wait()
if not view.value:
await question.edit(
content=f'Okay you keep thinking on it and get back to me when you\'re ready.', view=None
content=f"Okay you keep thinking on it and get back to me when you're ready.",
view=None,
)
return
elif view.value == 'Gauntlet Team':
elif view.value == "Gauntlet Team":
await question.delete()
team = gauntlet_team
sheets = get_sheets(self.bot)
response = await ctx.send(f'I\'ll go grab that sheet...')
response = await ctx.send(f"I'll go grab that sheet...")
try:
new_sheet = sheets.open_by_url(google_sheet_url)
except Exception as e:
logger.error(f'Error accessing {team["abbrev"]} sheet: {e}')
current = await db_get('current')
await ctx.send(f'I wasn\'t able to access that sheet. Did you remember to share it with my PD email?'
f'\n\nHere\'s a quick refresher:\n{SHEET_SHARE_STEPS}\n\n'
f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}')
logger.error(f"Error accessing {team['abbrev']} sheet: {e}")
current = await db_get("current")
await ctx.send(
f"I wasn't able to access that sheet. Did you remember to share it with my PD email?"
f"\n\nHere's a quick refresher:\n{SHEET_SHARE_STEPS}\n\n"
f"{get_roster_sheet({'gsheet': current['gsheet_template']})}"
)
return
team_data = new_sheet.worksheet_by_title('Team Data')
team_data = new_sheet.worksheet_by_title("Team Data")
if not gauntlet_team or owner_team != gauntlet_team:
team_data.update_values(
crange='B1:B2',
values=[[f'{team["id"]}'], [f'{team_hash(team)}']]
crange="B1:B2", values=[[f"{team['id']}"], [f"{team_hash(team)}"]]
)
if copy_rosters and team['gsheet'].lower() != 'none':
old_sheet = sheets.open_by_key(team['gsheet'])
r_sheet = old_sheet.worksheet_by_title(f'My Rosters')
roster_ids = r_sheet.range('B3:B80')
lineups_data = r_sheet.range('H4:M26')
if copy_rosters and team["gsheet"].lower() != "none":
old_sheet = sheets.open_by_key(team["gsheet"])
r_sheet = old_sheet.worksheet_by_title(f"My Rosters")
roster_ids = r_sheet.range("B3:B80")
lineups_data = r_sheet.range("H4:M26")
new_r_data, new_l_data = [], []
for row in roster_ids:
if row[0].value != '':
if row[0].value != "":
new_r_data.append([int(row[0].value)])
else:
new_r_data.append([None])
logger.debug(f'new_r_data: {new_r_data}')
logger.debug(f"new_r_data: {new_r_data}")
for row in lineups_data:
logger.debug(f'row: {row}')
new_l_data.append([
row[0].value if row[0].value != '' else None,
int(row[1].value) if row[1].value != '' else None,
row[2].value if row[2].value != '' else None,
int(row[3].value) if row[3].value != '' else None,
row[4].value if row[4].value != '' else None,
int(row[5].value) if row[5].value != '' else None
])
logger.debug(f'new_l_data: {new_l_data}')
logger.debug(f"row: {row}")
new_l_data.append(
[
row[0].value if row[0].value != "" else None,
int(row[1].value) if row[1].value != "" else None,
row[2].value if row[2].value != "" else None,
int(row[3].value) if row[3].value != "" else None,
row[4].value if row[4].value != "" else None,
int(row[5].value) if row[5].value != "" else None,
]
)
logger.debug(f"new_l_data: {new_l_data}")
new_r_sheet = new_sheet.worksheet_by_title(f'My Rosters')
new_r_sheet.update_values(
crange='B3:B80',
values=new_r_data
)
new_r_sheet.update_values(
crange='H4:M26',
values=new_l_data
)
new_r_sheet = new_sheet.worksheet_by_title(f"My Rosters")
new_r_sheet.update_values(crange="B3:B80", values=new_r_data)
new_r_sheet.update_values(crange="H4:M26", values=new_l_data)
if team['has_guide']:
if team["has_guide"]:
post_ratings_guide(team, self.bot, this_sheet=new_sheet)
team = await db_patch('teams', object_id=team['id'], params=[('gsheet', new_sheet.id)])
team = await db_patch(
"teams", object_id=team["id"], params=[("gsheet", new_sheet.id)]
)
await refresh_sheet(team, self.bot, sheets)
conf_message = f'Alright, your sheet is linked to your team - good luck'
conf_message = f"Alright, your sheet is linked to your team - good luck"
if owner_team == team:
conf_message += ' this season!'
conf_message += " this season!"
else:
conf_message += ' on your run!'
conf_message += f'\n\n{HELP_SHEET_SCRIPTS}'
await response.edit(content=f'{conf_message}')
conf_message += " on your run!"
conf_message += f"\n\n{HELP_SHEET_SCRIPTS}"
await response.edit(content=f"{conf_message}")
async def setup(bot):
"""Setup function for the TeamSetup cog."""
await bot.add_cog(TeamSetup(bot))
await bot.add_cog(TeamSetup(bot))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -12,65 +12,89 @@ import datetime
from sqlmodel import Session
from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev
from helpers import (
ACTIVE_EVENT_LITERAL, PD_PLAYERS_ROLE_NAME, get_team_embed, get_team_by_owner,
legal_channel, Confirm, send_to_channel
ACTIVE_EVENT_LITERAL,
PD_PLAYERS_ROLE_NAME,
get_team_embed,
get_team_by_owner,
legal_channel,
Confirm,
send_to_channel,
)
from helpers.utils import get_roster_sheet, get_cal_user
from utilities.buttons import ask_with_buttons
from in_game.gameplay_models import engine
from in_game.gameplay_queries import get_team_or_none
logger = logging.getLogger('discord_app')
logger = logging.getLogger("discord_app")
# Try to import gauntlets module, provide fallback if not available
try:
import gauntlets
GAUNTLETS_AVAILABLE = True
except ImportError:
logger.warning("Gauntlets module not available - gauntlet commands will have limited functionality")
logger.warning(
"Gauntlets module not available - gauntlet commands will have limited functionality"
)
GAUNTLETS_AVAILABLE = False
gauntlets = None
class Gauntlet(commands.Cog):
"""Gauntlet game mode functionality for Paper Dynasty."""
def __init__(self, bot):
self.bot = bot
group_gauntlet = app_commands.Group(name='gauntlets', description='Check your progress or start a new Gauntlet')
group_gauntlet = app_commands.Group(
name="gauntlets", description="Check your progress or start a new Gauntlet"
)
@group_gauntlet.command(name='status', description='View status of current Gauntlet run')
@group_gauntlet.command(
name="status", description="View status of current Gauntlet run"
)
@app_commands.describe(
team_abbrev='To check the status of a team\'s active run, enter their abbreviation'
team_abbrev="To check the status of a team's active run, enter their abbreviation"
)
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
async def gauntlet_run_command(
self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL, # type: ignore
team_abbrev: Optional[str] = None):
self,
interaction: discord.Interaction,
event_name: ACTIVE_EVENT_LITERAL, # type: ignore
team_abbrev: Optional[str] = None,
):
"""View status of current gauntlet run - corrected to match original business logic."""
await interaction.response.defer()
e_query = await db_get('events', params=[("name", event_name), ("active", True)])
if not e_query or e_query.get('count', 0) == 0:
await interaction.edit_original_response(content=f'Hmm...looks like that event is inactive.')
e_query = await db_get(
"events", params=[("name", event_name), ("active", True)]
)
if not e_query or e_query.get("count", 0) == 0:
await interaction.edit_original_response(
content=f"Hmm...looks like that event is inactive."
)
return
else:
this_event = e_query['events'][0]
this_event = e_query["events"][0]
this_run, this_team = None, None
if team_abbrev:
if 'Gauntlet-' not in team_abbrev:
team_abbrev = f'Gauntlet-{team_abbrev}'
t_query = await db_get('teams', params=[('abbrev', team_abbrev)])
if t_query and t_query.get('count', 0) != 0:
this_team = t_query['teams'][0]
r_query = await db_get('gauntletruns', params=[
('team_id', this_team['id']), ('is_active', True), ('gauntlet_id', this_event['id'])
])
if "Gauntlet-" not in team_abbrev:
team_abbrev = f"Gauntlet-{team_abbrev}"
t_query = await db_get("teams", params=[("abbrev", team_abbrev)])
if t_query and t_query.get("count", 0) != 0:
this_team = t_query["teams"][0]
r_query = await db_get(
"gauntletruns",
params=[
("team_id", this_team["id"]),
("is_active", True),
("gauntlet_id", this_event["id"]),
],
)
if r_query and r_query.get('count', 0) != 0:
this_run = r_query['runs'][0]
if r_query and r_query.get("count", 0) != 0:
this_run = r_query["runs"][0]
else:
await interaction.edit_original_response(
content=f'I do not see an active run for the {this_team["lname"]}.'
@ -78,7 +102,7 @@ class Gauntlet(commands.Cog):
return
else:
await interaction.edit_original_response(
content=f'I do not see an active run for {team_abbrev.upper()}.'
content=f"I do not see an active run for {team_abbrev.upper()}."
)
return
@ -86,127 +110,168 @@ class Gauntlet(commands.Cog):
if GAUNTLETS_AVAILABLE and gauntlets:
await interaction.edit_original_response(
content=None,
embed=await gauntlets.get_embed(this_run, this_event, this_team) # type: ignore
embed=await gauntlets.get_embed(this_run, this_event, this_team), # type: ignore
)
else:
await interaction.edit_original_response(
content='Gauntlet status unavailable - gauntlets module not loaded.'
content="Gauntlet status unavailable - gauntlets module not loaded."
)
@group_gauntlet.command(name='start', description='Start a new Gauntlet run')
@group_gauntlet.command(name="start", description="Start a new Gauntlet run")
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
async def gauntlet_start_command(self, interaction: discord.Interaction):
"""Start a new gauntlet run."""
# Channel restriction - must be in a 'hello' channel (private channel)
if interaction.channel and hasattr(interaction.channel, 'name') and 'hello' not in str(interaction.channel.name):
if (
interaction.channel
and hasattr(interaction.channel, "name")
and "hello" not in str(interaction.channel.name)
):
await interaction.response.send_message(
content='The draft will probably take you about 15 minutes. Why don\'t you head to your private '
'channel to run the draft?',
ephemeral=True
content="The draft will probably take you about 15 minutes. Why don't you head to your private "
"channel to run the draft?",
ephemeral=True,
)
return
logger.info(f'Starting a gauntlet run for user {interaction.user.name}')
logger.info(f"Starting a gauntlet run for user {interaction.user.name}")
await interaction.response.defer()
with Session(engine) as session:
main_team = await get_team_or_none(session, gm_id=interaction.user.id, main_team=True)
draft_team = await get_team_or_none(session, gm_id=interaction.user.id, gauntlet_team=True)
main_team = await get_team_or_none(
session, gm_id=interaction.user.id, main_team=True
)
draft_team = await get_team_or_none(
session, gm_id=interaction.user.id, gauntlet_team=True
)
# Get active events
e_query = await db_get('events', params=[("active", True)])
if not e_query or e_query.get('count', 0) == 0:
await interaction.edit_original_response(content='Hmm...I don\'t see any active events.')
e_query = await db_get("events", params=[("active", True)])
if not e_query or e_query.get("count", 0) == 0:
await interaction.edit_original_response(
content="Hmm...I don't see any active events."
)
return
elif e_query.get('count', 0) == 1:
this_event = e_query['events'][0]
elif e_query.get("count", 0) == 1:
this_event = e_query["events"][0]
else:
event_choice = await ask_with_buttons(
interaction,
button_options=[x['name'] for x in e_query['events']],
question='Which event would you like to take on?',
button_options=[x["name"] for x in e_query["events"]],
question="Which event would you like to take on?",
timeout=3,
delete_question=False
delete_question=False,
)
this_event = [event for event in e_query['events'] if event['name'] == event_choice][0]
logger.info(f'this_event: {this_event}')
this_event = [
event
for event in e_query["events"]
if event["name"] == event_choice
][0]
logger.info(f"this_event: {this_event}")
first_flag = draft_team is None
if draft_team is not None:
r_query = await db_get(
'gauntletruns',
params=[('team_id', draft_team.id), ('gauntlet_id', this_event['id']), ('is_active', True)]
"gauntletruns",
params=[
("team_id", draft_team.id),
("gauntlet_id", this_event["id"]),
("is_active", True),
],
)
if r_query and r_query.get('count', 0) != 0:
if r_query and r_query.get("count", 0) != 0:
await interaction.edit_original_response(
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
try:
draft_embed = await gauntlets.run_draft(interaction, main_team, this_event, draft_team) # type: ignore
draft_embed = await gauntlets.run_draft(interaction, main_team, this_event, draft_team) # type: ignore
except ZeroDivisionError as e:
logger.error(
f'ZeroDivisionError in {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}'
)
await gauntlets.wipe_team(draft_team, interaction) # type: ignore
await interaction.followup.send(
content=f"Shoot - it looks like we ran into an issue running the draft. I had to clear it all out "
f"for now. I let {get_cal_user(interaction).mention} know what happened so he better "
f"fix it quick."
)
return
except Exception as e:
logger.error(f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}')
await gauntlets.wipe_team(draft_team, interaction) # type: ignore
logger.error(
f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}'
)
await gauntlets.wipe_team(draft_team, interaction) # type: ignore
await interaction.followup.send(
content=f'Shoot - it looks like we ran into an issue running the draft. I had to clear it all out '
f'for now. I let {get_cal_user(interaction).mention} know what happened so he better '
f'fix it quick.'
content=f"Shoot - it looks like we ran into an issue running the draft. I had to clear it all out "
f"for now. I let {get_cal_user(interaction).mention} know what happened so he better "
f"fix it quick."
)
return
if first_flag:
await interaction.followup.send(
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'2) Run `/newsheet` to link it to your Gauntlet team\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"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"]}`'
)
else:
await interaction.followup.send(
f'Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> '
f'**Data Imports** -> **My Cards** then you can set your lineup here and you\'ll be ready to go!\n\n'
f'{get_roster_sheet(draft_team)}'
f"Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> "
f"**Data Imports** -> **My Cards** then you can set your lineup here and you'll be ready to go!\n\n"
f"{get_roster_sheet(draft_team)}"
)
await send_to_channel(
bot=self.bot,
channel_name='pd-news-ticker',
channel_name="pd-news-ticker",
content=f'The {main_team.lname if main_team else "Unknown Team"} have entered the {this_event["name"]} Gauntlet!',
embed=draft_embed
embed=draft_embed,
)
@group_gauntlet.command(name='reset', description='Wipe your current team so you can re-draft')
@group_gauntlet.command(
name="reset", description="Wipe your current team so you can re-draft"
)
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
async def gauntlet_reset_command(self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL): # type: ignore
async def gauntlet_reset_command(self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL): # type: ignore
"""Reset current gauntlet run."""
await interaction.response.defer()
main_team = await get_team_by_owner(interaction.user.id)
draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}')
if draft_team is None:
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?"
)
return
e_query = await db_get('events', params=[("name", event_name), ("active", True)])
if e_query['count'] == 0:
await interaction.edit_original_response(content='Hmm...looks like that event is inactive.')
e_query = await db_get(
"events", params=[("name", event_name), ("active", True)]
)
if e_query["count"] == 0:
await interaction.edit_original_response(
content="Hmm...looks like that event is inactive."
)
return
else:
this_event = e_query['events'][0]
this_event = e_query["events"][0]
r_query = await db_get('gauntletruns', params=[
('team_id', draft_team['id']), ('is_active', True), ('gauntlet_id', this_event['id'])
])
r_query = await db_get(
"gauntletruns",
params=[
("team_id", draft_team["id"]),
("is_active", True),
("gauntlet_id", this_event["id"]),
],
)
if r_query and r_query.get('count', 0) != 0:
this_run = r_query['runs'][0]
if r_query and r_query.get("count", 0) != 0:
this_run = r_query["runs"][0]
else:
await interaction.edit_original_response(
content=f'I do not see an active run for the {draft_team["lname"]}.'
@ -214,27 +279,24 @@ class Gauntlet(commands.Cog):
return
view = Confirm(responders=[interaction.user], timeout=60)
conf_string = f'Are you sure you want to wipe your active run?'
await interaction.edit_original_response(
content=conf_string,
view=view
)
conf_string = f"Are you sure you want to wipe your active run?"
await interaction.edit_original_response(content=conf_string, view=view)
await view.wait()
if view.value:
await gauntlets.end_run(this_run, this_event, draft_team, force_end=True) # type: ignore
await gauntlets.end_run(this_run, this_event, draft_team, force_end=True) # type: ignore
await interaction.edit_original_response(
content=f'Your {event_name} run has been reset. Run `/gauntlets start` to redraft!',
view=None
content=f"Your {event_name} run has been reset. Run `/gauntlets start` to redraft!",
view=None,
)
else:
await interaction.edit_original_response(
content=f'~~{conf_string}~~\n\nNo worries, I will leave it active.',
view=None
content=f"~~{conf_string}~~\n\nNo worries, I will leave it active.",
view=None,
)
async def setup(bot):
"""Setup function for the Gauntlet cog."""
await bot.add_cog(Gauntlet(bot))
await bot.add_cog(Gauntlet(bot))

423
cogs/refractor.py Normal file
View File

@ -0,0 +1,423 @@
"""
Refractor cog /refractor status slash command.
Displays a team's refractor progress: formula value vs next threshold
with a progress bar, paginated 10 cards per page.
Tier names: Base Card (T0) / Base Chrome (T1) / Refractor (T2) /
Gold Refractor (T3) / Superfractor (T4).
Depends on WP-07 (refractor/cards API endpoint).
"""
import logging
from typing import Optional
import discord
from discord import app_commands
from discord.app_commands import Choice
from discord.ext import commands
from api_calls import db_get
from helpers.discord_utils import get_team_embed
from helpers.main import get_team_by_owner
logger = logging.getLogger("discord_app")
PAGE_SIZE = 10
TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
# Tier-specific labels for the status display.
TIER_SYMBOLS = {
0: "Base", # Base Card — used in summary only, not in per-card display
1: "T1", # Base Chrome
2: "T2", # Refractor
3: "T3", # Gold Refractor
4: "T4★", # Superfractor
}
_FULL_BAR = "" * 12
# Embed accent colors per tier (used for single-tier filtered views).
TIER_COLORS = {
0: 0x95A5A6, # slate grey
1: 0xBDC3C7, # silver/chrome
2: 0x3498DB, # refractor blue
3: 0xF1C40F, # gold
4: 0x1ABC9C, # teal superfractor
}
def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
"""
Render a Unicode block progress bar.
Examples:
render_progress_bar(120, 149) -> '▰▰▰▰▰▰▰▰▰▰▱▱'
render_progress_bar(0, 100) -> '▱▱▱▱▱▱▱▱▱▱▱▱'
render_progress_bar(100, 100) -> '▰▰▰▰▰▰▰▰▰▰▰▰'
"""
if threshold <= 0:
filled = width
else:
ratio = max(0.0, min(current / threshold, 1.0))
filled = round(ratio * width)
empty = width - filled
return f"{'' * filled}{'' * empty}"
def _pct_label(current: int, threshold: int) -> str:
"""Return a percentage string like '80%'."""
if threshold <= 0:
return "100%"
return f"{min(current / threshold, 1.0):.0%}"
def format_refractor_entry(card_state: dict) -> str:
"""
Format a single card state dict as a compact two-line display string.
Output example (base card no suffix):
**Mike Trout**
120/149 (80%)
Output example (evolved suffix tag):
**Mike Trout** Base Chrome [T1]
120/149 (80%)
Output example (fully evolved):
**Barry Bonds** Superfractor [T4]
`MAX`
"""
player_name = card_state.get("player_name", "Unknown")
current_tier = card_state.get("current_tier", 0)
formula_value = int(card_state.get("current_value", 0))
next_threshold = int(card_state.get("next_threshold") or 0) or None
if current_tier == 0:
first_line = f"**{player_name}**"
else:
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
symbol = TIER_SYMBOLS.get(current_tier, "")
first_line = f"**{player_name}** — {tier_label} [{symbol}]"
if current_tier >= 4 or next_threshold is None:
second_line = f"{_FULL_BAR} `MAX`"
else:
bar = render_progress_bar(formula_value, next_threshold)
pct = _pct_label(formula_value, next_threshold)
second_line = f"{bar} {formula_value}/{next_threshold} ({pct})"
return f"{first_line}\n{second_line}"
def build_tier_summary(items: list, total_count: int) -> str:
"""
Build a one-line summary of tier distribution from the current page items.
Returns something like: 'T0: 3 T1: 12 T2: 8 T3: 5 T4★: 2 — 30 total'
"""
counts = {t: 0 for t in range(5)}
for item in items:
t = item.get("current_tier", 0)
if t in counts:
counts[t] += 1
parts = []
for t in range(5):
if counts[t] > 0:
parts.append(f"{TIER_SYMBOLS[t]}: {counts[t]}")
summary = " ".join(parts) if parts else "No cards"
return f"{summary}{total_count} total"
def build_status_embed(
team: dict,
items: list,
page: int,
total_pages: int,
total_count: int,
tier_filter: Optional[int] = None,
) -> discord.Embed:
"""
Build the refractor status embed with team branding.
Uses get_team_embed for consistent team color/logo/footer, then layers
on the refractor-specific content.
"""
embed = get_team_embed(f"{team['sname']} — Refractor Status", team=team)
# Override color for single-tier views to match the tier's identity.
if tier_filter is not None and tier_filter in TIER_COLORS:
embed.color = TIER_COLORS[tier_filter]
header = build_tier_summary(items, total_count)
lines = [format_refractor_entry(state) for state in items]
body = "\n\n".join(lines) if lines else "*No cards found.*"
embed.description = f"```{header}```\n{body}"
existing_footer = embed.footer.text or ""
page_text = f"Page {page}/{total_pages}"
embed.set_footer(
text=f"{page_text} · {existing_footer}" if existing_footer else page_text,
icon_url=embed.footer.icon_url,
)
return embed
def apply_close_filter(card_states: list) -> list:
"""
Return only cards within 80% of their next tier threshold.
Fully evolved cards (T4 or no next_threshold) are excluded.
"""
result = []
for state in card_states:
current_tier = state.get("current_tier", 0)
formula_value = int(state.get("current_value", 0))
next_threshold = state.get("next_threshold")
if current_tier >= 4 or not next_threshold:
continue
if formula_value >= 0.8 * int(next_threshold):
result.append(state)
return result
def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
"""
Slice items for the given 1-indexed page.
Returns (page_items, total_pages). Page is clamped to valid range.
"""
total_pages = max(1, (len(items) + page_size - 1) // page_size)
page = max(1, min(page, total_pages))
start = (page - 1) * page_size
return items[start : start + page_size], total_pages
class RefractorPaginationView(discord.ui.View):
"""Prev/Next buttons for refractor status pagination."""
def __init__(
self,
team: dict,
page: int,
total_pages: int,
total_count: int,
params: list,
owner_id: int,
tier_filter: Optional[int] = None,
timeout: float = 120.0,
):
super().__init__(timeout=timeout)
self.team = team
self.page = page
self.total_pages = total_pages
self.total_count = total_count
self.base_params = params
self.owner_id = owner_id
self.tier_filter = tier_filter
self._update_buttons()
def _update_buttons(self):
self.prev_btn.disabled = self.page <= 1
self.next_btn.disabled = self.page >= self.total_pages
async def _fetch_and_update(self, interaction: discord.Interaction):
offset = (self.page - 1) * PAGE_SIZE
params = [(k, v) for k, v in self.base_params if k != "offset"]
params.append(("offset", offset))
data = await db_get("refractor/cards", params=params)
items = data.get("items", []) if isinstance(data, dict) else []
self.total_count = (
data.get("count", self.total_count)
if isinstance(data, dict)
else self.total_count
)
self.total_pages = max(1, (self.total_count + PAGE_SIZE - 1) // PAGE_SIZE)
self.page = min(self.page, self.total_pages)
embed = build_status_embed(
self.team,
items,
self.page,
self.total_pages,
self.total_count,
tier_filter=self.tier_filter,
)
self._update_buttons()
await interaction.response.edit_message(embed=embed, view=self)
@discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.grey)
async def prev_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.owner_id:
return
self.page = max(1, self.page - 1)
await self._fetch_and_update(interaction)
@discord.ui.button(label="Next ▶", style=discord.ButtonStyle.grey)
async def next_btn(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.owner_id:
return
self.page = min(self.total_pages, self.page + 1)
await self._fetch_and_update(interaction)
async def on_timeout(self):
self.prev_btn.disabled = True
self.next_btn.disabled = True
class Refractor(commands.Cog):
"""Refractor progress tracking slash commands."""
def __init__(self, bot):
self.bot = bot
group_refractor = app_commands.Group(
name="refractor", description="Refractor tracking commands"
)
@group_refractor.command(
name="status", description="Show your team's refractor progress"
)
@app_commands.describe(
card_type="Filter by card type",
tier="Filter by current tier",
progress="Filter by advancement progress",
page="Page number (default: 1, 10 cards per page)",
)
@app_commands.choices(
card_type=[
Choice(value="batter", name="Batter"),
Choice(value="sp", name="Starting Pitcher"),
Choice(value="rp", name="Relief Pitcher"),
],
tier=[
Choice(value="0", name="T0 — Base Card"),
Choice(value="1", name="T1 — Base Chrome"),
Choice(value="2", name="T2 — Refractor"),
Choice(value="3", name="T3 — Gold Refractor"),
Choice(value="4", name="T4 — Superfractor"),
],
progress=[
Choice(value="close", name="Close to next tier (≥80%)"),
],
)
async def refractor_status(
self,
interaction: discord.Interaction,
card_type: Optional[Choice[str]] = None,
tier: Optional[Choice[str]] = None,
progress: Optional[Choice[str]] = None,
page: int = 1,
):
"""Show a paginated view of the invoking user's team refractor progress."""
await interaction.response.defer(ephemeral=True)
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.edit_original_response(
content="You don't have a team. Sign up with /newteam first."
)
return
page = max(1, page)
offset = (page - 1) * PAGE_SIZE
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
if card_type:
params.append(("card_type", card_type.value))
if tier is not None:
params.append(("tier", tier.value))
if progress:
params.append(("progress", progress.value))
tier_filter = int(tier.value) if tier is not None else None
data = await db_get("refractor/cards", params=params)
if not data:
logger.error(
"Refractor API returned empty response for team %s", team["id"]
)
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
# API error responses contain "detail" key
if isinstance(data, dict) and "detail" in data:
logger.error(
"Refractor API error for team %s: %s", team["id"], data["detail"]
)
await interaction.edit_original_response(
content="Something went wrong fetching refractor data. Please try again later."
)
return
items = data if isinstance(data, list) else data.get("items", [])
total_count = (
data.get("count", len(items)) if isinstance(data, dict) else len(items)
)
logger.debug(
"Refractor status for team %s: %d items returned, %d total (page %d)",
team["id"],
len(items),
total_count,
page,
)
if not items:
has_filters = card_type or tier is not None or progress
if has_filters:
parts = []
if card_type:
parts.append(f"**{card_type.name}**")
if tier is not None:
parts.append(f"**{tier.name}**")
if progress:
parts.append(f"progress: **{progress.name}**")
filter_str = ", ".join(parts)
await interaction.edit_original_response(
content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
)
else:
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages)
embed = build_status_embed(
team, items, page, total_pages, total_count, tier_filter=tier_filter
)
if total_pages > 1:
view = RefractorPaginationView(
team=team,
page=page,
total_pages=total_pages,
total_count=total_count,
params=params,
owner_id=interaction.user.id,
tier_filter=tier_filter,
)
await interaction.edit_original_response(embed=embed, view=view)
else:
await interaction.edit_original_response(embed=embed)
async def setup(bot):
await bot.add_cog(Refractor(bot))

View File

@ -23,6 +23,7 @@ from helpers import (
position_name_to_abbrev,
team_role,
)
from helpers.refractor_notifs import notify_tier_completion
from in_game.ai_manager import get_starting_lineup
from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check
from in_game.gameplay_models import (
@ -1266,7 +1267,7 @@ async def checks_log_interaction(
f"Hm, I was not able to find a gauntlet team for you."
)
if not owner_team.id in [this_game.away_team_id, this_game.home_team_id]:
if owner_team.id not in [this_game.away_team_id, this_game.home_team_id]:
if interaction.user.id != 258104532423147520:
logger.exception(
f"{interaction.user.display_name} tried to run a command in Game {this_game.id} when they aren't a GM in the game."
@ -4295,33 +4296,29 @@ async def complete_game(
else this_game.away_team
)
db_game = None
try:
db_game = await db_post("games", payload=game_data)
db_ready_plays = get_db_ready_plays(session, this_game, db_game["id"])
db_ready_decisions = get_db_ready_decisions(session, this_game, db_game["id"])
except Exception as e:
await roll_back(db_game["id"])
if db_game is not None:
await roll_back(db_game["id"])
log_exception(e, msg="Unable to post game to API, rolling back")
# Post game stats to API
try:
resp = await db_post("plays", payload=db_ready_plays)
await db_post("plays", payload=db_ready_plays)
except Exception as e:
await roll_back(db_game["id"], plays=True)
log_exception(e, msg="Unable to post plays to API, rolling back")
if len(resp) > 0:
pass
try:
resp = await db_post("decisions", payload={"decisions": db_ready_decisions})
await db_post("decisions", payload={"decisions": db_ready_decisions})
except Exception as e:
await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Unable to post decisions to API, rolling back")
if len(resp) > 0:
pass
# Post game rewards (gauntlet and main team)
try:
win_reward, loss_reward = await post_game_rewards(
@ -4345,6 +4342,20 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Error while posting game rewards")
# Post-game refractor processing (non-blocking)
# WP-13: update season stats then evaluate refractor milestones for all
# participating players. Wrapped in try/except so any failure here is
# 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.commit()

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,7 @@ import logging
import discord
from api_calls import db_get, db_patch, db_post
from api_calls import db_get, db_post
from helpers.main import get_team_by_owner, get_card_embeds
from helpers.scouting import (
SCOUT_TOKEN_COST,
@ -340,9 +340,7 @@ class BuyScoutTokenView(discord.ui.View):
# Deduct currency
new_wallet = team["wallet"] - SCOUT_TOKEN_COST
try:
await db_patch(
"teams", object_id=team["id"], params=[("wallet", new_wallet)]
)
await db_post(f'teams/{team["id"]}/money/-{SCOUT_TOKEN_COST}')
except Exception as e:
logger.error(f"Failed to deduct scout token cost: {e}")
await interaction.response.edit_message(

View File

@ -1,252 +0,0 @@
"""
Discord Utilities
This module contains Discord helper functions for channels, roles, embeds,
and other Discord-specific operations.
"""
import logging
import os
import asyncio
from typing import Optional
import discord
from discord.ext import commands
from helpers.constants import SBA_COLOR, PD_SEASON, IMAGES
logger = logging.getLogger('discord_app')
async def send_to_bothole(ctx, content, embed):
"""Send a message to the pd-bot-hole channel."""
await discord.utils.get(ctx.guild.text_channels, name='pd-bot-hole') \
.send(content=content, embed=embed)
async def send_to_news(ctx, content, embed):
"""Send a message to the pd-news-ticker channel."""
await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \
.send(content=content, embed=embed)
async def typing_pause(ctx, seconds=1):
"""Show typing indicator for specified seconds."""
async with ctx.typing():
await asyncio.sleep(seconds)
async def pause_then_type(ctx, message):
"""Show typing indicator based on message length, then send message."""
async with ctx.typing():
await asyncio.sleep(len(message) / 100)
await ctx.send(message)
async def check_if_pdhole(ctx):
"""Check if the current channel is pd-bot-hole."""
if ctx.message.channel.name != 'pd-bot-hole':
await ctx.send('Slide on down to my bot-hole for running commands.')
await ctx.message.add_reaction('')
return False
return True
async def bad_channel(ctx):
"""Check if current channel is in the list of bad channels for commands."""
bad_channels = ['paper-dynasty-chat', 'pd-news-ticker']
if ctx.message.channel.name in bad_channels:
await ctx.message.add_reaction('')
bot_hole = discord.utils.get(
ctx.guild.text_channels,
name=f'pd-bot-hole'
)
await ctx.send(f'Slide on down to the {bot_hole.mention} ;)')
return True
else:
return False
def get_channel(ctx, name) -> Optional[discord.TextChannel]:
"""Get a text channel by name."""
# Handle both Context and Interaction objects
guild = ctx.guild if hasattr(ctx, 'guild') else None
if not guild:
return None
channel = discord.utils.get(
guild.text_channels,
name=name
)
if channel:
return channel
return None
async def get_emoji(ctx, name, return_empty=True):
"""Get an emoji by name, with fallback options."""
try:
emoji = await commands.converter.EmojiConverter().convert(ctx, name)
except:
if return_empty:
emoji = ''
else:
return name
return emoji
async def react_and_reply(ctx, reaction, message):
"""Add a reaction to the message and send a reply."""
await ctx.message.add_reaction(reaction)
await ctx.send(message)
async def send_to_channel(bot, channel_name, content=None, embed=None):
"""Send a message to a specific channel by name or ID."""
guild = bot.get_guild(int(os.environ.get('GUILD_ID')))
if not guild:
logger.error('Cannot send to channel - bot not logged in')
return
this_channel = discord.utils.get(guild.text_channels, name=channel_name)
if not this_channel:
this_channel = discord.utils.get(guild.text_channels, id=channel_name)
if not this_channel:
raise NameError(f'**{channel_name}** channel not found')
return await this_channel.send(content=content, embed=embed)
async def get_or_create_role(ctx, role_name, mentionable=True):
"""Get an existing role or create it if it doesn't exist."""
this_role = discord.utils.get(ctx.guild.roles, name=role_name)
if not this_role:
this_role = await ctx.guild.create_role(name=role_name, mentionable=mentionable)
return this_role
def get_special_embed(special):
"""Create an embed for a special item."""
embed = discord.Embed(title=f'{special.name} - Special #{special.get_id()}',
color=discord.Color.random(),
description=f'{special.short_desc}')
embed.add_field(name='Description', value=f'{special.long_desc}', inline=False)
if special.thumbnail.lower() != 'none':
embed.set_thumbnail(url=f'{special.thumbnail}')
if special.url.lower() != 'none':
embed.set_image(url=f'{special.url}')
return embed
def get_random_embed(title, thumb=None):
"""Create a basic embed with random color."""
embed = discord.Embed(title=title, color=discord.Color.random())
if thumb:
embed.set_thumbnail(url=thumb)
return embed
def get_team_embed(title, team=None, thumbnail: bool = True):
"""Create a team-branded embed."""
if team:
embed = discord.Embed(
title=title,
color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16)
)
embed.set_footer(text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES['logo'])
if thumbnail:
embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES['logo'])
else:
embed = discord.Embed(
title=title,
color=int(SBA_COLOR, 16)
)
embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo'])
if thumbnail:
embed.set_thumbnail(url=IMAGES['logo'])
return embed
async def create_channel_old(
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None,
allowed_roles=None):
"""Create a text channel with specified permissions (legacy version)."""
this_category = discord.utils.get(ctx.guild.categories, name=category_name)
if not this_category:
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
overwrites = {
ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True),
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
}
if allowed_members:
if isinstance(allowed_members, list):
for member in allowed_members:
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
if allowed_roles:
if isinstance(allowed_roles, list):
for role in allowed_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
this_channel = await ctx.guild.create_text_channel(
channel_name,
overwrites=overwrites,
category=this_category
)
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
return this_channel
async def create_channel(
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True,
read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None):
"""Create a text channel with specified permissions."""
# Handle both Context and Interaction objects
guild = ctx.guild if hasattr(ctx, 'guild') else None
if not guild:
raise ValueError(f'Unable to access guild from context object')
# Get bot member - different for Context vs Interaction
if hasattr(ctx, 'me'): # Context object
bot_member = ctx.me
elif hasattr(ctx, 'client'): # Interaction object
bot_member = guild.get_member(ctx.client.user.id)
else:
# Fallback - try to find bot member by getting the first member with bot=True
bot_member = next((m for m in guild.members if m.bot), None)
if not bot_member:
raise ValueError(f'Unable to find bot member in guild')
this_category = discord.utils.get(guild.categories, name=category_name)
if not this_category:
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
overwrites = {
bot_member: discord.PermissionOverwrite(read_messages=True, send_messages=True),
guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
}
if read_send_members:
for member in read_send_members:
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
if read_send_roles:
for role in read_send_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
if read_only_roles:
for role in read_only_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
this_channel = await guild.create_text_channel(
channel_name,
overwrites=overwrites,
category=this_category
)
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
return this_channel

2153
helpers.py

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@ Discord Utilities
This module contains Discord helper functions for channels, roles, embeds,
and other Discord-specific operations.
"""
import logging
import os
import asyncio
@ -13,19 +14,21 @@ import discord
from discord.ext import commands
from helpers.constants import SBA_COLOR, PD_SEASON, IMAGES
logger = logging.getLogger('discord_app')
logger = logging.getLogger("discord_app")
async def send_to_bothole(ctx, content, embed):
"""Send a message to the pd-bot-hole channel."""
await discord.utils.get(ctx.guild.text_channels, name='pd-bot-hole') \
.send(content=content, embed=embed)
await discord.utils.get(ctx.guild.text_channels, name="pd-bot-hole").send(
content=content, embed=embed
)
async def send_to_news(ctx, content, embed):
"""Send a message to the pd-news-ticker channel."""
await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \
.send(content=content, embed=embed)
await discord.utils.get(ctx.guild.text_channels, name="pd-news-ticker").send(
content=content, embed=embed
)
async def typing_pause(ctx, seconds=1):
@ -43,23 +46,20 @@ async def pause_then_type(ctx, message):
async def check_if_pdhole(ctx):
"""Check if the current channel is pd-bot-hole."""
if ctx.message.channel.name != 'pd-bot-hole':
await ctx.send('Slide on down to my bot-hole for running commands.')
await ctx.message.add_reaction('')
if ctx.message.channel.name != "pd-bot-hole":
await ctx.send("Slide on down to my bot-hole for running commands.")
await ctx.message.add_reaction("")
return False
return True
async def bad_channel(ctx):
"""Check if current channel is in the list of bad channels for commands."""
bad_channels = ['paper-dynasty-chat', 'pd-news-ticker']
bad_channels = ["paper-dynasty-chat", "pd-news-ticker"]
if ctx.message.channel.name in bad_channels:
await ctx.message.add_reaction('')
bot_hole = discord.utils.get(
ctx.guild.text_channels,
name=f'pd-bot-hole'
)
await ctx.send(f'Slide on down to the {bot_hole.mention} ;)')
await ctx.message.add_reaction("")
bot_hole = discord.utils.get(ctx.guild.text_channels, name=f"pd-bot-hole")
await ctx.send(f"Slide on down to the {bot_hole.mention} ;)")
return True
else:
return False
@ -68,14 +68,11 @@ async def bad_channel(ctx):
def get_channel(ctx, name) -> Optional[discord.TextChannel]:
"""Get a text channel by name."""
# Handle both Context and Interaction objects
guild = ctx.guild if hasattr(ctx, 'guild') else None
guild = ctx.guild if hasattr(ctx, "guild") else None
if not guild:
return None
channel = discord.utils.get(
guild.text_channels,
name=name
)
channel = discord.utils.get(guild.text_channels, name=name)
if channel:
return channel
return None
@ -87,7 +84,7 @@ async def get_emoji(ctx, name, return_empty=True):
emoji = await commands.converter.EmojiConverter().convert(ctx, name)
except:
if return_empty:
emoji = ''
emoji = ""
else:
return name
return emoji
@ -101,9 +98,13 @@ async def react_and_reply(ctx, reaction, message):
async def send_to_channel(bot, channel_name, content=None, embed=None):
"""Send a message to a specific channel by name or ID."""
guild = bot.get_guild(int(os.environ.get('GUILD_ID')))
guild_id = os.environ.get("GUILD_ID")
if not guild_id:
logger.error("GUILD_ID env var is not set")
return
guild = bot.get_guild(int(guild_id))
if not guild:
logger.error('Cannot send to channel - bot not logged in')
logger.error("Cannot send to channel - bot not logged in")
return
this_channel = discord.utils.get(guild.text_channels, name=channel_name)
@ -111,7 +112,7 @@ async def send_to_channel(bot, channel_name, content=None, embed=None):
if not this_channel:
this_channel = discord.utils.get(guild.text_channels, id=channel_name)
if not this_channel:
raise NameError(f'**{channel_name}** channel not found')
raise NameError(f"**{channel_name}** channel not found")
return await this_channel.send(content=content, embed=embed)
@ -128,14 +129,16 @@ async def get_or_create_role(ctx, role_name, mentionable=True):
def get_special_embed(special):
"""Create an embed for a special item."""
embed = discord.Embed(title=f'{special.name} - Special #{special.get_id()}',
color=discord.Color.random(),
description=f'{special.short_desc}')
embed.add_field(name='Description', value=f'{special.long_desc}', inline=False)
if special.thumbnail.lower() != 'none':
embed.set_thumbnail(url=f'{special.thumbnail}')
if special.url.lower() != 'none':
embed.set_image(url=f'{special.url}')
embed = discord.Embed(
title=f"{special.name} - Special #{special.get_id()}",
color=discord.Color.random(),
description=f"{special.short_desc}",
)
embed.add_field(name="Description", value=f"{special.long_desc}", inline=False)
if special.thumbnail.lower() != "none":
embed.set_thumbnail(url=f"{special.thumbnail}")
if special.url.lower() != "none":
embed.set_image(url=f"{special.url}")
return embed
@ -154,99 +157,125 @@ def get_team_embed(title, team=None, thumbnail: bool = True):
if team:
embed = discord.Embed(
title=title,
color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16)
color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16),
)
embed.set_footer(
text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES["logo"]
)
embed.set_footer(text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES['logo'])
if thumbnail:
embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES['logo'])
embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES["logo"])
else:
embed = discord.Embed(
title=title,
color=int(SBA_COLOR, 16)
embed = discord.Embed(title=title, color=int(SBA_COLOR, 16))
embed.set_footer(
text=f"Paper Dynasty Season {PD_SEASON}", icon_url=IMAGES["logo"]
)
embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo'])
if thumbnail:
embed.set_thumbnail(url=IMAGES['logo'])
embed.set_thumbnail(url=IMAGES["logo"])
return embed
async def create_channel_old(
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None,
allowed_roles=None):
ctx,
channel_name: str,
category_name: str,
everyone_send=False,
everyone_read=True,
allowed_members=None,
allowed_roles=None,
):
"""Create a text channel with specified permissions (legacy version)."""
this_category = discord.utils.get(ctx.guild.categories, name=category_name)
if not this_category:
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
raise ValueError(f"I couldn't find a category named **{category_name}**")
overwrites = {
ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True),
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
ctx.guild.me: discord.PermissionOverwrite(
read_messages=True, send_messages=True
),
ctx.guild.default_role: discord.PermissionOverwrite(
read_messages=everyone_read, send_messages=everyone_send
),
}
if allowed_members:
if isinstance(allowed_members, list):
for member in allowed_members:
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
overwrites[member] = discord.PermissionOverwrite(
read_messages=True, send_messages=True
)
if allowed_roles:
if isinstance(allowed_roles, list):
for role in allowed_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
overwrites[role] = discord.PermissionOverwrite(
read_messages=True, send_messages=True
)
this_channel = await ctx.guild.create_text_channel(
channel_name,
overwrites=overwrites,
category=this_category
channel_name, overwrites=overwrites, category=this_category
)
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
logger.info(f"Creating channel ({channel_name}) in ({category_name})")
return this_channel
async def create_channel(
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True,
read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None):
ctx,
channel_name: str,
category_name: str,
everyone_send=False,
everyone_read=True,
read_send_members: list = None,
read_send_roles: list = None,
read_only_roles: list = None,
):
"""Create a text channel with specified permissions."""
# Handle both Context and Interaction objects
guild = ctx.guild if hasattr(ctx, 'guild') else None
guild = ctx.guild if hasattr(ctx, "guild") else None
if not guild:
raise ValueError(f'Unable to access guild from context object')
raise ValueError(f"Unable to access guild from context object")
# Get bot member - different for Context vs Interaction
if hasattr(ctx, 'me'): # Context object
if hasattr(ctx, "me"): # Context object
bot_member = ctx.me
elif hasattr(ctx, 'client'): # Interaction object
elif hasattr(ctx, "client"): # Interaction object
bot_member = guild.get_member(ctx.client.user.id)
else:
# Fallback - try to find bot member by getting the first member with bot=True
bot_member = next((m for m in guild.members if m.bot), None)
if not bot_member:
raise ValueError(f'Unable to find bot member in guild')
raise ValueError(f"Unable to find bot member in guild")
this_category = discord.utils.get(guild.categories, name=category_name)
if not this_category:
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
raise ValueError(f"I couldn't find a category named **{category_name}**")
overwrites = {
bot_member: discord.PermissionOverwrite(read_messages=True, send_messages=True),
guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
guild.default_role: discord.PermissionOverwrite(
read_messages=everyone_read, send_messages=everyone_send
),
}
if read_send_members:
for member in read_send_members:
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
overwrites[member] = discord.PermissionOverwrite(
read_messages=True, send_messages=True
)
if read_send_roles:
for role in read_send_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
overwrites[role] = discord.PermissionOverwrite(
read_messages=True, send_messages=True
)
if read_only_roles:
for role in read_only_roles:
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
overwrites[role] = discord.PermissionOverwrite(
read_messages=True, send_messages=False
)
this_channel = await guild.create_text_channel(
channel_name,
overwrites=overwrites,
category=this_category
channel_name, overwrites=overwrites, category=this_category
)
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
logger.info(f"Creating channel ({channel_name}) in ({category_name})")
return this_channel
return this_channel

View File

@ -2,38 +2,29 @@ import asyncio
import datetime
import logging
import math
import os
import random
import traceback
import discord
import pygsheets
import aiohttp
from discord.ext import commands
from api_calls import *
from bs4 import BeautifulSoup
from difflib import get_close_matches
from dataclasses import dataclass
from typing import Optional, Literal, Union, List
from typing import Optional, Union, List
from exceptions import log_exception
from in_game.gameplay_models import Team
from constants import *
from discord_ui import *
from random_content import *
from utils import (
position_name_to_abbrev,
user_has_role,
get_roster_sheet_legacy,
get_roster_sheet,
get_player_url,
owner_only,
get_cal_user,
get_context_user,
)
from search_utils import *
from discord_utils import *
from .discord_utils import *
# Refractor tier badge prefixes for card embeds (T0 = no badge)
TIER_BADGES = {1: "BC", 2: "R", 3: "GR", 4: "SF"}
async def get_player_photo(player):
@ -122,8 +113,18 @@ async def share_channel(channel, user, read_only=False):
async def get_card_embeds(card, include_stats=False) -> list:
tier_badge = ""
try:
evo_state = await db_get(f"refractor/cards/{card['id']}")
if evo_state and evo_state.get("current_tier", 0) > 0:
tier = evo_state["current_tier"]
badge = TIER_BADGES.get(tier)
tier_badge = f"[{badge}] " if badge else ""
except Exception:
pass
embed = discord.Embed(
title=f"{card['player']['p_name']}",
title=f"{tier_badge}{card['player']['p_name']}",
color=int(card["player"]["rarity"]["color"], 16),
)
# embed.description = card['team']['lname']
@ -166,7 +167,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
]
if any(bool_list):
if count == 1:
coll_string = f"Only you"
coll_string = "Only you"
else:
coll_string = (
f"You and {count - 1} other{'s' if count - 1 != 1 else ''}"
@ -174,18 +175,26 @@ async def get_card_embeds(card, include_stats=False) -> list:
elif count:
coll_string = f"{count} other team{'s' if count != 1 else ''}"
else:
coll_string = f"0 teams"
coll_string = "0 teams"
embed.add_field(name="Collected By", value=coll_string)
else:
embed.add_field(
name="Collected By", value=f"{count} team{'s' if count != 1 else ''}"
)
# TODO: check for dupes with the included paperdex data
# if card['team']['lname'] != 'Paper Dynasty':
# team_dex = await db_get('cards', params=[("player_id", card["player"]["player_id"]), ('team_id', card['team']['id'])])
# count = 1 if not team_dex['count'] else team_dex['count']
# embed.add_field(name='# Dupes', value=f'{count - 1} dupe{"s" if count - 1 != 1 else ""}')
if card["team"]["lname"] != "Paper Dynasty":
team_dex = await db_get(
"cards",
params=[
("player_id", card["player"]["player_id"]),
("team_id", card["team"]["id"]),
],
)
if team_dex is not None:
dupe_count = max(0, team_dex["count"] - 1)
embed.add_field(
name="Dupes", value=f"{dupe_count} dupe{'s' if dupe_count != 1 else ''}"
)
# embed.add_field(name='Team', value=f'{card["player"]["mlbclub"]}')
if card["player"]["franchise"] != "Pokemon":
@ -215,7 +224,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
except Exception as e:
logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True
f"could not pull evolution: {e}", exc_info=True, stack_info=True
)
if "420420" not in card["player"]["strat_code"]:
try:
@ -226,7 +235,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
except Exception as e:
logging.error(
"could not pull evolution: {e}", exc_info=True, stack_info=True
f"could not pull evolution: {e}", exc_info=True, stack_info=True
)
if include_stats:
@ -326,7 +335,7 @@ async def display_cards(
)
try:
cards.sort(key=lambda x: x["player"]["rarity"]["value"])
logger.debug(f"Cards sorted successfully")
logger.debug("Cards sorted successfully")
card_embeds = [await get_card_embeds(x) for x in cards]
logger.debug(f"Created {len(card_embeds)} card embeds")
@ -347,15 +356,15 @@ async def display_cards(
r_emoji = ""
view.left_button.disabled = True
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Close Pack"
view.cancel_button.label = "Close Pack"
view.right_button.label = f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
if len(cards) == 1:
view.right_button.disabled = True
logger.debug(f"Pagination view created successfully")
logger.debug("Pagination view created successfully")
if pack_cover:
logger.debug(f"Sending pack cover message")
logger.debug("Sending pack cover message")
msg = await channel.send(
content=None,
embed=image_embed(pack_cover, title=f"{team['lname']}", desc=pack_name),
@ -367,7 +376,7 @@ async def display_cards(
content=None, embeds=card_embeds[page_num], view=view
)
logger.debug(f"Initial message sent successfully")
logger.debug("Initial message sent successfully")
except Exception as e:
logger.error(
f"Error creating view or sending initial message: {e}", exc_info=True
@ -384,12 +393,12 @@ async def display_cards(
f"{user.mention} you've got {len(cards)} cards here"
)
logger.debug(f"Follow-up message sent successfully")
logger.debug("Follow-up message sent successfully")
except Exception as e:
logger.error(f"Error sending follow-up message: {e}", exc_info=True)
return False
logger.debug(f"Starting main interaction loop")
logger.debug("Starting main interaction loop")
while True:
try:
logger.debug(f"Waiting for user interaction on page {page_num}")
@ -455,7 +464,7 @@ async def display_cards(
),
view=view,
)
logger.debug(f"MVP display updated successfully")
logger.debug("MVP display updated successfully")
except Exception as e:
logger.error(
f"Error processing shiny card on page {page_num}: {e}", exc_info=True
@ -463,19 +472,19 @@ async def display_cards(
# Continue with regular flow instead of crashing
try:
tmp_msg = await channel.send(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
await follow_up.edit(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
await tmp_msg.delete()
except discord.errors.NotFound:
# Role might not exist or message was already deleted
await follow_up.edit(content=f"We've got an MVP!")
await follow_up.edit(content="We've got an MVP!")
except Exception as e:
# Log error but don't crash the function
logger.error(f"Error handling MVP notification: {e}")
await follow_up.edit(content=f"We've got an MVP!")
await follow_up.edit(content="We've got an MVP!")
await view.wait()
view = Pagination([user], timeout=10)
@ -483,7 +492,7 @@ async def display_cards(
view.right_button.label = (
f"Next: {page_num + 2}/{len(card_embeds)}{r_emoji}"
)
view.cancel_button.label = f"Close Pack"
view.cancel_button.label = "Close Pack"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(card_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(card_embeds)}"
@ -531,7 +540,7 @@ async def embed_pagination(
l_emoji = ""
r_emoji = ""
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}"
view.cancel_button.label = f"Cancel"
view.cancel_button.label = "Cancel"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
@ -566,7 +575,7 @@ async def embed_pagination(
view = Pagination([user], timeout=timeout)
view.right_button.label = f"Next: {page_num + 2}/{len(all_embeds)}{r_emoji}"
view.cancel_button.label = f"Cancel"
view.cancel_button.label = "Cancel"
view.left_button.label = f"{l_emoji}Prev: {page_num}/{len(all_embeds)}"
if page_num == 0:
view.left_button.label = f"{l_emoji}Prev: -/{len(all_embeds)}"
@ -880,7 +889,7 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
timeout=10,
)
if not success:
raise ConnectionError(f"Failed to create this pack of cards.")
raise ConnectionError("Failed to create this pack of cards.")
await db_patch(
"packs",
@ -946,7 +955,7 @@ def get_sheets(bot):
except Exception as e:
logger.error(f"Could not grab sheets auth: {e}")
raise ConnectionError(
f"Bot has not authenticated with discord; please try again in 1 minute."
"Bot has not authenticated with discord; please try again in 1 minute."
)
@ -1056,7 +1065,7 @@ def get_blank_team_card(player):
def get_rosters(team, bot, roster_num: Optional[int] = None) -> list:
sheets = get_sheets(bot)
this_sheet = sheets.open_by_key(team["gsheet"])
r_sheet = this_sheet.worksheet_by_title(f"My Rosters")
r_sheet = this_sheet.worksheet_by_title("My Rosters")
logger.debug(f"this_sheet: {this_sheet} / r_sheet = {r_sheet}")
all_rosters = [None, None, None]
@ -1137,11 +1146,11 @@ def get_roster_lineups(team, bot, roster_num, lineup_num) -> list:
try:
lineup_cells = [(row[0].value, int(row[1].value)) for row in raw_cells]
except ValueError as e:
except ValueError:
logger.error(f"Could not pull roster for {team['abbrev']} due to a ValueError")
raise ValueError(
f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
f"get the card IDs"
"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
"get the card IDs"
)
logger.debug(f"lineup_cells: {lineup_cells}")
@ -1536,7 +1545,7 @@ def get_ratings_guide(sheets):
}
for x in p_data
]
except Exception as e:
except Exception:
return {"valid": False}
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers}
@ -1748,7 +1757,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
pack_ids = await roll_for_cards(all_packs)
if not pack_ids:
logger.error(f"open_packs - unable to roll_for_cards for packs: {all_packs}")
raise ValueError(f"I was not able to unpack these cards")
raise ValueError("I was not able to unpack these cards")
all_cards = []
for p_id in pack_ids:
@ -1759,7 +1768,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
if not all_cards:
logger.error(f"open_packs - unable to get cards for packs: {pack_ids}")
raise ValueError(f"I was not able to display these cards")
raise ValueError("I was not able to display these cards")
# Present cards to opening channel
if type(context) == commands.Context:
@ -1818,7 +1827,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -1836,7 +1845,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.left_button.disabled = True
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
@ -1879,7 +1888,7 @@ async def get_choice_from_cards(
view = Pagination([interaction.user], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1:
@ -1925,7 +1934,7 @@ async def open_choice_pack(
players = pl["players"]
elif pack_type == "Team Choice":
if this_pack["pack_team"] is None:
raise KeyError(f"Team not listed for Team Choice pack")
raise KeyError("Team not listed for Team Choice pack")
d1000 = random.randint(1, 1000)
pack_cover = this_pack["pack_team"]["logo"]
@ -1964,7 +1973,7 @@ async def open_choice_pack(
rarity_id += 1
elif pack_type == "Promo Choice":
if this_pack["pack_cardset"] is None:
raise KeyError(f"Cardset not listed for Promo Choice pack")
raise KeyError("Cardset not listed for Promo Choice pack")
d1000 = random.randint(1, 1000)
pack_cover = IMAGES["mvp-hype"]
@ -2021,8 +2030,8 @@ async def open_choice_pack(
rarity_id += 3
if len(players) == 0:
logger.error(f"Could not create choice pack")
raise ConnectionError(f"Could not create choice pack")
logger.error("Could not create choice pack")
raise ConnectionError("Could not create choice pack")
if type(context) == commands.Context:
author = context.author
@ -2045,7 +2054,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30)
view.left_button.disabled = True
view.left_button.label = f"Prev: -/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.cancel_button.disabled = True
view.right_button.label = f"Next: 1/{len(card_embeds)}"
@ -2063,10 +2072,10 @@ async def open_choice_pack(
)
if rarity_id >= 5:
tmp_msg = await pack_channel.send(
content=f"<@&1163537676885033010> we've got an MVP!"
content="<@&1163537676885033010> we've got an MVP!"
)
else:
tmp_msg = await pack_channel.send(content=f"We've got a choice pack here!")
tmp_msg = await pack_channel.send(content="We've got a choice pack here!")
while True:
await view.wait()
@ -2081,7 +2090,7 @@ async def open_choice_pack(
)
except Exception as e:
logger.error(f"failed to create cards: {e}")
raise ConnectionError(f"Failed to distribute these cards.")
raise ConnectionError("Failed to distribute these cards.")
await db_patch(
"packs",
@ -2115,7 +2124,7 @@ async def open_choice_pack(
view = Pagination([author], timeout=30)
view.left_button.label = f"Prev: {page_num - 1}/{len(card_embeds)}"
view.cancel_button.label = f"Take This Card"
view.cancel_button.label = "Take This Card"
view.cancel_button.style = discord.ButtonStyle.success
view.right_button.label = f"Next: {page_num + 1}/{len(card_embeds)}"
if page_num == 1:

108
helpers/refractor_notifs.py Normal file
View File

@ -0,0 +1,108 @@
"""
Refractor Tier Completion Notifications
Builds and sends Discord embeds when a player completes a refractor tier
during post-game evaluation. Each tier-up event gets its own embed.
Notification failures are non-fatal: the send is wrapped in try/except so
a Discord API hiccup never disrupts game flow.
"""
import logging
import discord
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"
def build_tier_up_embed(tier_up: dict) -> discord.Embed:
"""Build a Discord embed for a tier-up event.
Parameters
----------
tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
Returns
-------
discord.Embed
A fully configured embed ready to send to a channel.
"""
player_name: str = tier_up["player_name"]
new_tier: int = tier_up["new_tier"]
track_name: str = tier_up["track_name"]
tier_name = TIER_NAMES.get(new_tier, f"Tier {new_tier}")
color = TIER_COLORS.get(new_tier, 0x2ECC71)
if new_tier >= 4:
# Superfractor — special title and description.
embed = discord.Embed(
title="SUPERFRACTOR!",
description=(
f"**{player_name}** has reached maximum refractor tier on the **{track_name}** track"
),
color=color,
)
embed.add_field(
name="Rating Boosts",
value="Rating boosts coming in a future update!",
inline=False,
)
else:
embed = discord.Embed(
title="Refractor Tier Up!",
description=(
f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track"
),
color=color,
)
embed.set_footer(text=FOOTER_TEXT)
return embed
async def notify_tier_completion(
channel: discord.abc.Messageable, tier_up: dict
) -> None:
"""Send a tier-up notification embed to the given channel.
Non-fatal: any exception during send is caught and logged so that a
Discord API failure never interrupts game evaluation.
Parameters
----------
channel:
A discord.abc.Messageable (e.g. discord.TextChannel).
tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
"""
try:
embed = build_tier_up_embed(tier_up)
await channel.send(embed=embed)
except Exception as exc:
logger.error(
"Failed to send tier-up notification for %s (tier %s): %s",
tier_up.get("player_name", "unknown"),
tier_up.get("new_tier"),
exc,
)

View File

@ -1315,47 +1315,9 @@ def create_test_games():
session.commit()
def select_speed_testing():
with Session(engine) as session:
game_1 = session.exec(select(Game).where(Game.id == 1)).one()
ss_search_start = datetime.datetime.now()
man_ss = [x for x in game_1.lineups if x.position == 'SS' and x.active]
ss_search_end = datetime.datetime.now()
ss_query_start = datetime.datetime.now()
query_ss = session.exec(select(Lineup).where(Lineup.game == game_1, Lineup.position == 'SS', Lineup.active == True)).all()
ss_query_end = datetime.datetime.now()
manual_time = ss_search_end - ss_search_start
query_time = ss_query_end - ss_query_start
print(f'Manual Shortstops: time: {manual_time.microseconds} ms / {man_ss}')
print(f'Query Shortstops: time: {query_time.microseconds} ms / {query_ss}')
print(f'Game: {game_1}')
games = session.exec(select(Game).where(Game.active == True)).all()
print(f'len(games): {len(games)}')
def select_all_testing():
with Session(engine) as session:
game_search = session.exec(select(Team)).all()
for game in game_search:
print(f'Game: {game}')
# def select_specic_fields():
# with Session(engine) as session:
# games = session.exec(select(Game.id, Game.away_team, Game.home_team))
# print(f'Games: {games}')
# print(f'.all(): {games.all()}')
def main():
create_db_and_tables()
create_test_games()
# select_speed_testing()
# select_all_testing()
if __name__ == "__main__":

View File

@ -124,7 +124,7 @@ async def get_team_or_none(
logger.info(f'Refreshing this_team')
session.refresh(this_team)
return this_team
except:
except Exception:
logger.info(f'Team not found, adding to db')
session.add(db_team)
session.commit()
@ -235,7 +235,7 @@ async def get_player_or_none(session: Session, player_id: int, skip_cache: bool
logger.info(f'Refreshing this_player')
session.refresh(this_player)
return this_player
except:
except Exception:
session.add(db_player)
session.commit()
session.refresh(db_player)
@ -307,7 +307,7 @@ async def get_batter_scouting_or_none(session: Session, card: Card, skip_cache:
# logger.info(f'Refreshing this_card')
# session.refresh(this_card)
# return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
this_card = db_bc
session.add(this_card)
@ -330,7 +330,7 @@ async def get_batter_scouting_or_none(session: Session, card: Card, skip_cache:
# logger.info(f'Refreshing this_card')
# session.refresh(this_card)
# return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
this_vl_rating = db_vl
session.add(this_vl_rating)
@ -353,7 +353,7 @@ async def get_batter_scouting_or_none(session: Session, card: Card, skip_cache:
# logger.info(f'Refreshing this_card')
# session.refresh(this_card)
# return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
this_vr_rating = db_vr
session.add(this_vr_rating)
@ -444,7 +444,7 @@ async def get_pitcher_scouting_or_none(session: Session, card: Card, skip_cache:
# logger.info(f'Refreshing this_card')
# session.refresh(this_card)
# return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
this_card = db_bc
session.add(this_card)
@ -467,7 +467,7 @@ async def get_pitcher_scouting_or_none(session: Session, card: Card, skip_cache:
# logger.info(f'Refreshing this_card')
# session.refresh(this_card)
# return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
this_vl_rating = db_vl
session.add(this_vl_rating)
@ -490,7 +490,7 @@ async def get_pitcher_scouting_or_none(session: Session, card: Card, skip_cache:
# logger.info(f'Refreshing this_card')
# session.refresh(this_card)
# return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
this_vr_rating = db_vr
session.add(this_vr_rating)
@ -699,7 +699,7 @@ async def get_or_create_ai_card(session: Session, player: Player, team: Team, sk
logger.info(f'Refreshing this_card')
session.refresh(this_card)
return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
session.add(db_card)
session.commit()
@ -808,7 +808,7 @@ async def get_card_or_none(session: Session, card_id: int, skip_cache: bool = Fa
logger.info(f'Refreshing this_card')
session.refresh(this_card)
return this_card
except:
except Exception:
logger.info(f'Card not found, adding to db')
session.add(db_card)
session.commit()

View File

@ -1,5 +1,4 @@
import discord
import datetime
import logging
from logging.handlers import RotatingFileHandler
import asyncio
@ -54,6 +53,7 @@ COGS = [
"cogs.players",
"cogs.gameplay",
"cogs.economy_new.scouting",
"cogs.refractor",
]
intents = discord.Intents.default()

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.0.2
pytest-asyncio==1.3.0

View File

@ -1,15 +1,13 @@
discord.py
pygsheets
pydantic
gsheets
bs4
peewee
sqlmodel
alembic
pytest
pytest-asyncio
numpy<2
pandas
psycopg2-binary
aiohttp
discord.py==2.7.1
pygsheets==2.0.6
pydantic==2.12.5
gsheets==0.6.1
bs4==0.0.2
peewee==4.0.1
sqlmodel==0.0.37
alembic==1.18.4
numpy==1.26.4
pandas==3.0.1
psycopg2-binary==2.9.11
aiohttp==3.13.3
# psycopg[binary]

37
ruff.toml Normal file
View File

@ -0,0 +1,37 @@
# Ruff configuration for paper-dynasty discord bot
# See https://docs.astral.sh/ruff/configuration/
[lint]
# Rules suppressed globally because they reflect intentional project patterns:
# F403/F405: star imports — __init__.py files use `from .module import *` for re-exports
# E712: SQLAlchemy/SQLModel ORM comparisons require == syntax (not `is`)
# F541: f-strings without placeholders — 1000+ legacy occurrences; cosmetic, deferred
ignore = ["F403", "F405", "F541", "E712"]
# Per-file suppressions for pre-existing violations in legacy code.
# New files outside these paths get the full rule set.
# Remove entries here as files are cleaned up.
[lint.per-file-ignores]
# Core cogs — F841/F401 widespread; E711/E713/F811 pre-existing
"cogs/**" = ["F841", "F401", "E711", "E713", "F811"]
# Game engine — F841/F401 widespread; E722/F811 pre-existing bare-excepts and redefinitions
"in_game/**" = ["F841", "F401", "E722", "F811"]
# Helpers — F841/F401 widespread; E721/E722 pre-existing type-comparison and bare-excepts
"helpers/**" = ["F841", "F401", "E721", "E722"]
# Game logic and commands
"command_logic/**" = ["F841", "F401"]
# Test suite — E711/F811/F821 pre-existing test assertion patterns
"tests/**" = ["F841", "F401", "E711", "F811", "F821"]
# Utilities
"utilities/**" = ["F841", "F401"]
# Migrations
"migrations/**" = ["F401"]
# Top-level legacy files
"db_calls_gameplay.py" = ["F841", "F401"]
"gauntlets.py" = ["F841", "F401"]
"dice.py" = ["F841", "E711"]
"manual_pack_distribution.py" = ["F841"]
"play_lock.py" = ["F821"]
"paperdynasty.py" = ["F401"]
"api_calls.py" = ["F401"]
"health_server.py" = ["F401"]

View File

@ -0,0 +1,700 @@
# Refractor System -- In-App Integration Test Plan
**Target environment**: Dev Discord server (Guild ID: `613880856032968834`)
**Dev API**: `pddev.manticorum.com`
**Bot container**: `paper-dynasty_discord-app_1` on `sba-bots`
**Date created**: 2026-03-25
This test plan is designed for browser automation (Playwright against the Discord
web client) or manual execution. Each test case specifies an exact slash command,
the expected bot response, and pass/fail criteria.
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [API Health Checks](#1-api-health-checks)
3. [/refractor status -- Basic Functionality](#2-refractor-status----basic-functionality)
4. [/refractor status -- Filters](#3-refractor-status----filters)
5. [/refractor status -- Pagination](#4-refractor-status----pagination)
6. [/refractor status -- Edge Cases and Errors](#5-refractor-status----edge-cases-and-errors)
7. [Tier Badges on Card Embeds](#6-tier-badges-on-card-embeds)
8. [Post-Game Hook -- Stat Accumulation and Evaluation](#7-post-game-hook----stat-accumulation-and-evaluation)
9. [Tier-Up Notifications](#8-tier-up-notifications)
10. [Cross-Command Badge Propagation](#9-cross-command-badge-propagation)
11. [Known Gaps and Risks](#known-gaps-and-risks)
---
## Prerequisites
Before running these tests, ensure the following state exists:
### Bot State
- [ ] Bot is online and healthy: `GET http://sba-bots:8080/health` returns 200
- [ ] Refractor cog is loaded: check bot logs for `Loaded extension 'cogs.refractor'`
- [ ] Test user has the `PD Players` role on the dev server
### Team and Card State
- [ ] Test user owns a team (verify with `/team` or `/myteam`)
- [ ] Team has at least 15 cards on its roster (needed for pagination tests)
- [ ] At least one batter card, one SP card, and one RP card exist on the roster
- [ ] At least one card has refractor state initialized in the database (the API must have a `RefractorCardState` row for this player+team pair)
- [ ] Record the team ID, and at least 3 card IDs for use in tests:
- `CARD_BATTER` -- a batter card ID with refractor state
- `CARD_SP` -- a starting pitcher card ID with refractor state
- `CARD_RP` -- a relief pitcher card ID with refractor state
- `CARD_NO_STATE` -- a card ID that exists but has no RefractorCardState row
- `CARD_INVALID` -- a card ID that does not exist (e.g. 999999)
### API State
- [ ] Refractor tracks are seeded: `GET /api/v2/refractor/tracks` returns at least 3 tracks (batter, sp, rp)
- [ ] At least one RefractorCardState row exists for a card on the test team
- [ ] Verify manually: `GET /api/v2/refractor/cards/{CARD_BATTER}` returns a valid response
- [ ] Verify list endpoint: `GET /api/v2/refractor/cards?team_id={TEAM_ID}` returns cards for the test team
### Data Setup Script (run against dev API)
If refractor state does not yet exist for test cards, trigger initialization:
```bash
# Force-evaluate a specific card to create its RefractorCardState
curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_BATTER}/evaluate" \
-H "Authorization: Bearer ${API_TOKEN}"
```
---
## 1. API Health Checks
These are pre-flight checks run before any Discord interaction. They verify the
API layer is functional. Execute via shell or Playwright network interception.
### REF-API-01: Bot health endpoint
| Field | Value |
|---|---|
| **Command** | `curl -sf http://sba-bots:8080/health` |
| **Expected** | HTTP 200, body contains health status |
| **Pass criteria** | Non-empty 200 response |
### REF-API-02: Refractor tracks endpoint responds
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/tracks" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with `count >= 3` and `items` array containing batter, sp, rp tracks |
| **Pass criteria** | `count` field >= 3; each item has `card_type`, `t1_threshold`, `t2_threshold`, `t3_threshold`, `t4_threshold` |
### REF-API-03: Single card refractor state endpoint
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_BATTER}" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with `player_id`, `team_id`, `current_tier`, `current_value`, `fully_evolved`, `next_threshold`, `track` |
| **Pass criteria** | `current_tier` is an integer 0-4; `track` object exists with threshold fields |
### REF-API-04: Card state 404 for nonexistent card
| Field | Value |
|---|---|
| **Command** | `curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/refractor/cards/999999" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | HTTP 404 |
| **Pass criteria** | Status code is exactly 404 |
### REF-API-05: Old evolution endpoint removed
| Field | Value |
|---|---|
| **Command** | `curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/evolution/cards/1" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | HTTP 404 |
| **Pass criteria** | Status code is 404 (confirms evolution->refractor rename is complete) |
### REF-API-06: Team-level card list endpoint
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with `count` >= 1 and `items` array containing card state objects |
| **Pass criteria** | 1. `count` reflects total cards with refractor state for the team |
| | 2. Each item has `player_id`, `team_id`, `current_tier`, `current_value`, `progress_pct`, `player_name` |
| | 3. Items sorted by `current_tier` DESC, `current_value` DESC |
### REF-API-07: Card list with card_type filter
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&card_type=batter" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with only batter card states |
| **Pass criteria** | All items have batter track; count <= total from REF-API-06 |
### REF-API-08: Card list with tier filter
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&tier=0" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with only T0 card states |
| **Pass criteria** | All items have `current_tier: 0` |
### REF-API-09: Card list with progress=close filter
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&progress=close" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with only cards at >= 80% of next tier threshold |
| **Pass criteria** | Each item's `progress_pct` >= 80.0; no fully evolved cards |
### REF-API-10: Card list pagination
| Field | Value |
|---|---|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&limit=2&offset=0" -H "Authorization: Bearer $TOKEN"` |
| **Expected** | JSON with `count` reflecting total (not page size) and `items` array with at most 2 entries |
| **Pass criteria** | 1. `count` same as REF-API-06 (total matching, not page size) |
| | 2. `items` length <= 2 |
---
## 2. /refractor status -- Basic Functionality
### REF-01: Basic status command (no filters)
| Field | Value |
|---|---|
| **Description** | Invoke /refractor status with no arguments; verify the embed appears |
| **Discord command** | `/refractor status` |
| **Expected result** | An ephemeral embed with: |
| | - Title: `{team short name} Refractor Status` |
| | - Purple embed color (hex `0x6F42C1` = RGB 111, 66, 193) |
| | - Description containing card entries (player names, progress bars, tier labels) |
| | - Footer: `Page 1/N . M card(s) total` |
| **Pass criteria** | 1. Embed title contains team short name and "Refractor Status" |
| | 2. Embed color is purple (`#6F42C1`) |
| | 3. At least one card entry is visible in the description |
| | 4. Footer contains page number and total card count |
| | 5. Response is ephemeral (only visible to the invoking user) |
### REF-02: Card entry format -- batter
| Field | Value |
|---|---|
| **Description** | Verify a batter card entry has correct format in the status embed |
| **Discord command** | `/refractor status card_type:batter` |
| **Expected result** | Each entry in the embed follows this pattern: |
| | Line 1: `**{badge} Player Name** (Tier Label)` |
| | Line 2: `[====------] value/threshold (PA+TB x 2) -- T{n} -> T{n+1}` |
| **Pass criteria** | 1. Player name appears in bold (`**...**`) |
| | 2. Tier label is one of: Base Card, Base Chrome, Refractor, Gold Refractor, Superfractor |
| | 3. Progress bar has format `[====------]` (10 chars of `=` and `-` between brackets) |
| | 4. Formula label shows `PA+TB x 2` for batters |
| | 5. Tier progression arrow shows `T{current} -> T{next}` |
### REF-03: Card entry format -- starting pitcher
| Field | Value |
|---|---|
| **Description** | Verify SP cards show the correct formula label |
| **Discord command** | `/refractor status card_type:sp` |
| **Expected result** | SP card entries show `IP+K` as the formula label |
| **Pass criteria** | Formula label in progress line is `IP+K` (not `PA+TB x 2`) |
### REF-04: Card entry format -- relief pitcher
| Field | Value |
|---|---|
| **Description** | Verify RP cards show the correct formula label |
| **Discord command** | `/refractor status card_type:rp` |
| **Expected result** | RP card entries show `IP+K` as the formula label |
| **Pass criteria** | Formula label in progress line is `IP+K` |
### REF-05: Tier badge display per tier
| Field | Value |
|---|---|
| **Description** | Verify correct tier badges appear for each tier level |
| **Discord command** | `/refractor status` (examine entries across tiers) |
| **Expected result** | Badge mapping: |
| | T0 (Base Card): no badge prefix |
| | T1 (Base Chrome): `[BC]` prefix |
| | T2 (Refractor): `[R]` prefix |
| | T3 (Gold Refractor): `[GR]` prefix |
| | T4 (Superfractor): `[SF]` prefix |
| **Pass criteria** | Each card's badge matches its tier per the mapping above |
### REF-06: Fully evolved card display
| Field | Value |
|---|---|
| **Description** | Verify T4 (Superfractor) cards show the fully evolved indicator |
| **Discord command** | `/refractor status tier:4` |
| **Expected result** | Fully evolved cards show: |
| | Line 1: `**[SF] Player Name** (Superfractor)` |
| | Line 2: `[==========] FULLY EVOLVED (star)` |
| **Pass criteria** | 1. Progress bar is completely filled (`[==========]`) |
| | 2. Text says "FULLY EVOLVED" with a star character |
| | 3. No tier progression arrow (no `->` text) |
---
## 3. /refractor status -- Filters
### REF-10: Filter by card_type=batter
| Field | Value |
|---|---|
| **Discord command** | `/refractor status card_type:batter` |
| **Expected result** | Only batter cards appear; formula label is `PA+TB x 2` on all entries |
| **Pass criteria** | No entries show `IP+K` formula label |
### REF-11: Filter by card_type=sp
| Field | Value |
|---|---|
| **Discord command** | `/refractor status card_type:sp` |
| **Expected result** | Only SP cards appear; formula label is `IP+K` on all entries |
| **Pass criteria** | No entries show `PA+TB x 2` formula label |
### REF-12: Filter by card_type=rp
| Field | Value |
|---|---|
| **Discord command** | `/refractor status card_type:rp` |
| **Expected result** | Only RP cards appear; formula label is `IP+K` on all entries |
| **Pass criteria** | No entries show `PA+TB x 2` formula label |
### REF-13: Filter by tier=0
| Field | Value |
|---|---|
| **Discord command** | `/refractor status tier:0` |
| **Expected result** | Only T0 (Base Card) entries appear; no tier badges on any entry |
| **Pass criteria** | No entries contain `[BC]`, `[R]`, `[GR]`, or `[SF]` badges |
### REF-14: Filter by tier=1
| Field | Value |
|---|---|
| **Discord command** | `/refractor status tier:1` |
| **Expected result** | Only T1 entries appear; all show `[BC]` badge and `(Base Chrome)` label |
| **Pass criteria** | Every entry contains `[BC]` and `(Base Chrome)` |
### REF-15: Filter by tier=4
| Field | Value |
|---|---|
| **Discord command** | `/refractor status tier:4` |
| **Expected result** | Only T4 entries appear; all show `[SF]` badge and `FULLY EVOLVED` |
| **Pass criteria** | Every entry contains `[SF]`, `(Superfractor)`, and `FULLY EVOLVED` |
### REF-16: Filter by progress=close
| Field | Value |
|---|---|
| **Discord command** | `/refractor status progress:close` |
| **Expected result** | Only cards within 80% of their next tier threshold appear |
| **Pass criteria** | 1. For each entry, the formula_value/next_threshold ratio >= 0.8 |
| | 2. No fully evolved (T4) cards appear |
| | 3. If no cards qualify, message says "No cards are currently close to a tier advancement." |
### REF-17: Combined filter -- tier + card_type
| Field | Value |
|---|---|
| **Discord command** | `/refractor status card_type:batter tier:1` |
| **Expected result** | Only T1 batter cards appear |
| **Pass criteria** | All entries have `[BC]` badge AND `PA+TB x 2` formula label |
### REF-18: Combined filter -- tier=4 + progress=close (empty result)
| Field | Value |
|---|---|
| **Discord command** | `/refractor status tier:4 progress:close` |
| **Expected result** | Message: "No cards are currently close to a tier advancement." |
| **Pass criteria** | No embed appears; plain text message about no close cards |
| **Notes** | T4 cards are fully evolved and cannot be "close" to any threshold |
### REF-19: Filter by season
| Field | Value |
|---|---|
| **Discord command** | `/refractor status season:1` |
| **Expected result** | Only cards from season 1 appear (or empty message if none exist) |
| **Pass criteria** | Response is either a valid embed or the "no data" message |
---
## 4. /refractor status -- Pagination
### REF-20: Page 1 shows first 10 cards
| Field | Value |
|---|---|
| **Discord command** | `/refractor status page:1` |
| **Expected result** | Embed shows up to 10 card entries; footer says `Page 1/N` |
| **Pass criteria** | 1. At most 10 card entries in the description |
| | 2. Footer page number is `1` |
| | 3. Total pages `N` matches `ceil(total_cards / 10)` |
### REF-21: Page 2 shows next batch
| Field | Value |
|---|---|
| **Discord command** | `/refractor status page:2` |
| **Expected result** | Embed shows cards 11-20; footer says `Page 2/N` |
| **Pass criteria** | 1. Different cards than page 1 |
| | 2. Footer shows `Page 2/N` |
| **Prerequisite** | Team has > 10 cards with refractor state |
### REF-22: Page beyond total clamps to last page
| Field | Value |
|---|---|
| **Discord command** | `/refractor status page:999` |
| **Expected result** | Embed shows the last page of cards |
| **Pass criteria** | 1. Footer shows `Page N/N` (last page) |
| | 2. No error or empty response |
### REF-23: Page 0 clamps to page 1
| Field | Value |
|---|---|
| **Discord command** | `/refractor status page:0` |
| **Expected result** | Embed shows page 1 |
| **Pass criteria** | Footer shows `Page 1/N` |
---
## 5. /refractor status -- Edge Cases and Errors
### REF-30: User with no team
| Field | Value |
|---|---|
| **Description** | Invoke command as a user who does not own a team |
| **Discord command** | `/refractor status` (from a user with no team) |
| **Expected result** | Plain text message: "You don't have a team. Sign up with /newteam first." |
| **Pass criteria** | 1. No embed appears |
| | 2. Message mentions `/newteam` |
| | 3. Response is ephemeral |
### REF-31: Team with no refractor data
| Field | Value |
|---|---|
| **Description** | Invoke command for a team that has cards but no RefractorCardState rows |
| **Discord command** | `/refractor status` (from a team with no refractor initialization) |
| **Expected result** | Plain text message: "No refractor data found for your team." |
| **Pass criteria** | 1. No embed appears |
| | 2. Message mentions "no refractor data" |
### REF-32: Invalid card_type filter
| Field | Value |
|---|---|
| **Discord command** | `/refractor status card_type:xyz` |
| **Expected result** | Empty result -- "No refractor data found for your team." |
| **Pass criteria** | No crash; clean empty-state message |
### REF-33: Negative tier filter
| Field | Value |
|---|---|
| **Discord command** | `/refractor status tier:-1` |
| **Expected result** | Empty result or Discord input validation rejection |
| **Pass criteria** | No crash; either a clean message or Discord prevents submission |
### REF-34: Negative page number
| Field | Value |
|---|---|
| **Discord command** | `/refractor status page:-5` |
| **Expected result** | Clamps to page 1 |
| **Pass criteria** | Footer shows `Page 1/N`; no crash |
---
## 6. Tier Badges on Card Embeds
These tests verify that tier badges appear in card embed titles across all
commands that display card embeds via `get_card_embeds()`.
### REF-40: Tier badge on /card command (player lookup)
| Field | Value |
|---|---|
| **Description** | Look up a card that has a refractor tier > 0 |
| **Discord command** | `/card {player_name}` (use a player known to have refractor state) |
| **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 |
| | 2. Player name follows the badge |
| | 3. Embed color is still from the card's rarity (not refractor-related) |
### REF-41: No badge for T0 card
| Field | Value |
|---|---|
| **Description** | Look up a card with current_tier=0 |
| **Discord command** | `/card {player_name}` (use a player at T0) |
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
| **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` |
### REF-42: No badge when refractor state is missing
| Field | Value |
|---|---|
| **Description** | Look up a card that has no RefractorCardState row |
| **Discord command** | `/card {player_name}` (use a player with no refractor state) |
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
| **Pass criteria** | 1. Title has no badge prefix |
| | 2. No error in bot logs about the refractor API call |
| | 3. Card display is otherwise normal |
### REF-43: Badge on /buy confirmation embed
| Field | Value |
|---|---|
| **Description** | Start a card purchase for a player with refractor state |
| **Discord command** | `/buy {player_name}` |
| **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 |
| **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. |
### REF-44: Badge on pack opening cards
| Field | Value |
|---|---|
| **Description** | Open a pack and check if revealed cards show tier badges |
| **Discord command** | `/openpack` (or equivalent pack opening command) |
| **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 |
### REF-45: Badge consistency between /card and /refractor status
| Field | Value |
|---|---|
| **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 |
| **Expected result** | The badge in the `/card` 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] |
---
## 7. Post-Game Hook -- Stat Accumulation and Evaluation
These tests verify the end-to-end flow: play a game -> stats update -> refractor
evaluation -> optional tier-up notification.
### Prerequisites for Game Tests
- Two teams exist on the dev server (the test user's team + an AI opponent)
- The test user's team has a valid lineup and starting pitcher set
- Record the game ID from the game channel name after starting
**Note:** Cal will perform the test game manually in Discord. Sections 7 and 8
(REF-50 through REF-64) are not automated via Playwright — game simulation
requires interactive play that is impractical to automate through the Discord
web client. After the game completes, the verification checks (REF-52 through
REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
### REF-50: Start a game against AI
| Field | Value |
|---|---|
| **Description** | Start a new game to create a game context |
| **Discord command** | `/new-game mlb-campaign league:Minor League away_team_abbrev:{user_team} home_team_abbrev:{ai_team}` |
| **Expected result** | Game channel is created; game starts successfully |
| **Pass criteria** | A new channel appears; scorebug embed is posted |
| **Notes** | This is setup for REF-51 through REF-54 |
### REF-51: Complete a game (manual or auto-roll)
| Field | Value |
|---|---|
| **Description** | Play the game through to completion |
| **Discord command** | Use `/log ab` repeatedly or auto-roll to finish the game |
| **Expected result** | Game ends; final score is posted; game summary embed appears |
| **Pass criteria** | 1. Game over message appears |
| | 2. No errors in bot logs during the post-game hook |
### REF-52: Verify season stats updated post-game
| Field | Value |
|---|---|
| **Description** | After game completion, check that season stats were updated |
| **Verification** | Check bot logs for successful POST to `season-stats/update-game/{game_id}` |
| **Pass criteria** | 1. Bot logs show the season-stats POST was made |
| | 2. No error logged for that call |
| **API check** | `curl "https://pddev.manticorum.com/api/v2/season-stats?team_id={team_id}" -H "Authorization: Bearer $TOKEN"` returns updated stats |
### REF-53: Verify refractor evaluation triggered post-game
| Field | Value |
|---|---|
| **Description** | After game completion, check that refractor evaluation was called |
| **Verification** | Check bot logs for successful POST to `refractor/evaluate-game/{game_id}` |
| **Pass criteria** | 1. Bot logs show the refractor evaluate-game POST was made |
| | 2. The call happened AFTER the season-stats call (ordering matters) |
| | 3. Log does not show "Post-game refractor processing failed" |
### REF-54: Verify refractor values changed after game
| Field | Value |
|---|---|
| **Description** | After a completed game, check that formula values increased for participating players |
| **Discord command** | `/refractor status` (compare before/after values for a participating player) |
| **Expected result** | `formula_value` for batters who had PAs and pitchers who recorded outs should be higher than before the game |
| **Pass criteria** | At least one card's formula_value has increased |
| **API check** | `curl "https://pddev.manticorum.com/api/v2/refractor/cards/{CARD_BATTER}" -H "Authorization: Bearer $TOKEN"` -- compare `current_value` before and after |
### REF-55: Post-game hook is non-fatal
| Field | Value |
|---|---|
| **Description** | Even if the refractor API fails, the game completion should succeed |
| **Verification** | This is tested via unit tests (test_complete_game_hook.py). For integration: verify that if the API has a momentary error, the game result is still saved and the channel reflects the final score. |
| **Pass criteria** | Game results persist even if refractor evaluation errors appear in logs |
---
## 8. Tier-Up Notifications
### REF-60: Tier-up embed format (T0 -> T1)
| Field | Value |
|---|---|
| **Description** | When a card tiers up from T0 to T1 (Base Chrome), a notification embed is sent |
| **Trigger** | Complete a game where a player's formula_value crosses the T1 threshold |
| **Expected result** | An embed appears in the game channel with: |
| | - Title: "Refractor Tier Up!" |
| | - Description: `**{Player Name}** reached **Tier 1 (Base Chrome)** on the **{Track Name}** track` |
| | - Color: green (`0x2ECC71`) |
| | - Footer: "Paper Dynasty Refractor" |
| **Pass criteria** | 1. Embed title is exactly "Refractor Tier Up!" |
| | 2. Player name appears bold in description |
| | 3. Tier number and name are correct |
| | 4. Track name is one of: Batter Track, Starting Pitcher Track, Relief Pitcher Track |
| | 5. Footer text is "Paper Dynasty Refractor" |
### REF-61: Tier-up embed colors per tier
| Field | Value |
|---|---|
| **Description** | Each tier has a distinct embed color |
| **Expected colors** | T1: green (`0x2ECC71`), T2: gold (`0xF1C40F`), T3: purple (`0x9B59B6`), T4: teal (`0x1ABC9C`) |
| **Pass criteria** | Embed color matches the target tier |
| **Notes** | May require manual API manipulation to trigger specific tier transitions |
### REF-62: Superfractor notification (T3 -> T4)
| Field | Value |
|---|---|
| **Description** | The Superfractor tier-up has special formatting |
| **Trigger** | A player crosses the T4 threshold |
| **Expected result** | Embed with: |
| | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") |
| | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` |
| | - Color: teal (`0x1ABC9C`) |
| | - Extra field: "Rating Boosts" with value "Rating boosts coming in a future update!" |
| **Pass criteria** | 1. Title is "SUPERFRACTOR!" |
| | 2. Description mentions "maximum refractor tier" |
| | 3. "Rating Boosts" field is present |
### REF-63: Multiple tier-ups in one game
| Field | Value |
|---|---|
| **Description** | When multiple players tier up in the same game, each gets a separate notification |
| **Trigger** | Complete a game where 2+ players cross thresholds |
| **Expected result** | One embed per tier-up, posted sequentially in the game channel |
| **Pass criteria** | Each tier-up gets its own embed; no tier-ups are lost |
### REF-64: No notification when no tier-ups occur
| Field | Value |
|---|---|
| **Description** | Most games will not produce any tier-ups; verify no spurious notifications |
| **Trigger** | Complete a game where no thresholds are crossed |
| **Expected result** | No tier-up embeds appear in the channel |
| **Pass criteria** | The only game-end messages are the standard game summary and rewards |
---
## 9. Cross-Command Badge Propagation
These tests verify that tier badges appear (or correctly do not appear) in all
commands that display card information.
### REF-70: /roster command -- cards show tier badges
| Field | Value |
|---|---|
| **Discord command** | `/roster` or equivalent command that lists team cards |
| **Expected result** | If roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
| **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
### REF-71: /show-card defense (in-game) -- no badge expected
| Field | Value |
|---|---|
| **Description** | During an active game, the `/show-card defense` command uses `image_embed()` directly, NOT `get_card_embeds()` |
| **Discord command** | `/show-card defense position:Catcher` (during an active game) |
| **Expected result** | Card image is shown without a tier badge in the embed title |
| **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. |
### REF-72: /scouting view -- badge on scouted cards
| Field | Value |
|---|---|
| **Discord command** | `/scout {player_name}` (if the scouting cog uses get_card_embeds) |
| **Expected result** | If the scouting view calls get_card_embeds, badges should appear |
| **Pass criteria** | Verify whether scouting uses get_card_embeds or its own embed builder |
---
## 10. Force-Evaluate Endpoint (Admin/Debug)
### REF-80: Force evaluate a single card
| Field | Value |
|---|---|
| **Description** | Use the API to force-recalculate a card's refractor state |
| **Command** | `curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_BATTER}/evaluate" -H "Authorization: Bearer $TOKEN"` |
| **Expected result** | JSON response with updated `current_tier`, `current_value` |
| **Pass criteria** | Response includes tier and value fields; no 500 error |
### REF-81: Force evaluate a card with no stats
| Field | Value |
|---|---|
| **Command** | `curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_NO_STATS}/evaluate" -H "Authorization: Bearer $TOKEN"` |
| **Expected result** | Either 404 or a response with `current_tier: 0` and `current_value: 0` |
| **Pass criteria** | No 500 error; graceful handling |
### REF-82: Force evaluate nonexistent card
| Field | Value |
|---|---|
| **Command** | `curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/999999/evaluate" -H "Authorization: Bearer $TOKEN"` |
| **Expected result** | HTTP 404 with `"Card 999999 not found"` |
| **Pass criteria** | Status 404; clear error message |
---
## Known Gaps and Risks
### ~~RESOLVED: Team-level refractor cards list endpoint~~
The `GET /api/v2/refractor/cards` list endpoint was added in database PR #173
(merged 2026-03-25). It accepts `team_id` (required), `card_type`, `tier`,
`season`, `progress`, `limit`, and `offset` query parameters. The response
includes `progress_pct` (computed) and `player_name` (via LEFT JOIN on Player).
Sorting: `current_tier` DESC, `current_value` DESC. A non-unique index on
`refractor_card_state.team_id` was added for query performance.
Test cases REF-API-06 through REF-API-10 now cover this endpoint directly.
### In-game card display does not show badges
The `/show-card defense` command in the gameplay cog uses `image_embed()` which
renders the card image directly. It does not call `get_card_embeds()` and
therefore does not fetch or display refractor tier badges. This is a design
decision, not a bug, but should be documented as a known limitation.
### Tier badge format inconsistency (by design)
Two `TIER_BADGES` dicts exist:
- `cogs/refractor.py`: `{1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}` (with brackets)
- `helpers/main.py`: `{1: "BC", 2: "R", 3: "GR", 4: "SF"}` (without brackets)
This is intentional -- `helpers/main.py` wraps the value in brackets when building
the embed title (`f"[{badge}] "`). The existing unit test
`TestTierBadgesFormatConsistency` in `test_card_embed_refractor.py` enforces this
contract. Both dicts must stay in sync.
### Notification delivery is non-fatal
The tier-up notification send is wrapped in `try/except`. If Discord's API has a
momentary error, the notification is lost silently (logged but not retried). There
is no notification queue or retry mechanism. This is acceptable for the current
design but means tier-up notifications are best-effort.
---
## Test Execution Checklist
Run order for Playwright automation:
1. [~] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
- Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
- Not yet tested: REF-API-01, REF-API-02, REF-API-04, REF-API-05, REF-API-08, REF-API-09
2. [~] Execute REF-01 through REF-06 (basic /refractor status)
- Tested 2026-03-25: REF-01 (embed appears ✓), REF-02 (batter entry format ✓), REF-05 (tier badges [BC] ✓)
- Bugs found and fixed: wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
- Not yet tested: REF-03 (SP format), REF-04 (RP format), REF-06 (fully evolved)
3. [~] Execute REF-10 through REF-19 (filters)
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
- Choice dropdown menus added for all filter params (PR #126)
- Not yet tested: REF-11 through REF-19
4. [~] Execute REF-20 through REF-23 (pagination)
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
- Not yet tested: REF-21 (page 2), REF-22 (beyond total), REF-23 (page 0)
5. [ ] Execute REF-30 through REF-34 (edge cases)
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds)
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation)
10. [~] Execute REF-80 through REF-82 (force-evaluate API)
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
- Not yet tested: REF-81, REF-82
### Approximate Time Estimates
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
- /refractor status tests (REF-01 through REF-34): 10-15 minutes
- Tier badge tests (REF-40 through REF-45): 5-10 minutes
- Game simulation tests (REF-50 through REF-55): 15-30 minutes (depends on game length)
- Tier-up notification tests (REF-60 through REF-64): Requires setup; 10-20 minutes
- Cross-command tests (REF-70 through REF-72): 5 minutes
- Force-evaluate API tests (REF-80 through REF-82): 2-3 minutes
**Total estimated time**: 50-90 minutes for full suite (87 test cases)

22
tests/refractor-preflight.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
# refractor-preflight.sh — run from workstation after dev deploy
# Verifies the Refractor system endpoints and bot health
echo "=== Dev API ==="
# Refractor endpoint exists (expect 401 = auth required)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/refractor/cards/1")
[ "$STATUS" = "401" ] && echo "PASS: refractor/cards responds (401)" || echo "FAIL: refractor/cards ($STATUS, expected 401)"
# Old evolution endpoint removed (expect 404)
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/evolution/cards/1")
[ "$STATUS" = "404" ] && echo "PASS: evolution/cards removed (404)" || echo "FAIL: evolution/cards ($STATUS, expected 404)"
echo ""
echo "=== Discord Bot ==="
# Health check
curl -sf http://sba-bots:8080/health >/dev/null 2>&1 && echo "PASS: bot health OK" || echo "FAIL: bot health endpoint"
# Recent refractor activity in logs
echo ""
echo "=== Recent Bot Logs (refractor) ==="
ssh sba-bots "docker logs --since 10m paper-dynasty_discord-app_1 2>&1 | grep -i refract" || echo "(no recent refractor activity)"

View File

@ -0,0 +1,298 @@
"""
Tests for WP-12: Tier Badge on Card Embed.
Verifies that get_card_embeds() prepends a tier badge to the card title when a
card has Refractor tier progression, and falls back gracefully when the Refractor
API is unavailable or returns no state.
"""
import pytest
from unittest.mock import AsyncMock, patch
import discord
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_card(card_id=1, player_name="Mike Trout", rarity_color="FFD700"):
"""Minimal card dict matching the API shape consumed by get_card_embeds."""
return {
"id": card_id,
"player": {
"player_id": 101,
"p_name": player_name,
"rarity": {"name": "MVP", "value": 5, "color": rarity_color},
"cost": 500,
"image": "https://example.com/card.png",
"image2": None,
"mlbclub": "Los Angeles Angels",
"franchise": "Los Angeles Angels",
"headshot": "https://example.com/headshot.jpg",
"cardset": {"name": "2023 Season"},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"bbref_id": "troutmi01",
"strat_code": "420420",
"fangr_id": None,
"vanity_card": None,
},
"team": {
"id": 10,
"lname": "Paper Dynasty",
"logo": "https://example.com/logo.png",
"season": 7,
},
}
def _make_paperdex():
"""Minimal paperdex response."""
return {"count": 0, "paperdex": []}
# ---------------------------------------------------------------------------
# Helpers to patch the async dependencies of get_card_embeds
# ---------------------------------------------------------------------------
def _patch_db_get(evo_response=None, paperdex_response=None):
"""
Return a side_effect callable that routes db_get calls to the right mock
responses, so other get_card_embeds internals still behave.
"""
if paperdex_response is None:
paperdex_response = _make_paperdex()
async def _side_effect(endpoint, *args, **kwargs):
if str(endpoint).startswith("refractor/cards/"):
return evo_response
if endpoint == "paperdex":
return paperdex_response
# Fallback for any other endpoint (e.g. plays/batting, plays/pitching)
return None
return _side_effect
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestTierBadgeFormat:
"""Unit: tier badge string format for each tier level."""
@pytest.mark.asyncio
async def test_tier_zero_no_badge(self):
"""T0 evolution state (current_tier=0) should produce no badge in title."""
card = _make_card()
evo_state = {"current_tier": 0, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_tier_one_badge(self):
"""current_tier=1 should prefix title with [BC] (Base Chrome)."""
card = _make_card()
evo_state = {"current_tier": 1, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[BC] Mike Trout"
@pytest.mark.asyncio
async def test_tier_two_badge(self):
"""current_tier=2 should prefix title with [R] (Refractor)."""
card = _make_card()
evo_state = {"current_tier": 2, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[R] Mike Trout"
@pytest.mark.asyncio
async def test_tier_three_badge(self):
"""current_tier=3 should prefix title with [GR] (Gold Refractor)."""
card = _make_card()
evo_state = {"current_tier": 3, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[GR] Mike Trout"
@pytest.mark.asyncio
async def test_tier_four_superfractor_badge(self):
"""current_tier=4 (Superfractor) should prefix title with [SF]."""
card = _make_card()
evo_state = {"current_tier": 4, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "[SF] Mike Trout"
class TestTierBadgeInTitle:
"""Unit: badge appears correctly in the embed title."""
@pytest.mark.asyncio
async def test_badge_prepended_to_player_name(self):
"""Badge should be prepended so title reads '[Tx] <player_name>'."""
card = _make_card(player_name="Juan Soto")
evo_state = {"current_tier": 2, "card_id": 1}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title.startswith("[R] ")
assert "Juan Soto" in embeds[0].title
class TestFullyEvolvedBadge:
"""Unit: fully evolved card shows [SF] badge (Superfractor)."""
@pytest.mark.asyncio
async def test_fully_evolved_badge(self):
"""T4 card should show [SF] prefix, not [T4]."""
card = _make_card()
evo_state = {"current_tier": 4}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title.startswith("[SF] ")
assert "[T4]" not in embeds[0].title
class TestNoBadgeGracefulFallback:
"""Unit: embed renders correctly when evolution state is absent or API fails."""
@pytest.mark.asyncio
async def test_no_evolution_state_no_badge(self):
"""When evolution API returns None (404), title has no badge."""
card = _make_card()
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=None)
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_api_exception_no_badge(self):
"""When evolution API raises an exception, card display is unaffected."""
card = _make_card()
async def _failing_db_get(endpoint, *args, **kwargs):
if str(endpoint).startswith("refractor/cards/"):
raise ConnectionError("API unreachable")
if endpoint == "paperdex":
return _make_paperdex()
return None
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _failing_db_get
embeds = await _call_get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
class TestEmbedColorUnchanged:
"""Unit: embed color comes from card rarity, not affected by evolution state."""
@pytest.mark.asyncio
async def test_embed_color_from_rarity_with_evolution(self):
"""Color is still derived from rarity even when a tier badge is present."""
rarity_color = "FF0000"
card = _make_card(rarity_color=rarity_color)
evo_state = {"current_tier": 2}
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
embeds = await _call_get_card_embeds(card)
assert embeds[0].color == discord.Color(int(rarity_color, 16))
@pytest.mark.asyncio
async def test_embed_color_from_rarity_without_evolution(self):
"""Color is derived from rarity when no evolution state exists."""
rarity_color = "00FF00"
card = _make_card(rarity_color=rarity_color)
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
mock_db.side_effect = _patch_db_get(evo_response=None)
embeds = await _call_get_card_embeds(card)
assert embeds[0].color == discord.Color(int(rarity_color, 16))
# ---------------------------------------------------------------------------
# T1-7: TIER_BADGES format consistency check across modules
# ---------------------------------------------------------------------------
class TestTierSymbolsCompleteness:
"""
T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
while card embed titles use helpers.main TIER_BADGES in bracket format.
Both must cover the full tier range for their respective contexts.
"""
def test_tier_symbols_covers_all_tiers(self):
"""TIER_SYMBOLS must have entries for T0 through T4."""
from cogs.refractor import TIER_SYMBOLS
for tier in range(5):
assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
def test_tier_badges_covers_evolved_tiers(self):
"""helpers.main TIER_BADGES must have entries for T1 through T4."""
from helpers.main import TIER_BADGES
for tier in range(1, 5):
assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
def test_tier_symbols_are_unique(self):
"""Each tier must have a distinct symbol."""
from cogs.refractor import TIER_SYMBOLS
values = list(TIER_SYMBOLS.values())
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
# ---------------------------------------------------------------------------
# Helper: call get_card_embeds and return embed list
# ---------------------------------------------------------------------------
async def _call_get_card_embeds(card):
"""Import and call get_card_embeds, returning the list of embeds."""
from helpers.main import get_card_embeds
result = await get_card_embeds(card)
if isinstance(result, list):
return result
return [result]

View File

@ -0,0 +1,205 @@
"""
Tests for the WP-13 post-game callback integration hook.
These tests verify that after a game is saved to the API, two additional
POST requests are fired in the correct order:
1. POST season-stats/update-game/{game_id} update player_season_stats
2. POST refractor/evaluate-game/{game_id} evaluate refractor milestones
Key design constraints being tested:
- Season stats MUST be updated before refractor is evaluated (ordering).
- Failure of either refractor call must NOT propagate the game result has
already been committed; refractor will self-heal on the next evaluate pass.
- Tier-up dicts returned by the refractor endpoint are passed to
notify_tier_completion so WP-14 can present them to the player.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, call, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_channel(channel_id: int = 999) -> MagicMock:
ch = MagicMock()
ch.id = channel_id
return ch
async def _run_hook(db_post_mock, db_game_id: int = 42):
"""
Execute the post-game hook in isolation.
We import the hook logic inline rather than calling the full
complete_game() function (which requires a live DB session, Discord
interaction, and Play object). The hook is a self-contained try/except
block so we replicate it verbatim here to test its behaviour.
"""
channel = _make_channel()
from command_logic.logic_gameplay import notify_tier_completion
db_game = {"id": db_game_id}
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(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(channel, tier_up)
except Exception:
pass # non-fatal — mirrors the logger.warning in production
return channel
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_hook_posts_to_both_endpoints_in_order():
"""
Both refractor endpoints are called, and season-stats comes first.
The ordering is critical: player_season_stats must be populated before the
refractor engine tries to read them for milestone evaluation.
"""
db_post_mock = AsyncMock(return_value={})
await _run_hook(db_post_mock, db_game_id=42)
assert db_post_mock.call_count == 2
calls = db_post_mock.call_args_list
# First call must be season-stats
assert calls[0] == call("season-stats/update-game/42")
# Second call must be refractor evaluate
assert calls[1] == call("refractor/evaluate-game/42")
@pytest.mark.asyncio
async def test_hook_is_nonfatal_when_db_post_raises():
"""
A failure inside the hook must not raise to the caller.
The game result is already persisted when the hook runs. If the refractor
API is down or returns an error, we log a warning and continue the game
completion flow must not be interrupted.
"""
db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable"))
# Should not raise
try:
await _run_hook(db_post_mock, db_game_id=7)
except Exception as exc:
pytest.fail(f"Hook raised unexpectedly: {exc}")
@pytest.mark.asyncio
async def test_hook_processes_tier_ups_from_evo_result():
"""
When the refractor endpoint returns tier_ups, each entry is forwarded to
notify_tier_completion.
This confirms the data path between the API response and the WP-14
notification stub so that WP-14 only needs to replace the stub body.
"""
tier_ups = [
{"player_id": 101, "old_tier": 1, "new_tier": 2},
{"player_id": 202, "old_tier": 2, "new_tier": 3},
]
async def fake_db_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": tier_ups}
return {}
db_post_mock = AsyncMock(side_effect=fake_db_post)
with patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify:
channel = _make_channel()
db_game = {"id": 99}
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(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 mock_notify(channel, tier_up)
except Exception:
pass
assert mock_notify.call_count == 2
# Verify both tier_up dicts were forwarded
forwarded = [c.args[1] for c in mock_notify.call_args_list]
assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded
assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded
@pytest.mark.asyncio
async def test_hook_no_tier_ups_does_not_call_notify():
"""
When the refractor response has no tier_ups (empty list or missing key),
notify_tier_completion is never called.
Avoids spurious Discord messages for routine game completions.
"""
async def fake_db_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": []}
return {}
db_post_mock = AsyncMock(side_effect=fake_db_post)
with patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify:
channel = _make_channel()
db_game = {"id": 55}
try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post_mock(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 mock_notify(channel, tier_up)
except Exception:
pass
mock_notify.assert_not_called()
@pytest.mark.asyncio
async def test_notify_tier_completion_sends_embed_and_does_not_raise():
"""
notify_tier_completion sends a Discord embed and does not raise.
Now that WP-14 is wired, the function imported via logic_gameplay is the
real embed-sending implementation from helpers.refractor_notifs.
"""
from command_logic.logic_gameplay import notify_tier_completion
channel = AsyncMock()
# Full API response shape — the evaluate-game endpoint returns all these keys
tier_up = {
"player_id": 77,
"team_id": 1,
"player_name": "Mike Trout",
"old_tier": 0,
"new_tier": 1,
"current_value": 45.0,
"track_name": "Batter Track",
}
await notify_tier_completion(channel, tier_up)
channel.send.assert_called_once()
embed = channel.send.call_args.kwargs["embed"]
assert "Mike Trout" in embed.description

View File

@ -0,0 +1,740 @@
"""
Unit tests for refractor command helper functions (WP-11).
Tests cover:
- render_progress_bar: ASCII bar rendering at various fill levels
- format_refractor_entry: Full card state formatting including fully evolved case
- apply_close_filter: 80% proximity filter logic
- paginate: 1-indexed page slicing and total-page calculation
- TIER_NAMES: Display names for all tiers
- Slash command: empty roster and no-team responses (async, uses mocks)
All tests are pure-unit unless marked otherwise; no network calls are made.
"""
import sys
import os
import pytest
from unittest.mock import AsyncMock, Mock, patch
import discord
from discord.ext import commands
# Make the repo root importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from cogs.refractor import (
render_progress_bar,
format_refractor_entry,
apply_close_filter,
paginate,
TIER_NAMES,
TIER_SYMBOLS,
PAGE_SIZE,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def batter_state():
"""A mid-progress batter card state (API response shape)."""
return {
"player_name": "Mike Trout",
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 1,
"current_value": 120,
"next_threshold": 149,
}
@pytest.fixture
def evolved_state():
"""A fully evolved card state (T4)."""
return {
"player_name": "Shohei Ohtani",
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 4,
"current_value": 300,
"next_threshold": None,
}
@pytest.fixture
def sp_state():
"""A starting pitcher card state at T2."""
return {
"player_name": "Sandy Alcantara",
"track": {"card_type": "sp", "formula": "ip + k"},
"current_tier": 2,
"current_value": 95,
"next_threshold": 120,
}
# ---------------------------------------------------------------------------
# render_progress_bar
# ---------------------------------------------------------------------------
class TestRenderProgressBar:
"""
Tests for render_progress_bar().
Verifies width, fill character, empty character, boundary conditions,
and clamping when current exceeds threshold. Default width is 12.
Uses Unicode block chars: (filled) and (empty).
"""
def test_empty_bar(self):
"""current=0 → all empty blocks."""
assert render_progress_bar(0, 100) == "" * 12
def test_full_bar(self):
"""current == threshold → all filled blocks."""
assert render_progress_bar(100, 100) == "" * 12
def test_partial_fill(self):
"""120/149 ≈ 80.5% → ~10 filled of 12."""
bar = render_progress_bar(120, 149)
filled = bar.count("")
empty = bar.count("")
assert filled + empty == 12
assert filled == 10 # round(0.805 * 12) = 10
def test_half_fill(self):
"""50/100 = 50% → 6 filled."""
bar = render_progress_bar(50, 100)
assert bar.count("") == 6
assert bar.count("") == 6
def test_over_threshold_clamps_to_full(self):
"""current > threshold should not overflow the bar."""
assert render_progress_bar(200, 100) == "" * 12
def test_zero_threshold_returns_full_bar(self):
"""threshold=0 avoids division by zero and returns full bar."""
assert render_progress_bar(0, 0) == "" * 12
def test_custom_width(self):
"""Width parameter controls bar length."""
bar = render_progress_bar(5, 10, width=4)
assert bar == "▰▰▱▱"
# ---------------------------------------------------------------------------
# format_refractor_entry
# ---------------------------------------------------------------------------
class TestFormatRefractorEntry:
"""
Tests for format_refractor_entry().
Verifies player name, tier label, progress bar, formula label,
and the special fully-evolved formatting.
"""
def test_player_name_in_output(self, batter_state):
"""Player name appears bold in the first line (badge may prefix it)."""
result = format_refractor_entry(batter_state)
assert "Mike Trout" in result
assert "**" in result
def test_tier_label_in_output(self, batter_state):
"""Current tier name (Base Chrome for T1) appears in output."""
result = format_refractor_entry(batter_state)
assert "Base Chrome" in result
def test_progress_values_in_output(self, batter_state):
"""current/threshold values appear in output."""
result = format_refractor_entry(batter_state)
assert "120/149" in result
def test_percentage_in_output(self, batter_state):
"""Percentage appears in parentheses in output."""
result = format_refractor_entry(batter_state)
assert "(80%)" in result or "(81%)" in result
def test_fully_evolved_no_threshold(self, evolved_state):
"""T4 card with next_threshold=None shows MAX."""
result = format_refractor_entry(evolved_state)
assert "`MAX`" in result
def test_fully_evolved_by_tier(self, batter_state):
"""current_tier=4 triggers fully evolved display even with a threshold."""
batter_state["current_tier"] = 4
batter_state["next_threshold"] = 200
result = format_refractor_entry(batter_state)
assert "`MAX`" in result
def test_two_line_output(self, batter_state):
"""Output always has exactly two lines (name line + bar line)."""
result = format_refractor_entry(batter_state)
lines = result.split("\n")
assert len(lines) == 2
# ---------------------------------------------------------------------------
# TIER_BADGES
# ---------------------------------------------------------------------------
class TestTierSymbols:
"""
Verify TIER_SYMBOLS values and that format_refractor_entry prepends
the correct label for each tier. Labels use short readable text (T0-T4).
"""
def test_t0_symbol(self):
"""T0 label is empty (base cards get no prefix)."""
assert TIER_SYMBOLS[0] == "Base"
def test_t1_symbol(self):
"""T1 label is 'T1'."""
assert TIER_SYMBOLS[1] == "T1"
def test_t2_symbol(self):
"""T2 label is 'T2'."""
assert TIER_SYMBOLS[2] == "T2"
def test_t3_symbol(self):
"""T3 label is 'T3'."""
assert TIER_SYMBOLS[3] == "T3"
def test_t4_symbol(self):
"""T4 label is 'T4★'."""
assert TIER_SYMBOLS[4] == "T4★"
def test_format_entry_t1_suffix_tag(self, batter_state):
"""T1 cards show [T1] suffix tag after the tier name."""
result = format_refractor_entry(batter_state)
assert "[T1]" in result
def test_format_entry_t2_suffix_tag(self, sp_state):
"""T2 cards show [T2] suffix tag."""
result = format_refractor_entry(sp_state)
assert "[T2]" in result
def test_format_entry_t4_suffix_tag(self, evolved_state):
"""T4 cards show [T4★] suffix tag."""
result = format_refractor_entry(evolved_state)
assert "[T4★]" in result
def test_format_entry_t0_name_only(self):
"""T0 cards show just the bold name, no tier suffix."""
state = {
"player_name": "Rookie Player",
"current_tier": 0,
"current_value": 10,
"next_threshold": 50,
}
result = format_refractor_entry(state)
first_line = result.split("\n")[0]
assert first_line == "**Rookie Player**"
def test_format_entry_tag_after_name(self, batter_state):
"""Tag appears after the player name in the first line."""
result = format_refractor_entry(batter_state)
first_line = result.split("\n")[0]
name_pos = first_line.find("Mike Trout")
tag_pos = first_line.find("[T1]")
assert name_pos < tag_pos
# ---------------------------------------------------------------------------
# apply_close_filter
# ---------------------------------------------------------------------------
class TestApplyCloseFilter:
"""
Tests for apply_close_filter().
'Close' means formula_value >= 80% of next_threshold.
Fully evolved (T4 or no threshold) cards are excluded from results.
"""
def test_close_card_included(self):
"""Card at exactly 80% is included."""
state = {"current_tier": 1, "current_value": 80, "next_threshold": 100}
assert apply_close_filter([state]) == [state]
def test_above_80_percent_included(self):
"""Card above 80% is included."""
state = {"current_tier": 0, "current_value": 95, "next_threshold": 100}
assert apply_close_filter([state]) == [state]
def test_below_80_percent_excluded(self):
"""Card below 80% threshold is excluded."""
state = {"current_tier": 1, "current_value": 79, "next_threshold": 100}
assert apply_close_filter([state]) == []
def test_fully_evolved_excluded(self):
"""T4 cards are never returned by close filter."""
state = {"current_tier": 4, "current_value": 300, "next_threshold": None}
assert apply_close_filter([state]) == []
def test_none_threshold_excluded(self):
"""Cards with no next_threshold (regardless of tier) are excluded."""
state = {"current_tier": 3, "current_value": 200, "next_threshold": None}
assert apply_close_filter([state]) == []
def test_mixed_list(self):
"""Only qualifying cards are returned from a mixed list."""
close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
result = apply_close_filter([close, not_close, evolved])
assert result == [close]
def test_empty_list(self):
"""Empty input returns empty list."""
assert apply_close_filter([]) == []
# ---------------------------------------------------------------------------
# paginate
# ---------------------------------------------------------------------------
class TestPaginate:
"""
Tests for paginate().
Verifies 1-indexed page slicing, total page count calculation,
page clamping, and PAGE_SIZE default.
"""
def _items(self, n):
return list(range(n))
def test_single_page_all_items(self):
"""Fewer items than page size returns all on page 1."""
items, total = paginate(self._items(5), page=1)
assert items == [0, 1, 2, 3, 4]
assert total == 1
def test_first_page(self):
"""Page 1 returns first PAGE_SIZE items."""
items, total = paginate(self._items(25), page=1)
assert items == list(range(10))
assert total == 3
def test_second_page(self):
"""Page 2 returns next PAGE_SIZE items."""
items, total = paginate(self._items(25), page=2)
assert items == list(range(10, 20))
def test_last_page_partial(self):
"""Last page returns remaining items (fewer than PAGE_SIZE)."""
items, total = paginate(self._items(25), page=3)
assert items == [20, 21, 22, 23, 24]
assert total == 3
def test_page_clamp_low(self):
"""Page 0 or negative is clamped to page 1."""
items, _ = paginate(self._items(15), page=0)
assert items == list(range(10))
def test_page_clamp_high(self):
"""Page beyond total is clamped to last page."""
items, total = paginate(self._items(15), page=99)
assert items == [10, 11, 12, 13, 14]
assert total == 2
def test_empty_list_returns_empty_page(self):
"""Empty input returns empty page with total_pages=1."""
items, total = paginate([], page=1)
assert items == []
assert total == 1
def test_exact_page_boundary(self):
"""Exactly PAGE_SIZE items → 1 full page."""
items, total = paginate(self._items(PAGE_SIZE), page=1)
assert len(items) == PAGE_SIZE
assert total == 1
# ---------------------------------------------------------------------------
# TIER_NAMES
# ---------------------------------------------------------------------------
class TestTierNames:
"""
Verify all tier display names are correctly defined.
T0=Base Card, T1=Base Chrome, T2=Refractor, T3=Gold Refractor, T4=Superfractor
"""
def test_t0_base_card(self):
assert TIER_NAMES[0] == "Base Card"
def test_t1_base_chrome(self):
assert TIER_NAMES[1] == "Base Chrome"
def test_t2_refractor(self):
assert TIER_NAMES[2] == "Refractor"
def test_t3_gold_refractor(self):
assert TIER_NAMES[3] == "Gold Refractor"
def test_t4_superfractor(self):
assert TIER_NAMES[4] == "Superfractor"
# ---------------------------------------------------------------------------
# Slash command: empty roster / no-team scenarios
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_bot():
bot = AsyncMock(spec=commands.Bot)
return bot
@pytest.fixture
def mock_interaction():
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.edit_original_response = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
return interaction
# ---------------------------------------------------------------------------
# T1-6: TIER_NAMES duplication divergence check
# ---------------------------------------------------------------------------
class TestTierNamesDivergenceCheck:
"""
T1-6: Assert that TIER_NAMES in cogs.refractor and helpers.refractor_notifs
are identical (same keys, same values).
Why: TIER_NAMES is duplicated in two modules. If one is updated and the
other is not (e.g. a tier is renamed or a new tier is added), tier labels
in the /refractor status embed and the tier-up notification embed will
diverge silently. This test acts as a divergence tripwire it will fail
the moment the two copies fall out of sync, forcing an explicit fix.
"""
def test_tier_names_are_identical_across_modules(self):
"""
Import TIER_NAMES from both modules and assert deep equality.
The test imports the name at call-time rather than at module level to
ensure it always reads the current definition and is not affected by
module-level caching or monkeypatching in other tests.
"""
from cogs.refractor import TIER_NAMES as cog_tier_names
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
assert cog_tier_names == notifs_tier_names, (
"TIER_NAMES differs between cogs.refractor and helpers.refractor_notifs. "
"Both copies must be kept in sync. "
f"cogs.refractor: {cog_tier_names!r} "
f"helpers.refractor_notifs: {notifs_tier_names!r}"
)
def test_tier_names_have_same_keys(self):
"""Keys (tier numbers) must be identical in both modules."""
from cogs.refractor import TIER_NAMES as cog_tier_names
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
assert set(cog_tier_names.keys()) == set(notifs_tier_names.keys()), (
"TIER_NAMES key sets differ between modules."
)
def test_tier_names_have_same_values(self):
"""Display strings (values) must be identical for every shared key."""
from cogs.refractor import TIER_NAMES as cog_tier_names
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
for tier, name in cog_tier_names.items():
assert notifs_tier_names.get(tier) == name, (
f"Tier {tier} name mismatch: "
f"cogs.refractor={name!r}, "
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!r}"
)
# ---------------------------------------------------------------------------
# T2-8: Filter combination — tier=4 + progress="close" yields empty result
# ---------------------------------------------------------------------------
class TestApplyCloseFilterWithAllT4Cards:
"""
T2-8: When all cards in the list are T4 (fully evolved), apply_close_filter
must return an empty list.
Why: T4 cards have no next tier to advance to, so they have no threshold.
The close filter explicitly excludes fully evolved cards (tier >= 4 or
next_threshold is None). If a user passes both tier=4 and progress="close"
to /refractor status, the combined result should be empty the command
already handles this by showing "No cards are currently close to a tier
advancement." This test documents and protects that behaviour.
"""
def test_all_t4_cards_returns_empty(self):
"""
A list of T4-only card states should produce an empty result from
apply_close_filter, because T4 cards are fully evolved and have no
next threshold to be "close" to.
This is the intended behaviour when tier=4 and progress="close" are
combined: there are no qualifying cards, and the command should show
the "no cards close to advancement" message rather than an empty embed.
"""
t4_cards = [
{"current_tier": 4, "current_value": 300, "next_threshold": None},
{"current_tier": 4, "current_value": 500, "next_threshold": None},
{"current_tier": 4, "current_value": 275, "next_threshold": None},
]
result = apply_close_filter(t4_cards)
assert result == [], (
"apply_close_filter must return [] for fully evolved T4 cards — "
"they have no next threshold and cannot be 'close' to advancement."
)
def test_t4_cards_excluded_even_with_high_formula_value(self):
"""
T4 cards are excluded regardless of their formula_value, since the
filter is based on tier (>= 4) and threshold (None), not raw values.
"""
t4_high_value = {
"current_tier": 4,
"current_value": 9999,
"next_threshold": None,
}
assert apply_close_filter([t4_high_value]) == []
# ---------------------------------------------------------------------------
# T3-2: Malformed API response handling in format_refractor_entry
# ---------------------------------------------------------------------------
class TestFormatRefractorEntryMalformedInput:
"""
T3-2: format_refractor_entry should not crash when given a card state dict
that is missing expected keys.
Why: API responses can be incomplete due to migration states, partially
populated records, or future schema changes. format_refractor_entry uses
.get() with fallbacks for all keys, so missing fields should gracefully
degrade to sensible defaults ("Unknown" for name, 0 for values) rather than
raising a KeyError or TypeError.
"""
def test_missing_player_name_uses_unknown(self):
"""
When player_name is absent, the output should contain "Unknown" rather
than crashing with a KeyError.
"""
state = {
"track": {"card_type": "batter"},
"current_tier": 1,
"current_value": 100,
"next_threshold": 150,
}
result = format_refractor_entry(state)
assert "Unknown" in result
def test_missing_formula_value_uses_zero(self):
"""
When current_value is absent, the progress calculation should use 0
without raising a TypeError.
"""
state = {
"player_name": "Test Player",
"track": {"card_type": "batter"},
"current_tier": 1,
"next_threshold": 150,
}
result = format_refractor_entry(state)
assert "0/150" in result
def test_completely_empty_dict_does_not_crash(self):
"""
An entirely empty dict should produce a valid (if sparse) string using
all fallback values, not raise any exception.
"""
result = format_refractor_entry({})
# Should not raise; output should be a string with two lines
assert isinstance(result, str)
lines = result.split("\n")
assert len(lines) == 2
def test_missing_card_type_does_not_crash(self):
"""
When card_type is absent from the track, the code should still
produce a valid two-line output without crashing.
"""
state = {
"player_name": "Test Player",
"current_tier": 1,
"current_value": 50,
"next_threshold": 100,
}
result = format_refractor_entry(state)
assert "50/100" in result
# ---------------------------------------------------------------------------
# T3-3: Progress bar boundary precision
# ---------------------------------------------------------------------------
class TestRenderProgressBarBoundaryPrecision:
"""
T3-3: Verify the progress bar behaves correctly at edge values near zero,
near full, exactly at extremes, and for negative input.
Why: Off-by-one errors in rounding or integer truncation can make a nearly-
full bar look full (or vice versa), confusing users about how close their
card is to a tier advancement. Defensive handling of negative values ensures
no bar is rendered longer than its declared width.
"""
def test_one_of_hundred_shows_mostly_empty(self):
"""
1/100 = 1% should produce a bar with 0 or 1 filled segment and the
rest empty. The bar must not appear more than minimally filled.
"""
bar = render_progress_bar(1, 100)
filled_count = bar.count("")
assert filled_count <= 1, (
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):
"""
99/100 = 99% should produce a bar with 11 or 12 filled segments.
The bar must NOT be completely empty or show fewer than 11 filled.
"""
bar = render_progress_bar(99, 100)
filled_count = bar.count("")
assert filled_count >= 11, (
f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
)
# Bar width must be exactly 12
assert len(bar) == 12
def test_zero_of_hundred_is_completely_empty(self):
"""0/100 = all empty blocks — re-verify the all-empty baseline."""
assert render_progress_bar(0, 100) == "" * 12
def test_negative_current_does_not_overflow_bar(self):
"""
A negative formula_value (data anomaly) must not produce a bar with
more filled segments than the width. The min(..., 1.0) clamp in
render_progress_bar should handle this, but this test guards against
a future refactor removing the clamp.
"""
bar = render_progress_bar(-5, 100)
filled_count = bar.count("")
assert filled_count == 0, (
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
)
# Bar width must be exactly 12
assert len(bar) == 12
# ---------------------------------------------------------------------------
# T3-4: RP formula label
# ---------------------------------------------------------------------------
class TestCardTypeVariants:
"""
T3-4/T3-5: Verify that format_refractor_entry produces valid output for
all card types including unknown ones, without crashing.
"""
def test_rp_card_produces_valid_output(self):
"""Relief pitcher card produces a valid two-line string."""
rp_state = {
"player_name": "Edwin Diaz",
"track": {"card_type": "rp"},
"current_tier": 1,
"current_value": 45,
"next_threshold": 60,
}
result = format_refractor_entry(rp_state)
assert "Edwin Diaz" in result
assert "45/60" in result
def test_unknown_card_type_does_not_crash(self):
"""Unknown card_type produces a valid two-line string."""
state = {
"player_name": "Test Player",
"track": {"card_type": "dh"},
"current_tier": 1,
"current_value": 30,
"next_threshold": 50,
}
result = format_refractor_entry(state)
assert isinstance(result, str)
assert len(result.split("\n")) == 2
# ---------------------------------------------------------------------------
# Slash command: empty roster / no-team scenarios
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_refractor_status_no_team(mock_bot, mock_interaction):
"""
When the user has no team, the command replies with a signup prompt
and does not call db_get.
Why: get_team_by_owner returning None means the user is unregistered;
the command must short-circuit before hitting the API.
"""
from cogs.refractor import Refractor
cog = Refractor(mock_bot)
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=None)):
with patch("cogs.refractor.db_get", new=AsyncMock()) as mock_db:
await cog.refractor_status.callback(cog, mock_interaction)
mock_db.assert_not_called()
call_kwargs = mock_interaction.edit_original_response.call_args
content = call_kwargs.kwargs.get("content", "")
assert "newteam" in content.lower() or "team" in content.lower()
@pytest.mark.asyncio
async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
"""
When the API returns an empty card list, the command sends an
informative 'no data' message rather than an empty embed.
Why: An empty list is valid (team has no refractor cards yet);
the command should not crash or send a blank embed.
"""
from cogs.refractor import Refractor
cog = Refractor(mock_bot)
team = {"id": 1, "sname": "Test"}
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
with patch(
"cogs.refractor.db_get",
new=AsyncMock(return_value={"items": [], "count": 0}),
):
await cog.refractor_status.callback(cog, mock_interaction)
call_kwargs = mock_interaction.edit_original_response.call_args
content = call_kwargs.kwargs.get("content", "")
assert "no refractor data" in content.lower()

View File

@ -0,0 +1,328 @@
"""
Tests for Refractor Tier Completion Notification embeds.
These tests verify that:
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.
3. Multiple tier-up events each produce a separate embed.
4. An empty tier-up list results in no channel sends.
The channel interaction is mocked because we are testing the embed content, not Discord
network I/O. Notification failure must never affect game flow, so the non-fatal path
is also exercised.
"""
import pytest
from unittest.mock import AsyncMock
import discord
from helpers.refractor_notifs import build_tier_up_embed, notify_tier_completion
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def make_tier_up(
player_name="Mike Trout",
old_tier=1,
new_tier=2,
track_name="Batter",
current_value=150,
):
"""Return a minimal tier_up dict matching the expected shape."""
return {
"player_name": player_name,
"old_tier": old_tier,
"new_tier": new_tier,
"track_name": track_name,
"current_value": current_value,
}
# ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tiers 1-3 (standard tier-up)
# ---------------------------------------------------------------------------
class TestBuildTierUpEmbed:
"""Verify that build_tier_up_embed produces correctly structured embeds."""
def test_title_is_refractor_tier_up(self):
"""Title must read 'Refractor Tier Up!' for any non-max tier."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
assert embed.title == "Refractor Tier Up!"
def test_description_contains_player_name(self):
"""Description must contain the player's name."""
tier_up = make_tier_up(player_name="Mike Trout", new_tier=2)
embed = build_tier_up_embed(tier_up)
assert "Mike Trout" in embed.description
def test_description_contains_new_tier_name(self):
"""Description must include the human-readable tier name for the new tier."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
# Tier 2 display name is "Refractor"
assert "Refractor" in embed.description
def test_description_contains_track_name(self):
"""Description must mention the refractor track (e.g., 'Batter')."""
tier_up = make_tier_up(track_name="Batter", new_tier=2)
embed = build_tier_up_embed(tier_up)
assert "Batter" in embed.description
def test_tier1_color_is_green(self):
"""Tier 1 uses green (0x2ecc71)."""
tier_up = make_tier_up(old_tier=0, new_tier=1)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x2ECC71
def test_tier2_color_is_gold(self):
"""Tier 2 uses gold (0xf1c40f)."""
tier_up = make_tier_up(old_tier=1, new_tier=2)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0xF1C40F
def test_tier3_color_is_purple(self):
"""Tier 3 uses purple (0x9b59b6)."""
tier_up = make_tier_up(old_tier=2, new_tier=3)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x9B59B6
def test_footer_text_is_paper_dynasty_refractor(self):
"""Footer text must be 'Paper Dynasty Refractor' for brand consistency."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Refractor"
def test_returns_discord_embed_instance(self):
"""Return type must be discord.Embed so it can be sent directly."""
tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up)
assert isinstance(embed, discord.Embed)
# ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tier 4 (superfractor)
# ---------------------------------------------------------------------------
class TestBuildTierUpEmbedSuperfractor:
"""Verify that tier 4 (Superfractor) embeds use special formatting."""
def test_title_is_superfractor(self):
"""Tier 4 title must be 'SUPERFRACTOR!' to emphasise max achievement."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.title == "SUPERFRACTOR!"
def test_description_mentions_maximum_refractor_tier(self):
"""Tier 4 description must mention 'maximum refractor tier' per the spec."""
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert "maximum refractor tier" in embed.description.lower()
def test_description_contains_player_name(self):
"""Player name must appear in the tier 4 description."""
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert "Mike Trout" in embed.description
def test_description_contains_track_name(self):
"""Track name must appear in the tier 4 description."""
tier_up = make_tier_up(track_name="Batter", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert "Batter" in embed.description
def test_tier4_color_is_teal(self):
"""Tier 4 uses teal (0x1abc9c) to visually distinguish superfractor."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x1ABC9C
def test_note_field_present(self):
"""Tier 4 must include a note field about future rating boosts."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
field_names = [f.name for f in embed.fields]
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):
"""Footer must remain 'Paper Dynasty Refractor' for tier 4 as well."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Refractor"
# ---------------------------------------------------------------------------
# Unit: notify_tier_completion — multiple and empty cases
# ---------------------------------------------------------------------------
class TestNotifyTierCompletion:
"""Verify that notify_tier_completion sends the right number of messages."""
@pytest.mark.asyncio
async def test_single_tier_up_sends_one_message(self):
"""A single tier-up event sends exactly one embed to the channel."""
channel = AsyncMock()
tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up)
channel.send.assert_called_once()
@pytest.mark.asyncio
async def test_sends_embed_not_plain_text(self):
"""The channel.send call must use the embed= keyword, not content=."""
channel = AsyncMock()
tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up)
_, kwargs = channel.send.call_args
assert "embed" in kwargs, (
"notify_tier_completion must send an embed, not plain text"
)
@pytest.mark.asyncio
async def test_embed_type_is_discord_embed(self):
"""The embed passed to channel.send must be a discord.Embed instance."""
channel = AsyncMock()
tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up)
_, kwargs = channel.send.call_args
assert isinstance(kwargs["embed"], discord.Embed)
@pytest.mark.asyncio
async def test_notification_failure_does_not_raise(self):
"""If channel.send raises, notify_tier_completion must swallow it so game flow is unaffected."""
channel = AsyncMock()
channel.send.side_effect = Exception("Discord API unavailable")
tier_up = make_tier_up(new_tier=2)
# Should not raise
await notify_tier_completion(channel, tier_up)
@pytest.mark.asyncio
async def test_multiple_tier_ups_caller_sends_multiple_embeds(self):
"""
Callers are responsible for iterating tier-up events; each call to
notify_tier_completion sends a separate embed. This test simulates
three consecutive calls (3 events) and asserts 3 sends occurred.
"""
channel = AsyncMock()
events = [
make_tier_up(player_name="Mike Trout", new_tier=2),
make_tier_up(player_name="Aaron Judge", new_tier=1),
make_tier_up(player_name="Shohei Ohtani", new_tier=3),
]
for event in events:
await notify_tier_completion(channel, event)
assert channel.send.call_count == 3, (
"Each tier-up event must produce its own embed (no batching)"
)
@pytest.mark.asyncio
async def test_no_tier_ups_means_no_sends(self):
"""
When the caller has an empty list of tier-up events and simply
does not call notify_tier_completion, zero sends happen.
This explicitly guards against any accidental unconditional send.
"""
channel = AsyncMock()
tier_up_events = []
for event in tier_up_events:
await notify_tier_completion(channel, event)
channel.send.assert_not_called()
# ---------------------------------------------------------------------------
# T1-5: tier_up dict shape validation
# ---------------------------------------------------------------------------
class TestTierUpDictShapeValidation:
"""
T1-5: Verify build_tier_up_embed handles valid API shapes correctly and
rejects malformed input.
The evaluate-game API endpoint returns the full shape (player_name,
old_tier, new_tier, track_name, current_value). These tests guard the
contract between the API response and the embed builder.
"""
def test_empty_dict_raises_key_error(self):
"""
An empty dict must raise KeyError guards against callers passing
unrelated or completely malformed data.
"""
with pytest.raises(KeyError):
build_tier_up_embed({})
def test_full_api_shape_builds_embed(self):
"""
The full shape returned by the evaluate-game endpoint builds a valid
embed without error.
"""
full_shape = make_tier_up(
player_name="Mike Trout",
old_tier=1,
new_tier=2,
track_name="Batter Track",
current_value=150,
)
embed = build_tier_up_embed(full_shape)
assert embed is not None
assert "Mike Trout" in embed.description
# ---------------------------------------------------------------------------
# T2-7: notify_tier_completion with None channel
# ---------------------------------------------------------------------------
class TestNotifyTierCompletionNoneChannel:
"""
T2-7: notify_tier_completion must not propagate exceptions when the channel
is None.
Why: the post-game hook may call notify_tier_completion before a valid
channel is resolved (e.g. in tests, or if the scoreboard channel lookup
fails). The try/except in notify_tier_completion should catch the
AttributeError from None.send() so game flow is never interrupted.
"""
@pytest.mark.asyncio
async def test_none_channel_does_not_raise(self):
"""
Passing None as the channel argument must not raise.
None.send() raises AttributeError; the try/except in
notify_tier_completion is expected to absorb it silently.
"""
tier_up = make_tier_up(new_tier=2)
# Should not raise regardless of channel being None
await notify_tier_completion(None, tier_up)