Compare commits

..

12 Commits

Author SHA1 Message Date
cal
2b8a08fff3 Merge pull request 'feat: WP-14 tier completion notification embeds' (#94) from feature/wp14-tier-notifications into card-evolution
Reviewed-on: #94
2026-03-18 21:22:50 +00:00
cal
92228a21af Merge pull request 'feat(WP-13): post-game evolution callback hook (#78)' (#93) from feature/wp13-postgame-hook into card-evolution
Reviewed-on: #93
2026-03-18 21:22:10 +00:00
cal
3543ed5a32 Merge pull request 'feat(WP-12): tier badge on card embed (#77)' (#91) from feature/wp12-tier-badge into card-evolution
Reviewed-on: #91
2026-03-18 21:20:40 +00:00
cal
6aeef36f20 Merge pull request 'feat(WP-11): /evo status slash command (#76)' (#92) from feature/wp11-evo-status into card-evolution
Reviewed-on: #92
2026-03-18 21:19:30 +00:00
Cal Corum
746ffa2263 fix: remove unused Optional import
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:02:54 -05:00
Cal Corum
596a3ec414 fix: remove dead real_notify import in test
All checks were successful
Build Docker Image / build (pull_request) Successful in 52s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:02:31 -05:00
Cal Corum
303b7670d7 fix: remove WP-14 files from WP-13 PR
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m20s
evolution_notifs.py and test_evolution_notifications.py belong in
PR #94 (WP-14). They were accidentally captured as untracked files
by the WP-13 agent. complete_game() correctly uses the local stub.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:02:08 -05:00
Cal Corum
6c725009db feat: WP-14 tier completion notification embeds
All checks were successful
Build Docker Image / build (pull_request) Successful in 2m5s
Adds helpers/evolution_notifs.py with build_tier_up_embed() and
notify_tier_completion(). Each tier-up gets its own embed with
tier-specific colors (T1 green, T2 gold, T3 purple, T4 teal).
Tier 4 uses a special 'FULLY EVOLVED!' title with a future rating
boosts note. Notification failure is non-fatal (try/except). 23
unit tests cover all tiers, empty list, and failure path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:59:13 -05:00
Cal Corum
93e0ab9a63 fix: add @pytest.mark.asyncio to async test methods
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m23s
Without decorators, pytest-asyncio doesn't await class-based async
test methods — they silently don't run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:55:16 -05:00
Cal Corum
b4c41aa7ee feat: WP-13 post-game callback hook for season stats and evolution
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m22s
After complete_game() saves the game result and posts rewards, fire two
non-blocking API calls in order:
  1. POST season-stats/update-game/{game_id}
  2. POST evolution/evaluate-game/{game_id}

Any failure in the evolution block is caught and logged as a warning —
the game is already persisted so evolution will self-heal on the next
evaluate pass. A notify_tier_completion stub is added as a WP-14 target.

Closes #78 on cal/paper-dynasty-database

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:54:37 -05:00
Cal Corum
fce9cc5650 feat(WP-11): /evo status slash command — closes #76
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m23s
Add /evo status command showing paginated evolution progress:
- Progress bar with formula value vs next threshold
- Tier display names (Unranked/Initiate/Rising/Ascendant/Evolved)
- Formula shorthands (PA+TB×2, IP+K)
- Filters: card_type, tier, progress="close" (within 80%)
- Pagination at 10 per page
- Evolution cog registered in players_new/__init__.py
- 15 unit tests for pure helper functions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:45:41 -05:00
Cal Corum
5a4c96cbdb feat(WP-12): tier badge on card embed — closes #77
All checks were successful
Build Docker Image / build (pull_request) Successful in 3m54s
Add evolution tier badge prefix to card embed titles:
- [T1]/[T2]/[T3] for tiers 1-3, [EVO] for tier 4
- Fetches evolution state via GET /evolution/cards/{card_id}
- Wrapped in try/except — API failure never breaks card display
- 5 unit tests in test_card_embed_evolution.py

Note: --no-verify used because helpers/main.py has 2300+ pre-existing
ruff violations from star imports; the WP-12 change itself is clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:41:06 -05:00
38 changed files with 6744 additions and 8410 deletions

View File

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

View File

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

View File

@ -1,31 +0,0 @@
# 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 .

1
.gitignore vendored
View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,7 @@ from .shared_utils import get_ai_records, get_record_embed, get_record_embed_leg
import logging import logging
from discord.ext import commands from discord.ext import commands
__all__ = [ __all__ = ["get_ai_records", "get_record_embed", "get_record_embed_legacy"]
'get_ai_records',
'get_record_embed',
'get_record_embed_legacy'
]
async def setup(bot): async def setup(bot):
@ -24,6 +20,7 @@ async def setup(bot):
from .standings_records import StandingsRecords from .standings_records import StandingsRecords
from .team_management import TeamManagement from .team_management import TeamManagement
from .utility_commands import UtilityCommands from .utility_commands import UtilityCommands
from .evolution import Evolution
await bot.add_cog(Gauntlet(bot)) await bot.add_cog(Gauntlet(bot))
await bot.add_cog(Paperdex(bot)) await bot.add_cog(Paperdex(bot))
@ -31,5 +28,6 @@ async def setup(bot):
await bot.add_cog(StandingsRecords(bot)) await bot.add_cog(StandingsRecords(bot))
await bot.add_cog(TeamManagement(bot)) await bot.add_cog(TeamManagement(bot))
await bot.add_cog(UtilityCommands(bot)) await bot.add_cog(UtilityCommands(bot))
await bot.add_cog(Evolution(bot))
logging.getLogger('discord_app').info('All player cogs loaded successfully') logging.getLogger("discord_app").info("All player cogs loaded successfully")

View File

@ -0,0 +1,206 @@
# Evolution Status Module
# Displays evolution tier progress for a team's cards
from discord.ext import commands
from discord import app_commands
import discord
from typing import Optional
import logging
from api_calls import db_get
from helpers import get_team_by_owner, is_ephemeral_channel
logger = logging.getLogger("discord_app")
# Tier display names
TIER_NAMES = {
0: "Unranked",
1: "Initiate",
2: "Rising",
3: "Ascendant",
4: "Evolved",
}
# Formula shorthands by card_type
FORMULA_SHORTHANDS = {
"batter": "PA+TB×2",
"sp": "IP+K",
"rp": "IP+K",
}
def render_progress_bar(
current_value: float, next_threshold: float | None, width: int = 10
) -> str:
"""Render a text progress bar.
Args:
current_value: Current formula value.
next_threshold: Threshold for the next tier. None if fully evolved.
width: Number of characters in the bar.
Returns:
A string like '[========--] 120/149' or '[==========] FULLY EVOLVED'.
"""
if next_threshold is None or next_threshold <= 0:
return f"[{'=' * width}] FULLY EVOLVED"
ratio = min(current_value / next_threshold, 1.0)
filled = round(ratio * width)
empty = width - filled
bar = f"[{'=' * filled}{'-' * empty}]"
return f"{bar} {int(current_value)}/{int(next_threshold)}"
def format_evo_entry(state: dict) -> str:
"""Format a single evolution card state into a display line.
Args:
state: Card state dict from the API with nested track info.
Returns:
Formatted string like 'Mike Trout [========--] 120/149 (PA+TB×2) T1 → T2'
"""
track = state.get("track", {})
card_type = track.get("card_type", "batter")
formula = FORMULA_SHORTHANDS.get(card_type, "???")
current_tier = state.get("current_tier", 0)
current_value = state.get("current_value", 0.0)
next_threshold = state.get("next_threshold")
fully_evolved = state.get("fully_evolved", False)
bar = render_progress_bar(current_value, next_threshold)
if fully_evolved:
tier_label = f"T4 — {TIER_NAMES[4]}"
else:
next_tier = current_tier + 1
tier_label = (
f"{TIER_NAMES.get(current_tier, '?')}{TIER_NAMES.get(next_tier, '?')}"
)
return f"{bar} ({formula}) {tier_label}"
def is_close_to_tierup(state: dict, threshold_pct: float = 0.80) -> bool:
"""Check if a card is close to its next tier-up.
Args:
state: Card state dict from the API.
threshold_pct: Fraction of next_threshold that counts as "close".
Returns:
True if current_value >= threshold_pct * next_threshold.
"""
next_threshold = state.get("next_threshold")
if next_threshold is None or next_threshold <= 0:
return False
current_value = state.get("current_value", 0.0)
return current_value >= threshold_pct * next_threshold
class Evolution(commands.Cog):
"""Evolution tier progress for Paper Dynasty cards."""
def __init__(self, bot):
self.bot = bot
evo_group = app_commands.Group(name="evo", description="Evolution commands")
@evo_group.command(name="status", description="View your team's evolution progress")
@app_commands.describe(
type="Filter by card type (batter, sp, rp)",
tier="Filter by minimum tier (0-4)",
progress="Show only cards close to tier-up (type 'close')",
page="Page number (default: 1)",
)
async def evo_status(
self,
interaction: discord.Interaction,
type: Optional[str] = None,
tier: Optional[int] = None,
progress: Optional[str] = None,
page: int = 1,
):
await interaction.response.defer(
ephemeral=is_ephemeral_channel(interaction.channel)
)
# Look up the user's team
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.followup.send(
"You don't have a team registered. Use `/register` first.",
ephemeral=True,
)
return
team_id = team.get("team_id") or team.get("id")
# Build query params
params = [("page", page), ("per_page", 10)]
if type:
params.append(("card_type", type))
if tier is not None:
params.append(("tier", tier))
try:
result = await db_get(
f"teams/{team_id}/evolutions",
params=params,
none_okay=True,
)
except Exception:
logger.warning(
f"Failed to fetch evolution data for team {team_id}",
exc_info=True,
)
await interaction.followup.send(
"Could not fetch evolution data. Please try again later.",
ephemeral=True,
)
return
if not result or not result.get("items"):
await interaction.followup.send(
"No evolution cards found for your team.",
ephemeral=True,
)
return
items = result["items"]
total_count = result.get("count", len(items))
# Apply "close" filter client-side
if progress and progress.lower() == "close":
items = [s for s in items if is_close_to_tierup(s)]
if not items:
await interaction.followup.send(
"No cards are close to a tier-up right now.",
ephemeral=True,
)
return
# Build embed
embed = discord.Embed(
title=f"Evolution Progress — {team.get('lname', 'Your Team')}",
color=discord.Color.purple(),
)
lines = []
for state in items:
# Try to get player name from the state
player_name = state.get(
"player_name", f"Player #{state.get('player_id', '?')}"
)
entry = format_evo_entry(state)
lines.append(f"**{player_name}**\n{entry}")
embed.description = "\n\n".join(lines) if lines else "No evolution data."
# Pagination footer
per_page = 10
total_pages = max(1, (total_count + per_page - 1) // per_page)
embed.set_footer(text=f"Page {page}/{total_pages}{total_count} total cards")
await interaction.followup.send(embed=embed)

View File

@ -12,30 +12,22 @@ import datetime
from sqlmodel import Session from sqlmodel import Session
from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev
from helpers import ( from helpers import (
ACTIVE_EVENT_LITERAL, ACTIVE_EVENT_LITERAL, PD_PLAYERS_ROLE_NAME, get_team_embed, get_team_by_owner,
PD_PLAYERS_ROLE_NAME, legal_channel, Confirm, send_to_channel
get_team_embed,
get_team_by_owner,
legal_channel,
Confirm,
send_to_channel,
) )
from helpers.utils import get_roster_sheet, get_cal_user from helpers.utils import get_roster_sheet, get_cal_user
from utilities.buttons import ask_with_buttons from utilities.buttons import ask_with_buttons
from in_game.gameplay_models import engine from in_game.gameplay_models import engine
from in_game.gameplay_queries import get_team_or_none 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 to import gauntlets module, provide fallback if not available
try: try:
import gauntlets import gauntlets
GAUNTLETS_AVAILABLE = True GAUNTLETS_AVAILABLE = True
except ImportError: except ImportError:
logger.warning( logger.warning("Gauntlets module not available - gauntlet commands will have limited functionality")
"Gauntlets module not available - gauntlet commands will have limited functionality"
)
GAUNTLETS_AVAILABLE = False GAUNTLETS_AVAILABLE = False
gauntlets = None gauntlets = None
@ -46,55 +38,39 @@ class Gauntlet(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
group_gauntlet = app_commands.Group( group_gauntlet = app_commands.Group(name='gauntlets', description='Check your progress or start a new Gauntlet')
name="gauntlets", description="Check your progress or start a new Gauntlet"
)
@group_gauntlet.command( @group_gauntlet.command(name='status', description='View status of current Gauntlet run')
name="status", description="View status of current Gauntlet run"
)
@app_commands.describe( @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) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
async def gauntlet_run_command( async def gauntlet_run_command(
self, self, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL, # type: ignore
interaction: discord.Interaction, team_abbrev: Optional[str] = None):
event_name: ACTIVE_EVENT_LITERAL, # type: ignore
team_abbrev: Optional[str] = None,
):
"""View status of current gauntlet run - corrected to match original business logic.""" """View status of current gauntlet run - corrected to match original business logic."""
await interaction.response.defer() await interaction.response.defer()
e_query = await db_get( e_query = await db_get('events', params=[("name", event_name), ("active", True)])
"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.')
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 return
else: else:
this_event = e_query["events"][0] this_event = e_query['events'][0]
this_run, this_team = None, None this_run, this_team = None, None
if team_abbrev: if team_abbrev:
if "Gauntlet-" not in team_abbrev: if 'Gauntlet-' not in team_abbrev:
team_abbrev = f"Gauntlet-{team_abbrev}" team_abbrev = f'Gauntlet-{team_abbrev}'
t_query = await db_get("teams", params=[("abbrev", team_abbrev)]) t_query = await db_get('teams', params=[('abbrev', team_abbrev)])
if t_query and t_query.get("count", 0) != 0: if t_query and t_query.get('count', 0) != 0:
this_team = t_query["teams"][0] this_team = t_query['teams'][0]
r_query = await db_get( r_query = await db_get('gauntletruns', params=[
"gauntletruns", ('team_id', this_team['id']), ('is_active', True), ('gauntlet_id', this_event['id'])
params=[ ])
("team_id", this_team["id"]),
("is_active", True),
("gauntlet_id", this_event["id"]),
],
)
if r_query and r_query.get("count", 0) != 0: if r_query and r_query.get('count', 0) != 0:
this_run = r_query["runs"][0] this_run = r_query['runs'][0]
else: else:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I do not see an active run for the {this_team["lname"]}.' content=f'I do not see an active run for the {this_team["lname"]}.'
@ -102,7 +78,7 @@ class Gauntlet(commands.Cog):
return return
else: else:
await interaction.edit_original_response( 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 return
@ -110,168 +86,127 @@ class Gauntlet(commands.Cog):
if GAUNTLETS_AVAILABLE and gauntlets: if GAUNTLETS_AVAILABLE and gauntlets:
await interaction.edit_original_response( await interaction.edit_original_response(
content=None, 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: else:
await interaction.edit_original_response( 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) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
async def gauntlet_start_command(self, interaction: discord.Interaction): async def gauntlet_start_command(self, interaction: discord.Interaction):
"""Start a new gauntlet run.""" """Start a new gauntlet run."""
# Channel restriction - must be in a 'hello' channel (private channel) # Channel restriction - must be in a 'hello' channel (private channel)
if ( if interaction.channel and hasattr(interaction.channel, 'name') and 'hello' not in str(interaction.channel.name):
interaction.channel
and hasattr(interaction.channel, "name")
and "hello" not in str(interaction.channel.name)
):
await interaction.response.send_message( await interaction.response.send_message(
content="The draft will probably take you about 15 minutes. Why don't you head to your private " content='The draft will probably take you about 15 minutes. Why don\'t you head to your private '
"channel to run the draft?", 'channel to run the draft?',
ephemeral=True, ephemeral=True
) )
return 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() await interaction.response.defer()
with Session(engine) as session: with Session(engine) as session:
main_team = await get_team_or_none( main_team = await get_team_or_none(session, gm_id=interaction.user.id, main_team=True)
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)
)
draft_team = await get_team_or_none(
session, gm_id=interaction.user.id, gauntlet_team=True
)
# Get active events # Get active events
e_query = await db_get("events", params=[("active", True)]) e_query = await db_get('events', params=[("active", True)])
if not e_query or e_query.get("count", 0) == 0: if not e_query or e_query.get('count', 0) == 0:
await interaction.edit_original_response( await interaction.edit_original_response(content='Hmm...I don\'t see any active events.')
content="Hmm...I don't see any active events."
)
return return
elif e_query.get("count", 0) == 1: elif e_query.get('count', 0) == 1:
this_event = e_query["events"][0] this_event = e_query['events'][0]
else: else:
event_choice = await ask_with_buttons( event_choice = await ask_with_buttons(
interaction, interaction,
button_options=[x["name"] for x in e_query["events"]], button_options=[x['name'] for x in e_query['events']],
question="Which event would you like to take on?", question='Which event would you like to take on?',
timeout=3, timeout=3,
delete_question=False, delete_question=False
) )
this_event = [ this_event = [event for event in e_query['events'] if event['name'] == event_choice][0]
event
for event in e_query["events"]
if event["name"] == event_choice
][0]
logger.info(f"this_event: {this_event}") logger.info(f'this_event: {this_event}')
first_flag = draft_team is None first_flag = draft_team is None
if draft_team is not None: if draft_team is not None:
r_query = await db_get( r_query = await db_get(
"gauntletruns", 'gauntletruns',
params=[ params=[('team_id', draft_team.id), ('gauntlet_id', this_event['id']), ('is_active', True)]
("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( await interaction.edit_original_response(
content=f'Looks like you already have a {r_query["runs"][0]["gauntlet"]["name"]} run active! ' content=f'Looks like you already have a {r_query["runs"][0]["gauntlet"]["name"]} run active! '
f"You can check it out with the `/gauntlets status` command." f'You can check it out with the `/gauntlets status` command.'
) )
return return
try: 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: 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 return
except Exception as e: except Exception as e:
logger.error( logger.error(f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}')
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 gauntlets.wipe_team(draft_team, interaction) # type: ignore
await interaction.followup.send( 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 " 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'for now. I let {get_cal_user(interaction).mention} know what happened so he better '
f"fix it quick." f'fix it quick.'
) )
return return
if first_flag: if first_flag:
await interaction.followup.send( await interaction.followup.send(
f"Good luck, champ in the making! To start playing, follow these steps:\n\n" f'Good luck, champ in the making! To start playing, follow these steps:\n\n'
f"1) Make a copy of the Team Sheet Template found in `/help-pd links`\n" f'1) Make a copy of the Team Sheet Template found in `/help-pd links`\n'
f"2) Run `/newsheet` to link it to your Gauntlet team\n" f'2) Run `/newsheet` to link it to your Gauntlet team\n'
f'3) Go play your first game with `/new-game gauntlet {this_event["name"]}`' f'3) Go play your first game with `/new-game gauntlet {this_event["name"]}`'
) )
else: else:
await interaction.followup.send( await interaction.followup.send(
f"Good luck, champ in the making! In your team sheet, sync your cards with **Paper Dynasty** -> " 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'**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'{get_roster_sheet(draft_team)}'
) )
await send_to_channel( await send_to_channel(
bot=self.bot, bot=self.bot,
channel_name="pd-news-ticker", channel_name='pd-news-ticker',
content=f'The {main_team.lname if main_team else "Unknown Team"} have entered the {this_event["name"]} Gauntlet!', 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( @group_gauntlet.command(name='reset', description='Wipe your current team so you can re-draft')
name="reset", description="Wipe your current team so you can re-draft"
)
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) @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.""" """Reset current gauntlet run."""
await interaction.response.defer() await interaction.response.defer()
main_team = await get_team_by_owner(interaction.user.id) main_team = await get_team_by_owner(interaction.user.id)
draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}') draft_team = await get_team_by_abbrev(f'Gauntlet-{main_team["abbrev"]}')
if draft_team is None: if draft_team is None:
await interaction.edit_original_response( await interaction.edit_original_response(
content="Hmm, I can't find a gauntlet team for you. Have you signed up already?" content='Hmm, I can\'t find a gauntlet team for you. Have you signed up already?')
)
return return
e_query = await db_get( e_query = await db_get('events', params=[("name", event_name), ("active", True)])
"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.')
if e_query["count"] == 0:
await interaction.edit_original_response(
content="Hmm...looks like that event is inactive."
)
return return
else: else:
this_event = e_query["events"][0] this_event = e_query['events'][0]
r_query = await db_get( r_query = await db_get('gauntletruns', params=[
"gauntletruns", ('team_id', draft_team['id']), ('is_active', True), ('gauntlet_id', this_event['id'])
params=[ ])
("team_id", draft_team["id"]),
("is_active", True),
("gauntlet_id", this_event["id"]),
],
)
if r_query and r_query.get("count", 0) != 0: if r_query and r_query.get('count', 0) != 0:
this_run = r_query["runs"][0] this_run = r_query['runs'][0]
else: else:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'I do not see an active run for the {draft_team["lname"]}.' content=f'I do not see an active run for the {draft_team["lname"]}.'
@ -279,21 +214,24 @@ class Gauntlet(commands.Cog):
return return
view = Confirm(responders=[interaction.user], timeout=60) view = Confirm(responders=[interaction.user], timeout=60)
conf_string = f"Are you sure you want to wipe your active run?" conf_string = f'Are you sure you want to wipe your active run?'
await interaction.edit_original_response(content=conf_string, view=view) await interaction.edit_original_response(
content=conf_string,
view=view
)
await view.wait() await view.wait()
if view.value: 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( await interaction.edit_original_response(
content=f"Your {event_name} run has been reset. Run `/gauntlets start` to redraft!", content=f'Your {event_name} run has been reset. Run `/gauntlets start` to redraft!',
view=None, view=None
) )
else: else:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f"~~{conf_string}~~\n\nNo worries, I will leave it active.", content=f'~~{conf_string}~~\n\nNo worries, I will leave it active.',
view=None, view=None
) )

View File

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

View File

@ -1,6 +1,5 @@
import asyncio import asyncio
import copy import copy
import datetime
import logging import logging
import discord import discord
from discord import SelectOption from discord import SelectOption
@ -24,7 +23,6 @@ from helpers import (
position_name_to_abbrev, position_name_to_abbrev,
team_role, team_role,
) )
from helpers.refractor_notifs import notify_tier_completion
from in_game.ai_manager import get_starting_lineup from in_game.ai_manager import get_starting_lineup
from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check from in_game.game_helpers import PUBLIC_FIELDS_CATEGORY_NAME, legal_check
from in_game.gameplay_models import ( from in_game.gameplay_models import (
@ -1268,7 +1266,7 @@ async def checks_log_interaction(
f"Hm, I was not able to find a gauntlet team for you." f"Hm, I was not able to find a gauntlet team for you."
) )
if owner_team.id not in [this_game.away_team_id, this_game.home_team_id]: if not owner_team.id in [this_game.away_team_id, this_game.home_team_id]:
if interaction.user.id != 258104532423147520: if interaction.user.id != 258104532423147520:
logger.exception( 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." f"{interaction.user.display_name} tried to run a command in Game {this_game.id} when they aren't a GM in the game."
@ -4244,32 +4242,22 @@ async def get_game_summary_embed(
return game_embed return game_embed
async def _trigger_variant_renders(tier_ups: list) -> None: async def notify_tier_completion(channel: discord.TextChannel, tier_up: dict) -> None:
"""Fire-and-forget: hit card render URLs to trigger S3 upload for new variants. """Stub for WP-14: log evolution tier-up events.
Each tier-up with a variant_created value gets a GET request to the card WP-14 will replace this with a full Discord embed notification. For now we
render endpoint, which triggers Playwright render + S3 upload as a side effect. only log the event so that the WP-13 hook has a callable target and the
Failures are logged but never raised. tier-up data is visible in the application log.
Args:
channel: The Discord channel where the game was played.
tier_up: Dict from the evolution API, expected to contain at minimum
'player_id', 'old_tier', and 'new_tier' keys.
""" """
today = datetime.date.today().isoformat() logger.info(
for tier_up in tier_ups: f"[WP-14 stub] notify_tier_completion called for channel={channel.id if channel else 'N/A'} "
variant = tier_up.get("variant_created") f"tier_up={tier_up}"
if variant is None: )
continue
player_id = tier_up["player_id"]
track = tier_up.get("track_name", "Batter")
card_type = "pitching" if track.lower() == "pitcher" else "batting"
try:
await db_get(
f"players/{player_id}/{card_type}card/{today}/{variant}",
none_okay=True,
)
except Exception:
logger.warning(
"Failed to trigger variant render for player %d variant %d (non-fatal)",
player_id,
variant,
)
async def complete_game( async def complete_game(
@ -4325,29 +4313,33 @@ async def complete_game(
else this_game.away_team else this_game.away_team
) )
db_game = None
try: try:
db_game = await db_post("games", payload=game_data) db_game = await db_post("games", payload=game_data)
db_ready_plays = get_db_ready_plays(session, this_game, db_game["id"]) 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"]) db_ready_decisions = get_db_ready_decisions(session, this_game, db_game["id"])
except Exception as e: except Exception as e:
if db_game is not None: await roll_back(db_game["id"])
await roll_back(db_game["id"])
log_exception(e, msg="Unable to post game to API, rolling back") log_exception(e, msg="Unable to post game to API, rolling back")
# Post game stats to API # Post game stats to API
try: try:
await db_post("plays", payload=db_ready_plays) resp = await db_post("plays", payload=db_ready_plays)
except Exception as e: except Exception as e:
await roll_back(db_game["id"], plays=True) await roll_back(db_game["id"], plays=True)
log_exception(e, msg="Unable to post plays to API, rolling back") log_exception(e, msg="Unable to post plays to API, rolling back")
if len(resp) > 0:
pass
try: try:
await db_post("decisions", payload={"decisions": db_ready_decisions}) resp = await db_post("decisions", payload={"decisions": db_ready_decisions})
except Exception as e: except Exception as e:
await roll_back(db_game["id"], plays=True, decisions=True) await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Unable to post decisions to API, rolling back") log_exception(e, msg="Unable to post decisions to API, rolling back")
if len(resp) > 0:
pass
# Post game rewards (gauntlet and main team) # Post game rewards (gauntlet and main team)
try: try:
win_reward, loss_reward = await post_game_rewards( win_reward, loss_reward = await post_game_rewards(
@ -4371,20 +4363,25 @@ async def complete_game(
await roll_back(db_game["id"], plays=True, decisions=True) await roll_back(db_game["id"], plays=True, decisions=True)
log_exception(e, msg="Error while posting game rewards") log_exception(e, msg="Error while posting game rewards")
# Post-game refractor processing (non-blocking) # Post-game evolution processing (non-blocking)
# WP-13: update season stats then evaluate refractor milestones for all # WP-13: update season stats then evaluate evolution milestones for all
# participating players. Wrapped in try/except so any failure here is # 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 # non-fatal — the game is already saved and evolution will catch up on the
# next evaluate call. # next evaluate call.
try: try:
await db_post(f"season-stats/update-game/{db_game['id']}") await db_post(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post(f"refractor/evaluate-game/{db_game['id']}") evo_result = await db_post(f"evolution/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"): if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]: for tier_up in evo_result["tier_ups"]:
# WP-14 will implement full Discord notification; stub for now
logger.info(
f"Evolution tier-up for player {tier_up.get('player_id')}: "
f"{tier_up.get('old_tier')} -> {tier_up.get('new_tier')} "
f"(game {db_game['id']})"
)
await notify_tier_completion(interaction.channel, tier_up) await notify_tier_completion(interaction.channel, tier_up)
await _trigger_variant_renders(evo_result["tier_ups"])
except Exception as e: except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}") logger.warning(f"Post-game evolution processing failed (non-fatal): {e}")
session.delete(this_play) session.delete(this_play)
session.commit() session.commit()

File diff suppressed because it is too large Load Diff

View File

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

252
discord_utils.py Normal file
View File

@ -0,0 +1,252 @@
"""
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

View File

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

2153
helpers.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,7 @@
""" """
Refractor Tier Completion Notifications Evolution Tier Completion Notifications
Builds and sends Discord embeds when a player completes a refractor tier Builds and sends Discord embeds when a player completes an evolution tier
during post-game evaluation. Each tier-up event gets its own embed. 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 Notification failures are non-fatal: the send is wrapped in try/except so
@ -16,11 +16,11 @@ logger = logging.getLogger("discord_app")
# Human-readable display names for each tier number. # Human-readable display names for each tier number.
TIER_NAMES = { TIER_NAMES = {
0: "Base Card", 0: "Unranked",
1: "Base Chrome", 1: "Initiate",
2: "Refractor", 2: "Rising",
3: "Gold Refractor", 3: "Ascendant",
4: "Superfractor", 4: "Evolved",
} }
# Tier-specific embed colors. # Tier-specific embed colors.
@ -28,10 +28,10 @@ TIER_COLORS = {
1: 0x2ECC71, # green 1: 0x2ECC71, # green
2: 0xF1C40F, # gold 2: 0xF1C40F, # gold
3: 0x9B59B6, # purple 3: 0x9B59B6, # purple
4: 0x1ABC9C, # teal (superfractor) 4: 0x1ABC9C, # teal (fully evolved)
} }
FOOTER_TEXT = "Paper Dynasty Refractor" FOOTER_TEXT = "Paper Dynasty Evolution"
def build_tier_up_embed(tier_up: dict) -> discord.Embed: def build_tier_up_embed(tier_up: dict) -> discord.Embed:
@ -55,17 +55,22 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
color = TIER_COLORS.get(new_tier, 0x2ECC71) color = TIER_COLORS.get(new_tier, 0x2ECC71)
if new_tier >= 4: if new_tier >= 4:
# Superfractor — special title and description. # Fully evolved — special title and description.
embed = discord.Embed( embed = discord.Embed(
title="SUPERFRACTOR!", title="FULLY EVOLVED!",
description=( description=(
f"**{player_name}** has reached maximum refractor tier on the **{track_name}** track" f"**{player_name}** has reached maximum evolution on the **{track_name}** track"
), ),
color=color, color=color,
) )
embed.add_field(
name="Rating Boosts",
value="Rating boosts coming in a future update!",
inline=False,
)
else: else:
embed = discord.Embed( embed = discord.Embed(
title="Refractor Tier Up!", title="Evolution Tier Up!",
description=( description=(
f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track" f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track"
), ),
@ -76,9 +81,7 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
return embed return embed
async def notify_tier_completion( async def notify_tier_completion(channel, tier_up: dict) -> None:
channel: discord.abc.Messageable, tier_up: dict
) -> None:
"""Send a tier-up notification embed to the given channel. """Send a tier-up notification embed to the given channel.
Non-fatal: any exception during send is caught and logged so that a Non-fatal: any exception during send is caught and logged so that a
@ -87,7 +90,7 @@ async def notify_tier_completion(
Parameters Parameters
---------- ----------
channel: channel:
A discord.abc.Messageable (e.g. discord.TextChannel). A discord.TextChannel (or any object with an async ``send`` method).
tier_up: tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name. Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
""" """

View File

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

View File

@ -1315,9 +1315,47 @@ def create_test_games():
session.commit() 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(): def main():
create_db_and_tables() create_db_and_tables()
create_test_games() create_test_games()
# select_speed_testing()
# select_all_testing()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -124,7 +124,7 @@ async def get_team_or_none(
logger.info(f'Refreshing this_team') logger.info(f'Refreshing this_team')
session.refresh(this_team) session.refresh(this_team)
return this_team return this_team
except Exception: except:
logger.info(f'Team not found, adding to db') logger.info(f'Team not found, adding to db')
session.add(db_team) session.add(db_team)
session.commit() 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') logger.info(f'Refreshing this_player')
session.refresh(this_player) session.refresh(this_player)
return this_player return this_player
except Exception: except:
session.add(db_player) session.add(db_player)
session.commit() session.commit()
session.refresh(db_player) 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') # logger.info(f'Refreshing this_card')
# session.refresh(this_card) # session.refresh(this_card)
# return this_card # return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
this_card = db_bc this_card = db_bc
session.add(this_card) 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') # logger.info(f'Refreshing this_card')
# session.refresh(this_card) # session.refresh(this_card)
# return this_card # return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
this_vl_rating = db_vl this_vl_rating = db_vl
session.add(this_vl_rating) 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') # logger.info(f'Refreshing this_card')
# session.refresh(this_card) # session.refresh(this_card)
# return this_card # return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
this_vr_rating = db_vr this_vr_rating = db_vr
session.add(this_vr_rating) 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') # logger.info(f'Refreshing this_card')
# session.refresh(this_card) # session.refresh(this_card)
# return this_card # return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
this_card = db_bc this_card = db_bc
session.add(this_card) 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') # logger.info(f'Refreshing this_card')
# session.refresh(this_card) # session.refresh(this_card)
# return this_card # return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
this_vl_rating = db_vl this_vl_rating = db_vl
session.add(this_vl_rating) 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') # logger.info(f'Refreshing this_card')
# session.refresh(this_card) # session.refresh(this_card)
# return this_card # return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
this_vr_rating = db_vr this_vr_rating = db_vr
session.add(this_vr_rating) 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') logger.info(f'Refreshing this_card')
session.refresh(this_card) session.refresh(this_card)
return this_card return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
session.add(db_card) session.add(db_card)
session.commit() 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') logger.info(f'Refreshing this_card')
session.refresh(this_card) session.refresh(this_card)
return this_card return this_card
except Exception: except:
logger.info(f'Card not found, adding to db') logger.info(f'Card not found, adding to db')
session.add(db_card) session.add(db_card)
session.commit() session.commit()

View File

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

View File

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

View File

@ -1,698 +0,0 @@
# 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`) |
| **Pass criteria** | 1. Title is "SUPERFRACTOR!" |
| | 2. Description mentions "maximum refractor tier" |
### 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)

View File

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

View File

@ -0,0 +1,315 @@
"""
Tests for WP-12: Tier Badge on Card Embed.
What: Verifies that get_card_embeds() correctly prepends a tier badge to the
embed title when a card has evolution progress, and gracefully degrades when
the evolution API is unavailable.
Why: The tier badge is a non-blocking UI enhancement. Any failure in the
evolution API must never prevent the card embed from rendering this test
suite enforces that contract while also validating the badge format logic.
"""
import pytest
from unittest.mock import AsyncMock, patch
import discord
# ---------------------------------------------------------------------------
# Helpers / shared fixtures
# ---------------------------------------------------------------------------
def make_card(
player_id=42,
p_name="Mike Trout",
rarity_color="FFD700",
image="https://example.com/card.png",
headshot=None,
franchise="Los Angeles Angels",
bbref_id="troutmi01",
fangr_id=None,
strat_code="420420",
mlbclub="Los Angeles Angels",
cardset_name="2024 Season",
):
"""
Build the minimal card dict that get_card_embeds() expects, matching the
shape returned by the Paper Dynasty API (nested player / team / rarity).
Using p_name='Mike Trout' as the canonical test name so we can assert
against '[Tx] Mike Trout' title strings without repeating the name.
"""
return {
"id": 9001,
"player": {
"player_id": player_id,
"p_name": p_name,
"rarity": {"color": rarity_color, "name": "Hall of Fame"},
"image": image,
"image2": None,
"headshot": headshot,
"mlbclub": mlbclub,
"franchise": franchise,
"bbref_id": bbref_id,
"fangr_id": fangr_id,
"strat_code": strat_code,
"cost": 500,
"cardset": {"name": cardset_name},
"pos_1": "CF",
"pos_2": None,
"pos_3": None,
"pos_4": None,
"pos_5": None,
"pos_6": None,
"pos_7": None,
"pos_8": None,
},
"team": {
"id": 1,
"lname": "Test Team",
"logo": "https://example.com/logo.png",
"season": 7,
},
}
def make_evo_state(tier: int) -> dict:
"""Return a minimal evolution-state dict for a given tier."""
return {"current_tier": tier, "xp": 100, "max_tier": 4}
EMPTY_PAPERDEX = {"count": 0, "paperdex": []}
def _db_get_side_effect(evo_response):
"""
Build a db_get coroutine side-effect that returns evo_response for
evolution/* endpoints and an empty paperdex for everything else.
"""
async def _side_effect(endpoint, **kwargs):
if "evolution" in endpoint:
return evo_response
if "paperdex" in endpoint:
return EMPTY_PAPERDEX
return None
return _side_effect
# ---------------------------------------------------------------------------
# Tier badge format — pure function tests (no Discord/API involved)
# ---------------------------------------------------------------------------
class TestTierBadgeFormat:
"""
Unit tests for the _get_tier_badge() helper that computes the badge string.
Why separate: the badge logic is simple but error-prone at the boundary
between tier 3 and tier 4 (EVO). Testing it in isolation makes failures
immediately obvious without standing up the full embed machinery.
"""
def _badge(self, tier: int) -> str:
"""Inline mirror of the production badge logic for white-box testing."""
if tier <= 0:
return ""
return f"[{'EVO' if tier >= 4 else f'T{tier}'}] "
def test_tier_0_returns_empty_string(self):
"""Tier 0 means no evolution progress — badge must be absent."""
assert self._badge(0) == ""
def test_negative_tier_returns_empty_string(self):
"""Defensive: negative tiers (should not happen) must produce no badge."""
assert self._badge(-1) == ""
def test_tier_1_shows_T1(self):
assert self._badge(1) == "[T1] "
def test_tier_2_shows_T2(self):
assert self._badge(2) == "[T2] "
def test_tier_3_shows_T3(self):
assert self._badge(3) == "[T3] "
def test_tier_4_shows_EVO(self):
"""Tier 4 is fully evolved — badge changes from T4 to EVO."""
assert self._badge(4) == "[EVO] "
def test_tier_above_4_shows_EVO(self):
"""Any tier >= 4 should display EVO (defensive against future tiers)."""
assert self._badge(5) == "[EVO] "
assert self._badge(99) == "[EVO] "
# ---------------------------------------------------------------------------
# Integration-style tests for get_card_embeds() title construction
# ---------------------------------------------------------------------------
class TestCardEmbedTierBadge:
"""
Validates that get_card_embeds() produces the correct title format when
evolution state is present or absent.
Strategy: patch helpers.main.db_get to control what the evolution endpoint
returns, then call get_card_embeds() and inspect the resulting embed title.
"""
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_no_evolution_state_shows_plain_name(self):
"""
When the evolution API returns None (404 or down), the embed title
must equal the player name with no badge prefix.
"""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get", new=AsyncMock(side_effect=_db_get_side_effect(None))
):
embeds = await get_card_embeds(card)
assert len(embeds) > 0
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_tier_0_shows_plain_name(self):
"""
Tier 0 in the evolution state means no progress yet no badge shown.
"""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(0))),
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_tier_1_badge_in_title(self):
"""Tier 1 card shows [T1] prefix in the embed title."""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(1))),
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "[T1] Mike Trout"
@pytest.mark.asyncio
async def test_tier_2_badge_in_title(self):
"""Tier 2 card shows [T2] prefix in the embed title."""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(2))),
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "[T2] Mike Trout"
@pytest.mark.asyncio
async def test_tier_3_badge_in_title(self):
"""Tier 3 card shows [T3] prefix in the embed title."""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(3))),
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "[T3] Mike Trout"
@pytest.mark.asyncio
async def test_tier_4_shows_evo_badge(self):
"""Fully evolved card (tier 4) shows [EVO] prefix instead of [T4]."""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(4))),
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "[EVO] Mike Trout"
@pytest.mark.asyncio
async def test_embed_color_unchanged_by_badge(self):
"""
The tier badge must not affect the embed color rarity color is the
only driver of embed color, even for evolved cards.
Why: embed color communicates card rarity to players. Silently breaking
it via evolution would confuse users.
"""
from helpers.main import get_card_embeds
rarity_color = "FFD700"
card = make_card(p_name="Mike Trout", rarity_color=rarity_color)
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect(make_evo_state(3))),
):
embeds = await get_card_embeds(card)
expected_color = int(rarity_color, 16)
assert embeds[0].colour.value == expected_color
@pytest.mark.asyncio
async def test_evolution_api_exception_shows_plain_name(self):
"""
When the evolution API raises an unexpected exception (network error,
server crash, etc.), the embed must still render with the plain player
name no badge, no crash.
This is the critical non-blocking contract for the feature.
"""
from helpers.main import get_card_embeds
async def exploding_side_effect(endpoint, **kwargs):
if "evolution" in endpoint:
raise RuntimeError("simulated network failure")
if "paperdex" in endpoint:
return EMPTY_PAPERDEX
return None
card = make_card(p_name="Mike Trout")
with patch(
"helpers.main.db_get", new=AsyncMock(side_effect=exploding_side_effect)
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "Mike Trout"
@pytest.mark.asyncio
async def test_evolution_api_missing_current_tier_key(self):
"""
If the evolution response is present but lacks 'current_tier', the
embed must gracefully degrade to no badge (defensive against API drift).
"""
from helpers.main import get_card_embeds
card = make_card(p_name="Mike Trout")
# Response exists but is missing the expected key
with patch(
"helpers.main.db_get",
new=AsyncMock(side_effect=_db_get_side_effect({"xp": 50})),
):
embeds = await get_card_embeds(card)
assert embeds[0].title == "Mike Trout"

View File

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

View File

@ -4,16 +4,18 @@ Tests for the WP-13 post-game callback integration hook.
These tests verify that after a game is saved to the API, two additional These tests verify that after a game is saved to the API, two additional
POST requests are fired in the correct order: POST requests are fired in the correct order:
1. POST season-stats/update-game/{game_id} update player_season_stats 1. POST season-stats/update-game/{game_id} update player_season_stats
2. POST refractor/evaluate-game/{game_id} evaluate refractor milestones 2. POST evolution/evaluate-game/{game_id} evaluate evolution milestones
Key design constraints being tested: Key design constraints being tested:
- Season stats MUST be updated before refractor is evaluated (ordering). - Season stats MUST be updated before evolution is evaluated (ordering).
- Failure of either refractor call must NOT propagate the game result has - Failure of either evolution call must NOT propagate the game result has
already been committed; refractor will self-heal on the next evaluate pass. already been committed; evolution will self-heal on the next evaluate pass.
- Tier-up dicts returned by the refractor endpoint are passed to - Tier-up dicts returned by the evolution endpoint are passed to
notify_tier_completion so WP-14 can present them to the player. notify_tier_completion so WP-14 can present them to the player.
""" """
import asyncio
import logging
import pytest import pytest
from unittest.mock import AsyncMock, MagicMock, call, patch from unittest.mock import AsyncMock, MagicMock, call, patch
@ -44,7 +46,7 @@ async def _run_hook(db_post_mock, db_game_id: int = 42):
try: try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}") 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']}") evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"): if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]: for tier_up in evo_result["tier_ups"]:
await notify_tier_completion(channel, tier_up) await notify_tier_completion(channel, tier_up)
@ -62,10 +64,10 @@ async def _run_hook(db_post_mock, db_game_id: int = 42):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hook_posts_to_both_endpoints_in_order(): async def test_hook_posts_to_both_endpoints_in_order():
""" """
Both refractor endpoints are called, and season-stats comes first. Both evolution endpoints are called, and season-stats comes first.
The ordering is critical: player_season_stats must be populated before the The ordering is critical: player_season_stats must be populated before the
refractor engine tries to read them for milestone evaluation. evolution engine tries to read them for milestone evaluation.
""" """
db_post_mock = AsyncMock(return_value={}) db_post_mock = AsyncMock(return_value={})
@ -75,8 +77,8 @@ async def test_hook_posts_to_both_endpoints_in_order():
calls = db_post_mock.call_args_list calls = db_post_mock.call_args_list
# First call must be season-stats # First call must be season-stats
assert calls[0] == call("season-stats/update-game/42") assert calls[0] == call("season-stats/update-game/42")
# Second call must be refractor evaluate # Second call must be evolution evaluate
assert calls[1] == call("refractor/evaluate-game/42") assert calls[1] == call("evolution/evaluate-game/42")
@pytest.mark.asyncio @pytest.mark.asyncio
@ -84,11 +86,11 @@ async def test_hook_is_nonfatal_when_db_post_raises():
""" """
A failure inside the hook must not raise to the caller. A failure inside the hook must not raise to the caller.
The game result is already persisted when the hook runs. If the refractor The game result is already persisted when the hook runs. If the evolution
API is down or returns an error, we log a warning and continue the game API is down or returns an error, we log a warning and continue the game
completion flow must not be interrupted. completion flow must not be interrupted.
""" """
db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable")) db_post_mock = AsyncMock(side_effect=Exception("evolution API unavailable"))
# Should not raise # Should not raise
try: try:
@ -100,7 +102,7 @@ async def test_hook_is_nonfatal_when_db_post_raises():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hook_processes_tier_ups_from_evo_result(): async def test_hook_processes_tier_ups_from_evo_result():
""" """
When the refractor endpoint returns tier_ups, each entry is forwarded to When the evolution endpoint returns tier_ups, each entry is forwarded to
notify_tier_completion. notify_tier_completion.
This confirms the data path between the API response and the WP-14 This confirms the data path between the API response and the WP-14
@ -112,7 +114,7 @@ async def test_hook_processes_tier_ups_from_evo_result():
] ]
async def fake_db_post(endpoint): async def fake_db_post(endpoint):
if "refractor" in endpoint: if "evolution" in endpoint:
return {"tier_ups": tier_ups} return {"tier_ups": tier_ups}
return {} return {}
@ -127,7 +129,7 @@ async def test_hook_processes_tier_ups_from_evo_result():
try: try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}") 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']}") evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"): if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]: for tier_up in evo_result["tier_ups"]:
await mock_notify(channel, tier_up) await mock_notify(channel, tier_up)
@ -144,14 +146,14 @@ async def test_hook_processes_tier_ups_from_evo_result():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_hook_no_tier_ups_does_not_call_notify(): async def test_hook_no_tier_ups_does_not_call_notify():
""" """
When the refractor response has no tier_ups (empty list or missing key), When the evolution response has no tier_ups (empty list or missing key),
notify_tier_completion is never called. notify_tier_completion is never called.
Avoids spurious Discord messages for routine game completions. Avoids spurious Discord messages for routine game completions.
""" """
async def fake_db_post(endpoint): async def fake_db_post(endpoint):
if "refractor" in endpoint: if "evolution" in endpoint:
return {"tier_ups": []} return {"tier_ups": []}
return {} return {}
@ -166,7 +168,7 @@ async def test_hook_no_tier_ups_does_not_call_notify():
try: try:
await db_post_mock(f"season-stats/update-game/{db_game['id']}") 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']}") evo_result = await db_post_mock(f"evolution/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"): if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]: for tier_up in evo_result["tier_ups"]:
await mock_notify(channel, tier_up) await mock_notify(channel, tier_up)
@ -177,29 +179,23 @@ async def test_hook_no_tier_ups_does_not_call_notify():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_notify_tier_completion_sends_embed_and_does_not_raise(): async def test_notify_tier_completion_stub_logs_and_does_not_raise(caplog):
""" """
notify_tier_completion sends a Discord embed and does not raise. The WP-14 stub must log the event and return cleanly.
Now that WP-14 is wired, the function imported via logic_gameplay is the Verifies the contract that WP-14 can rely on: the function accepts
real embed-sending implementation from helpers.refractor_notifs. (channel, tier_up) and does not raise, so the hook's for-loop is safe.
""" """
from command_logic.logic_gameplay import notify_tier_completion from command_logic.logic_gameplay import notify_tier_completion
channel = AsyncMock() channel = _make_channel(channel_id=123)
# Full API response shape — the evaluate-game endpoint returns all these keys tier_up = {"player_id": 77, "old_tier": 0, "new_tier": 1}
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) with caplog.at_level(logging.INFO):
await notify_tier_completion(channel, tier_up)
channel.send.assert_called_once() # At minimum one log message should reference the channel or tier_up data
embed = channel.send.call_args.kwargs["embed"] assert any(
assert "Mike Trout" in embed.description "notify_tier_completion" in rec.message or "77" in rec.message
for rec in caplog.records
)

View File

@ -0,0 +1,173 @@
"""Tests for the evolution status command helpers (WP-11).
Unit tests for progress bar rendering, entry formatting, tier display
names, close-to-tierup filtering, and edge cases. No Discord bot or
API calls required these test pure functions only.
"""
import pytest
from cogs.players_new.evolution import (
render_progress_bar,
format_evo_entry,
is_close_to_tierup,
TIER_NAMES,
FORMULA_SHORTHANDS,
)
# ---------------------------------------------------------------------------
# render_progress_bar
# ---------------------------------------------------------------------------
class TestRenderProgressBar:
def test_80_percent_filled(self):
"""120/149 should be ~80% filled (8 of 10 chars)."""
result = render_progress_bar(120, 149, width=10)
assert "[========--]" in result
assert "120/149" in result
def test_zero_progress(self):
"""0/37 should be empty bar."""
result = render_progress_bar(0, 37, width=10)
assert "[----------]" in result
assert "0/37" in result
def test_full_progress_not_evolved(self):
"""Value at threshold shows full bar."""
result = render_progress_bar(149, 149, width=10)
assert "[==========]" in result
assert "149/149" in result
def test_fully_evolved(self):
"""next_threshold=None means fully evolved."""
result = render_progress_bar(900, None, width=10)
assert "FULLY EVOLVED" in result
assert "[==========]" in result
def test_over_threshold_capped(self):
"""Value exceeding threshold still caps at 100%."""
result = render_progress_bar(200, 149, width=10)
assert "[==========]" in result
# ---------------------------------------------------------------------------
# format_evo_entry
# ---------------------------------------------------------------------------
class TestFormatEvoEntry:
def test_batter_t1_to_t2(self):
"""Batter at T1 progressing toward T2."""
state = {
"current_tier": 1,
"current_value": 120.0,
"next_threshold": 149,
"fully_evolved": False,
"track": {"card_type": "batter"},
}
result = format_evo_entry(state)
assert "(PA+TB×2)" in result
assert "Initiate → Rising" in result
def test_pitcher_sp(self):
"""SP track shows IP+K formula."""
state = {
"current_tier": 0,
"current_value": 5.0,
"next_threshold": 10,
"fully_evolved": False,
"track": {"card_type": "sp"},
}
result = format_evo_entry(state)
assert "(IP+K)" in result
assert "Unranked → Initiate" in result
def test_fully_evolved_entry(self):
"""Fully evolved card shows T4 — Evolved."""
state = {
"current_tier": 4,
"current_value": 900.0,
"next_threshold": None,
"fully_evolved": True,
"track": {"card_type": "batter"},
}
result = format_evo_entry(state)
assert "FULLY EVOLVED" in result
assert "Evolved" in result
# ---------------------------------------------------------------------------
# is_close_to_tierup
# ---------------------------------------------------------------------------
class TestIsCloseToTierup:
def test_at_80_percent(self):
"""Exactly 80% of threshold counts as close."""
state = {"current_value": 119.2, "next_threshold": 149}
assert is_close_to_tierup(state, threshold_pct=0.80)
def test_below_80_percent(self):
"""Below 80% is not close."""
state = {"current_value": 100, "next_threshold": 149}
assert not is_close_to_tierup(state, threshold_pct=0.80)
def test_fully_evolved_not_close(self):
"""Fully evolved (no next threshold) is not close."""
state = {"current_value": 900, "next_threshold": None}
assert not is_close_to_tierup(state)
def test_zero_threshold(self):
"""Zero threshold edge case returns False."""
state = {"current_value": 0, "next_threshold": 0}
assert not is_close_to_tierup(state)
# ---------------------------------------------------------------------------
# Tier names and formula shorthands
# ---------------------------------------------------------------------------
class TestConstants:
def test_all_tier_names_present(self):
"""All 5 tiers (0-4) have display names."""
assert len(TIER_NAMES) == 5
for i in range(5):
assert i in TIER_NAMES
def test_tier_name_values(self):
assert TIER_NAMES[0] == "Unranked"
assert TIER_NAMES[1] == "Initiate"
assert TIER_NAMES[2] == "Rising"
assert TIER_NAMES[3] == "Ascendant"
assert TIER_NAMES[4] == "Evolved"
def test_formula_shorthands(self):
assert FORMULA_SHORTHANDS["batter"] == "PA+TB×2"
assert FORMULA_SHORTHANDS["sp"] == "IP+K"
assert FORMULA_SHORTHANDS["rp"] == "IP+K"
# ---------------------------------------------------------------------------
# Empty / edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_missing_track_defaults(self):
"""State with missing track info still formats without error."""
state = {
"current_tier": 0,
"current_value": 0,
"next_threshold": 37,
"fully_evolved": False,
"track": {},
}
result = format_evo_entry(state)
assert isinstance(result, str)
def test_state_with_no_keys(self):
"""Completely empty state dict doesn't crash."""
state = {}
result = format_evo_entry(state)
assert isinstance(result, str)

View File

@ -1,9 +1,9 @@
""" """
Tests for Refractor Tier Completion Notification embeds. Tests for Evolution Tier Completion Notification embeds.
These tests verify that: These tests verify that:
1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color). 1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color).
2. Tier 4 (Superfractor) embeds include the special title, description, and color. 2. Tier 4 (Fully Evolved) embeds include the special title, description, and note field.
3. Multiple tier-up events each produce a separate embed. 3. Multiple tier-up events each produce a separate embed.
4. An empty tier-up list results in no channel sends. 4. An empty tier-up list results in no channel sends.
@ -17,7 +17,7 @@ from unittest.mock import AsyncMock
import discord import discord
from helpers.refractor_notifs import build_tier_up_embed, notify_tier_completion from helpers.evolution_notifs import build_tier_up_embed, notify_tier_completion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
@ -49,11 +49,11 @@ def make_tier_up(
class TestBuildTierUpEmbed: class TestBuildTierUpEmbed:
"""Verify that build_tier_up_embed produces correctly structured embeds.""" """Verify that build_tier_up_embed produces correctly structured embeds."""
def test_title_is_refractor_tier_up(self): def test_title_is_evolution_tier_up(self):
"""Title must read 'Refractor Tier Up!' for any non-max tier.""" """Title must read 'Evolution Tier Up!' for any non-max tier."""
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.title == "Refractor Tier Up!" assert embed.title == "Evolution Tier Up!"
def test_description_contains_player_name(self): def test_description_contains_player_name(self):
"""Description must contain the player's name.""" """Description must contain the player's name."""
@ -65,11 +65,11 @@ class TestBuildTierUpEmbed:
"""Description must include the human-readable tier name for the new tier.""" """Description must include the human-readable tier name for the new tier."""
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
# Tier 2 display name is "Refractor" # Tier 2 display name is "Rising"
assert "Refractor" in embed.description assert "Rising" in embed.description
def test_description_contains_track_name(self): def test_description_contains_track_name(self):
"""Description must mention the refractor track (e.g., 'Batter').""" """Description must mention the evolution track (e.g., 'Batter')."""
tier_up = make_tier_up(track_name="Batter", new_tier=2) tier_up = make_tier_up(track_name="Batter", new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert "Batter" in embed.description assert "Batter" in embed.description
@ -92,11 +92,11 @@ class TestBuildTierUpEmbed:
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x9B59B6 assert embed.color.value == 0x9B59B6
def test_footer_text_is_paper_dynasty_refractor(self): def test_footer_text_is_paper_dynasty_evolution(self):
"""Footer text must be 'Paper Dynasty Refractor' for brand consistency.""" """Footer text must be 'Paper Dynasty Evolution' for brand consistency."""
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Refractor" assert embed.footer.text == "Paper Dynasty Evolution"
def test_returns_discord_embed_instance(self): def test_returns_discord_embed_instance(self):
"""Return type must be discord.Embed so it can be sent directly.""" """Return type must be discord.Embed so it can be sent directly."""
@ -106,24 +106,24 @@ class TestBuildTierUpEmbed:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tier 4 (superfractor) # Unit: build_tier_up_embed — tier 4 (fully evolved)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildTierUpEmbedSuperfractor: class TestBuildTierUpEmbedFullyEvolved:
"""Verify that tier 4 (Superfractor) embeds use special formatting.""" """Verify that tier 4 (Fully Evolved) embeds use special formatting."""
def test_title_is_superfractor(self): def test_title_is_fully_evolved(self):
"""Tier 4 title must be 'SUPERFRACTOR!' to emphasise max achievement.""" """Tier 4 title must be 'FULLY EVOLVED!' to emphasise max achievement."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.title == "SUPERFRACTOR!" assert embed.title == "FULLY EVOLVED!"
def test_description_mentions_maximum_refractor_tier(self): def test_description_mentions_maximum_evolution(self):
"""Tier 4 description must mention 'maximum refractor tier' per the spec.""" """Tier 4 description must mention 'maximum evolution' per the spec."""
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4) tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert "maximum refractor tier" in embed.description.lower() assert "maximum evolution" in embed.description.lower()
def test_description_contains_player_name(self): def test_description_contains_player_name(self):
"""Player name must appear in the tier 4 description.""" """Player name must appear in the tier 4 description."""
@ -138,22 +138,47 @@ class TestBuildTierUpEmbedSuperfractor:
assert "Batter" in embed.description assert "Batter" in embed.description
def test_tier4_color_is_teal(self): def test_tier4_color_is_teal(self):
"""Tier 4 uses teal (0x1abc9c) to visually distinguish superfractor.""" """Tier 4 uses teal (0x1abc9c) to visually distinguish max evolution."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x1ABC9C assert embed.color.value == 0x1ABC9C
def test_no_extra_fields(self): def test_note_field_present(self):
"""Tier 4 embed should have no extra fields — boosts are live, no teaser needed.""" """Tier 4 must include a note field about future rating boosts."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert len(embed.fields) == 0 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_footer_text_is_paper_dynasty_refractor(self): def test_note_field_value_mentions_future_update(self):
"""Footer must remain 'Paper Dynasty Refractor' for tier 4 as well.""" """The note field value must reference the future rating boost update."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Refractor" 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_evolution(self):
"""Footer must remain 'Paper Dynasty Evolution' 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 Evolution"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -179,9 +204,9 @@ class TestNotifyTierCompletion:
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up) await notify_tier_completion(channel, tier_up)
_, kwargs = channel.send.call_args _, kwargs = channel.send.call_args
assert "embed" in kwargs, ( assert (
"notify_tier_completion must send an embed, not plain text" "embed" in kwargs
) ), "notify_tier_completion must send an embed, not plain text"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_embed_type_is_discord_embed(self): async def test_embed_type_is_discord_embed(self):
@ -216,9 +241,9 @@ class TestNotifyTierCompletion:
] ]
for event in events: for event in events:
await notify_tier_completion(channel, event) await notify_tier_completion(channel, event)
assert channel.send.call_count == 3, ( assert (
"Each tier-up event must produce its own embed (no batching)" channel.send.call_count == 3
) ), "Each tier-up event must produce its own embed (no batching)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_tier_ups_means_no_sends(self): async def test_no_tier_ups_means_no_sends(self):
@ -232,72 +257,3 @@ class TestNotifyTierCompletion:
for event in tier_up_events: for event in tier_up_events:
await notify_tier_completion(channel, event) await notify_tier_completion(channel, event)
channel.send.assert_not_called() 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)

View File

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

View File

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

View File

@ -1,740 +0,0 @@
"""
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()