Merge branch 'main' into hotfix/open-packs-checkin
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
This commit is contained in:
commit
b6592b8a70
@ -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 }}
|
||||
|
||||
31
.gitea/workflows/ruff-lint.yml
Normal file
31
.gitea/workflows/ruff-lint.yml
Normal 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 .
|
||||
@ -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"
|
||||
```
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
FROM python:3.12-slim
|
||||
FROM python:3.12.13-slim
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
@ -17,7 +17,6 @@ DB_URL = (
|
||||
if "prod" in ENV_DATABASE
|
||||
else "https://pddev.manticorum.com/api"
|
||||
)
|
||||
PLAYER_CACHE = {}
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
|
||||
|
||||
2005
cogs/economy.py
2005
cogs/economy.py
File diff suppressed because it is too large
Load Diff
@ -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))
|
||||
|
||||
1808
cogs/gameplay.py
1808
cogs/gameplay.py
File diff suppressed because it is too large
Load Diff
1353
cogs/players.py
1353
cogs/players.py
File diff suppressed because it is too large
Load Diff
@ -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
423
cogs/refractor.py
Normal 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))
|
||||
@ -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()
|
||||
|
||||
|
||||
1444
db_calls_gameplay.py
1444
db_calls_gameplay.py
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||
|
||||
252
discord_utils.py
252
discord_utils.py
@ -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
2153
helpers.py
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
129
helpers/main.py
129
helpers/main.py
@ -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
108
helpers/refractor_notifs.py
Normal 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,
|
||||
)
|
||||
@ -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__":
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
3
requirements-dev.txt
Normal file
@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest==9.0.2
|
||||
pytest-asyncio==1.3.0
|
||||
@ -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
37
ruff.toml
Normal 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"]
|
||||
700
tests/refractor-integration-test-plan.md
Normal file
700
tests/refractor-integration-test-plan.md
Normal 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
22
tests/refractor-preflight.sh
Executable 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)"
|
||||
298
tests/test_card_embed_refractor.py
Normal file
298
tests/test_card_embed_refractor.py
Normal 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]
|
||||
205
tests/test_complete_game_hook.py
Normal file
205
tests/test_complete_game_hook.py
Normal 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
|
||||
740
tests/test_refractor_commands.py
Normal file
740
tests/test_refractor_commands.py
Normal 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()
|
||||
328
tests/test_refractor_notifs.py
Normal file
328
tests/test_refractor_notifs.py
Normal 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)
|
||||
Loading…
Reference in New Issue
Block a user