Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b65d91a65b | |||
| 4bda3bf0de | |||
| ff768c95f5 | |||
|
|
fb545ef34a | ||
|
|
f704b09933 | ||
|
|
94f3b1dc97 | ||
| fca85d583f | |||
| b6592b8a70 | |||
|
|
01f6fb50d5 | ||
| f843b45099 | |||
|
|
bbad1daba2 | ||
| 2d7c19814e | |||
|
|
c3ff85fd2d | ||
| 64c656ce91 | |||
|
|
cd822857bf | ||
| 34774290b8 | |||
|
|
6239f1177c | ||
| dea6316201 | |||
|
|
b9deb14b62 | ||
| 48392a9bbe | |||
|
|
a53cc5cac3 | ||
| a8b4d6cdbb | |||
|
|
8d2cdc81fe | ||
| 27ce8b3617 | |||
|
|
17d124feb4 | ||
|
|
1c21f674c2 | ||
|
|
a9ef04d102 | ||
| c285e9e12d | |||
|
|
7a3c21f6bd | ||
| b185874607 | |||
|
|
7191400b5b | ||
| fd89349f29 | |||
|
|
3b36dc33ee | ||
| cf1d9c372d | |||
|
|
190aa88d43 | ||
| 24a59e9670 | |||
|
|
571a86fe7e | ||
| 8c0c2eb21a | |||
| 55efdb39de | |||
|
|
fddac59f7e | ||
|
|
45894c72ee | ||
| 8940965ff8 | |||
|
|
6e45686457 | ||
| f85ead1f60 | |||
|
|
9bbd5305ef | ||
| 187ae854ca | |||
| dc128ad995 | |||
| aa2fce94b8 | |||
| 80344fe473 | |||
|
|
29f2a8683f | ||
|
|
9940b160db | ||
| d2410ab374 | |||
| c85359ca5d | |||
|
|
45d71c61e3 | ||
| 7a50ab0bce | |||
| 3ce5aebc57 | |||
|
|
3a85564a6d | ||
|
|
911c6842e4 | ||
|
|
2c57fbcdf5 | ||
|
|
b04219d208 | ||
|
|
687b91a009 | ||
| f4a57879ab | |||
|
|
f09470b1f1 | ||
|
|
fcd2e33916 | ||
|
|
1f26020bd7 | ||
|
|
cc02d6db1e | ||
|
|
5670cd6e88 | ||
|
|
fc8508fbd5 | ||
|
|
6b4957ec70 | ||
| e2ddaf75b7 | |||
|
|
bf7a8f8394 | ||
| 9167bc2f1c | |||
|
|
1c03d91478 | ||
|
|
57a64127ba | ||
| 9d1a46b84d | |||
| 1d7ffb61cd | |||
|
|
55a3255b35 | ||
|
|
08a639ec54 | ||
| b93e51bbf7 | |||
| bae3d72d6b | |||
|
|
c0af0c3d32 | ||
|
|
075e0ef433 | ||
| 6b375e62af | |||
| 8740c65773 | |||
|
|
740ea93b34 | ||
| 841216c679 | |||
| d98f8ea8ab | |||
| fc7dced253 | |||
| 4f62f7b96d | |||
| c36c80d7f6 | |||
| 6e156f971e | |||
| 376e0b8a31 | |||
| 791b991538 | |||
| 3de3ec4707 | |||
| fc9cfae7d9 | |||
| 829e03e3de | |||
| 8da9157f3c | |||
|
|
7e406f1a06 | ||
|
|
208efd11a6 | ||
|
|
0304753e92 | ||
|
|
d12cdb8d97 | ||
|
|
678fa320df | ||
|
|
8b2a442385 | ||
|
|
247d0cf6bf | ||
| ce894cfa64 | |||
|
|
9d279cd038 | ||
| eb17b17dd4 | |||
|
|
db15993b02 | ||
|
|
a509a4ebf5 | ||
|
|
33260fd5fa |
54
.env.example
Normal file
54
.env.example
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Paper Dynasty Discord Bot - Environment Configuration
|
||||||
|
# Copy this file to .env and fill in your actual values.
|
||||||
|
# DO NOT commit .env to version control!
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BOT CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Discord bot token (from Discord Developer Portal)
|
||||||
|
BOT_TOKEN=your-discord-bot-token-here
|
||||||
|
|
||||||
|
# Discord server (guild) ID
|
||||||
|
GUILD_ID=your-guild-id-here
|
||||||
|
|
||||||
|
# Channel ID for scoreboard messages
|
||||||
|
SCOREBOARD_CHANNEL=your-scoreboard-channel-id-here
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Paper Dynasty API authentication token
|
||||||
|
API_TOKEN=your-api-token-here
|
||||||
|
|
||||||
|
# Target database environment: 'dev' or 'prod'
|
||||||
|
# Default: dev
|
||||||
|
DATABASE=dev
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# APPLICATION CONFIGURATION
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Logging level: DEBUG, INFO, WARNING, ERROR
|
||||||
|
# Default: INFO
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Python hash seed (set for reproducibility in tests)
|
||||||
|
PYTHONHASHSEED=0
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE CONFIGURATION (PostgreSQL — used by gameplay_models.py)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
DB_USERNAME=your_db_username
|
||||||
|
DB_PASSWORD=your_db_password
|
||||||
|
DB_URL=localhost
|
||||||
|
DB_NAME=postgres
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# WEBHOOKS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Discord webhook URL for restart notifications
|
||||||
|
RESTART_WEBHOOK_URL=https://discord.com/api/webhooks/your-webhook-id/your-webhook-token
|
||||||
@ -1,31 +1,48 @@
|
|||||||
# 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:
|
||||||
# - Builds Docker images on every push/PR
|
# - Triggered by pushing a CalVer tag (e.g., 2026.3.11) or "dev" tag
|
||||||
# - Auto-generates CalVer version (YYYY.MM.BUILD) on main branch merges
|
# - CalVer tags push with version + "production" Docker tags
|
||||||
# - Pushes to Docker Hub and creates git tag on main
|
# - "dev" tag pushes with "dev" Docker tag for the dev environment
|
||||||
# - 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:
|
||||||
branches:
|
tags:
|
||||||
- main
|
- '20*' # matches CalVer tags like 2026.3.11
|
||||||
- next-release
|
- 'dev' # dev environment builds
|
||||||
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 # Full history for tag counting
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
SHA_SHORT=$(git rev-parse --short HEAD)
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "sha_short=$SHA_SHORT" >> $GITHUB_OUTPUT
|
||||||
|
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
|
||||||
|
if [ "$VERSION" = "dev" ]; then
|
||||||
|
echo "environment=dev" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "environment=production" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: https://github.com/docker/setup-buildx-action@v3
|
uses: https://github.com/docker/setup-buildx-action@v3
|
||||||
@ -36,67 +53,52 @@ 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: ${{ steps.tags.outputs.tags }}
|
tags: |
|
||||||
cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache
|
manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.version }}
|
||||||
cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max
|
manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.environment }}
|
||||||
|
cache-from: type=local,src=/opt/buildx-cache/pd-discord
|
||||||
|
cache-to: type=local,dest=/opt/buildx-cache/pd-discord-new,mode=max
|
||||||
|
|
||||||
- name: Tag release
|
- name: Rotate cache
|
||||||
if: success() && steps.tags.outputs.channel == 'stable'
|
run: |
|
||||||
uses: cal/gitea-actions/gitea-tag@main
|
rm -rf /opt/buildx-cache/pd-discord
|
||||||
with:
|
mv /opt/buildx-cache/pd-discord-new /opt/buildx-cache/pd-discord
|
||||||
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 "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "**Version:** \`${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||||
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
|
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
for tag in "${TAG_ARRAY[@]}"; do
|
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.environment }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
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 "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "- Commit: \`${{ steps.version.outputs.sha_short }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "- Timestamp: \`${{ steps.version.outputs.timestamp }}\`" >> $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.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
- name: Discord Notification - Success
|
- name: Discord Notification - Success
|
||||||
if: success() && steps.tags.outputs.channel != 'dev'
|
if: success()
|
||||||
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.calver.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
image_tag: ${{ steps.calver.outputs.version_sha }}
|
image_tag: ${{ steps.version.outputs.version }}
|
||||||
commit_sha: ${{ steps.calver.outputs.sha_short }}
|
commit_sha: ${{ steps.version.outputs.sha_short }}
|
||||||
timestamp: ${{ steps.calver.outputs.timestamp }}
|
timestamp: ${{ steps.version.outputs.timestamp }}
|
||||||
|
|
||||||
- name: Discord Notification - Failure
|
- name: Discord Notification - Failure
|
||||||
if: failure() && steps.tags.outputs.channel != 'dev'
|
if: failure()
|
||||||
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 }}
|
||||||
|
|||||||
31
.gitea/workflows/ruff-lint.yml
Normal file
31
.gitea/workflows/ruff-lint.yml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Gitea Actions: Ruff Lint Check
|
||||||
|
#
|
||||||
|
# Runs ruff on every PR to main to catch violations before merge.
|
||||||
|
# Complements the local pre-commit hook — violations blocked here even if
|
||||||
|
# the developer bypassed the hook with --no-verify.
|
||||||
|
|
||||||
|
name: Ruff Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: https://github.com/actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install ruff
|
||||||
|
run: pip install ruff
|
||||||
|
|
||||||
|
- name: Run ruff check
|
||||||
|
run: ruff check .
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -133,6 +133,7 @@ dmypy.json
|
|||||||
storage*
|
storage*
|
||||||
storage/paper-dynasty-service-creds.json
|
storage/paper-dynasty-service-creds.json
|
||||||
*compose.yml
|
*compose.yml
|
||||||
|
!docker-compose.example.yml
|
||||||
**.db
|
**.db
|
||||||
**/htmlcov
|
**/htmlcov
|
||||||
.vscode/**
|
.vscode/**
|
||||||
|
|||||||
@ -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.MM.BUILD`) — auto-generated on merge to `main`
|
- **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release
|
||||||
|
|
||||||
### 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,8 +49,9 @@ 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
|
||||||
Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version on merge.
|
Ruff lint on PRs. Docker image built on CalVer tag push only.
|
||||||
```bash
|
```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"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12.13-slim
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2017
cogs/economy.py
2017
cogs/economy.py
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
# Economy Packs Module
|
# Economy Packs Module
|
||||||
# Contains pack opening, daily rewards, and donation commands from the original economy.py
|
# Contains pack opening, daily rewards, and donation commands from the original economy.py
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@ -9,97 +9,135 @@ import datetime
|
|||||||
|
|
||||||
# Import specific utilities needed by this module
|
# Import specific utilities needed by this module
|
||||||
import random
|
import random
|
||||||
from api_calls import db_get, db_post, db_patch
|
from api_calls import db_get, db_post
|
||||||
from helpers.constants import PD_PLAYERS_ROLE_NAME, PD_PLAYERS, IMAGES
|
from helpers.constants import PD_PLAYERS_ROLE_NAME, PD_PLAYERS, IMAGES
|
||||||
from helpers import (
|
from helpers import (
|
||||||
get_team_by_owner, display_cards, give_packs, legal_channel, get_channel,
|
get_team_by_owner,
|
||||||
get_cal_user, refresh_sheet, roll_for_cards, int_timestamp, get_context_user
|
display_cards,
|
||||||
|
give_packs,
|
||||||
|
legal_channel,
|
||||||
|
get_channel,
|
||||||
|
get_cal_user,
|
||||||
|
refresh_sheet,
|
||||||
|
roll_for_cards,
|
||||||
|
int_timestamp,
|
||||||
|
get_context_user,
|
||||||
)
|
)
|
||||||
from helpers.discord_utils import get_team_embed, send_to_channel, get_emoji
|
from helpers.discord_utils import get_team_embed, get_emoji
|
||||||
from discord_ui import SelectView, SelectOpenPack
|
from discord_ui import SelectView, SelectOpenPack
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger('discord_app')
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
|
|
||||||
class Packs(commands.Cog):
|
class Packs(commands.Cog):
|
||||||
"""Pack management, daily rewards, and donation system for Paper Dynasty."""
|
"""Pack management, daily rewards, and donation system for Paper Dynasty."""
|
||||||
|
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@commands.hybrid_group(name='donation', help='Mod: Give packs for PD donations')
|
@commands.hybrid_group(name="donation", help="Mod: Give packs for PD donations")
|
||||||
@commands.has_any_role(PD_PLAYERS_ROLE_NAME)
|
@commands.has_any_role(PD_PLAYERS_ROLE_NAME)
|
||||||
async def donation(self, ctx: commands.Context):
|
async def donation(self, ctx: commands.Context):
|
||||||
if ctx.invoked_subcommand is None:
|
if ctx.invoked_subcommand is None:
|
||||||
await ctx.send('To buy packs, visit https://ko-fi.com/manticorum/shop and include your discord username!')
|
await ctx.send(
|
||||||
|
"To buy packs, visit https://ko-fi.com/manticorum/shop and include your discord username!"
|
||||||
|
)
|
||||||
|
|
||||||
@donation.command(name='premium', help='Mod: Give premium packs', aliases=['p', 'prem'])
|
@donation.command(
|
||||||
|
name="premium", help="Mod: Give premium packs", aliases=["p", "prem"]
|
||||||
|
)
|
||||||
async def donation_premium(self, ctx: commands.Context, num_packs: int, gm: Member):
|
async def donation_premium(self, ctx: commands.Context, num_packs: int, gm: Member):
|
||||||
if ctx.author.id != self.bot.owner_id:
|
if ctx.author.id != self.bot.owner_id:
|
||||||
await ctx.send('Wait a second. You\'re not in charge here!')
|
await ctx.send("Wait a second. You're not in charge here!")
|
||||||
return
|
return
|
||||||
|
|
||||||
team = await get_team_by_owner(gm.id)
|
team = await get_team_by_owner(gm.id)
|
||||||
p_query = await db_get('packtypes', params=[('name', 'Premium')])
|
p_query = await db_get("packtypes", params=[("name", "Premium")])
|
||||||
if p_query['count'] == 0:
|
if p_query["count"] == 0:
|
||||||
await ctx.send('Oof. I couldn\'t find a Premium Pack')
|
await ctx.send("Oof. I couldn't find a Premium Pack")
|
||||||
return
|
return
|
||||||
|
|
||||||
total_packs = await give_packs(team, num_packs, pack_type=p_query['packtypes'][0])
|
total_packs = await give_packs(
|
||||||
await ctx.send(f'The {team["lname"]} now have {total_packs["count"]} total packs!')
|
team, num_packs, pack_type=p_query["packtypes"][0]
|
||||||
|
)
|
||||||
|
await ctx.send(
|
||||||
|
f"The {team['lname']} now have {total_packs['count']} total packs!"
|
||||||
|
)
|
||||||
|
|
||||||
@donation.command(name='standard', help='Mod: Give standard packs', aliases=['s', 'sta'])
|
@donation.command(
|
||||||
async def donation_standard(self, ctx: commands.Context, num_packs: int, gm: Member):
|
name="standard", help="Mod: Give standard packs", aliases=["s", "sta"]
|
||||||
|
)
|
||||||
|
async def donation_standard(
|
||||||
|
self, ctx: commands.Context, num_packs: int, gm: Member
|
||||||
|
):
|
||||||
if ctx.author.id != self.bot.owner_id:
|
if ctx.author.id != self.bot.owner_id:
|
||||||
await ctx.send('Wait a second. You\'re not in charge here!')
|
await ctx.send("Wait a second. You're not in charge here!")
|
||||||
return
|
return
|
||||||
|
|
||||||
team = await get_team_by_owner(gm.id)
|
team = await get_team_by_owner(gm.id)
|
||||||
p_query = await db_get('packtypes', params=[('name', 'Standard')])
|
p_query = await db_get("packtypes", params=[("name", "Standard")])
|
||||||
if p_query['count'] == 0:
|
if p_query["count"] == 0:
|
||||||
await ctx.send('Oof. I couldn\'t find a Standard Pack')
|
await ctx.send("Oof. I couldn't find a Standard Pack")
|
||||||
return
|
return
|
||||||
|
|
||||||
total_packs = await give_packs(team, num_packs, pack_type=p_query['packtypes'][0])
|
total_packs = await give_packs(
|
||||||
await ctx.send(f'The {team["lname"]} now have {total_packs["count"]} total packs!')
|
team, num_packs, pack_type=p_query["packtypes"][0]
|
||||||
|
)
|
||||||
|
await ctx.send(
|
||||||
|
f"The {team['lname']} now have {total_packs['count']} total packs!"
|
||||||
|
)
|
||||||
|
|
||||||
@commands.hybrid_command(name='lastpack', help='Replay your last pack')
|
@commands.hybrid_command(name="lastpack", help="Replay your last pack")
|
||||||
@commands.check(legal_channel)
|
@commands.check(legal_channel)
|
||||||
@commands.has_any_role(PD_PLAYERS_ROLE_NAME)
|
@commands.has_any_role(PD_PLAYERS_ROLE_NAME)
|
||||||
async def last_pack_command(self, ctx: commands.Context):
|
async def last_pack_command(self, ctx: commands.Context):
|
||||||
team = await get_team_by_owner(get_context_user(ctx).id)
|
team = await get_team_by_owner(get_context_user(ctx).id)
|
||||||
if not team:
|
if not team:
|
||||||
await ctx.send(f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!')
|
await ctx.send(
|
||||||
|
"I don't see a team for you, yet. You can sign up with the `/newteam` command!"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
p_query = await db_get(
|
p_query = await db_get(
|
||||||
'packs',
|
"packs",
|
||||||
params=[('opened', True), ('team_id', team['id']), ('new_to_old', True), ('limit', 1)]
|
params=[
|
||||||
|
("opened", True),
|
||||||
|
("team_id", team["id"]),
|
||||||
|
("new_to_old", True),
|
||||||
|
("limit", 1),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
if not p_query['count']:
|
if not p_query["count"]:
|
||||||
await ctx.send(f'I do not see any packs for you, bub.')
|
await ctx.send("I do not see any packs for you, bub.")
|
||||||
return
|
return
|
||||||
|
|
||||||
pack_name = p_query['packs'][0]['pack_type']['name']
|
pack_name = p_query["packs"][0]["pack_type"]["name"]
|
||||||
if pack_name == 'Standard':
|
if pack_name == "Standard":
|
||||||
pack_cover = IMAGES['pack-sta']
|
pack_cover = IMAGES["pack-sta"]
|
||||||
elif pack_name == 'Premium':
|
elif pack_name == "Premium":
|
||||||
pack_cover = IMAGES['pack-pre']
|
pack_cover = IMAGES["pack-pre"]
|
||||||
else:
|
else:
|
||||||
pack_cover = None
|
pack_cover = None
|
||||||
|
|
||||||
c_query = await db_get(
|
c_query = await db_get("cards", params=[("pack_id", p_query["packs"][0]["id"])])
|
||||||
'cards',
|
if not c_query["count"]:
|
||||||
params=[('pack_id', p_query['packs'][0]['id'])]
|
await ctx.send("Hmm...I didn't see any cards in that pack.")
|
||||||
)
|
|
||||||
if not c_query['count']:
|
|
||||||
await ctx.send(f'Hmm...I didn\'t see any cards in that pack.')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await display_cards(c_query['cards'], team, ctx.channel, ctx.author, self.bot, pack_cover=pack_cover)
|
await display_cards(
|
||||||
|
c_query["cards"],
|
||||||
|
team,
|
||||||
|
ctx.channel,
|
||||||
|
ctx.author,
|
||||||
|
self.bot,
|
||||||
|
pack_cover=pack_cover,
|
||||||
|
)
|
||||||
|
|
||||||
@app_commands.command(name='comeonmanineedthis', description='Daily check-in for cards, currency, and packs')
|
@app_commands.command(
|
||||||
|
name="comeonmanineedthis",
|
||||||
|
description="Daily check-in for cards, currency, and packs",
|
||||||
|
)
|
||||||
@commands.has_any_role(PD_PLAYERS)
|
@commands.has_any_role(PD_PLAYERS)
|
||||||
@commands.check(legal_channel)
|
@commands.check(legal_channel)
|
||||||
async def daily_checkin(self, interaction: discord.Interaction):
|
async def daily_checkin(self, interaction: discord.Interaction):
|
||||||
@ -107,97 +145,127 @@ class Packs(commands.Cog):
|
|||||||
team = await get_team_by_owner(interaction.user.id)
|
team = await get_team_by_owner(interaction.user.id)
|
||||||
if not team:
|
if not team:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!'
|
content="I don't see a team for you, yet. You can sign up with the `/newteam` command!"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
current = await db_get('current')
|
current = await db_get("current")
|
||||||
now = datetime.datetime.now()
|
now = datetime.datetime.now()
|
||||||
midnight = int_timestamp(datetime.datetime(now.year, now.month, now.day, 0, 0, 0))
|
midnight = int_timestamp(
|
||||||
daily = await db_get('rewards', params=[
|
datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
|
||||||
('name', 'Daily Check-in'), ('team_id', team['id']), ('created_after', midnight)
|
)
|
||||||
])
|
daily = await db_get(
|
||||||
logger.debug(f'midnight: {midnight} / now: {int_timestamp(now)}')
|
"rewards",
|
||||||
logger.debug(f'daily_return: {daily}')
|
params=[
|
||||||
|
("name", "Daily Check-in"),
|
||||||
|
("team_id", team["id"]),
|
||||||
|
("created_after", midnight),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger.debug(f"midnight: {midnight} / now: {int_timestamp(now)}")
|
||||||
|
logger.debug(f"daily_return: {daily}")
|
||||||
|
|
||||||
if daily:
|
if daily:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'Looks like you already checked in today - come back at midnight Central!'
|
content="Looks like you already checked in today - come back at midnight Central!"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await db_post('rewards', payload={
|
await db_post(
|
||||||
'name': 'Daily Check-in', 'team_id': team['id'], 'season': current['season'], 'week': current['week'],
|
"rewards",
|
||||||
'created': int_timestamp(now)
|
payload={
|
||||||
})
|
"name": "Daily Check-in",
|
||||||
current = await db_get('current')
|
"team_id": team["id"],
|
||||||
check_ins = await db_get('rewards', params=[
|
"season": current["season"],
|
||||||
('name', 'Daily Check-in'), ('team_id', team['id']), ('season', current['season'])
|
"week": current["week"],
|
||||||
])
|
"created": int_timestamp(now),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
current = await db_get("current")
|
||||||
|
check_ins = await db_get(
|
||||||
|
"rewards",
|
||||||
|
params=[
|
||||||
|
("name", "Daily Check-in"),
|
||||||
|
("team_id", team["id"]),
|
||||||
|
("season", current["season"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
check_count = check_ins['count'] % 5
|
check_count = check_ins["count"] % 5
|
||||||
|
|
||||||
# 2nd, 4th, and 5th check-ins
|
# 2nd, 4th, and 5th check-ins
|
||||||
if check_count == 0 or check_count % 2 == 0:
|
if check_count == 0 or check_count % 2 == 0:
|
||||||
# Every fifth check-in
|
# Every fifth check-in
|
||||||
if check_count == 0:
|
if check_count == 0:
|
||||||
greeting = await interaction.edit_original_response(
|
greeting = await interaction.edit_original_response(
|
||||||
content=f'Hey, you just earned a Standard pack of cards!'
|
content="Hey, you just earned a Standard pack of cards!"
|
||||||
)
|
)
|
||||||
pack_channel = get_channel(interaction, 'pack-openings')
|
pack_channel = get_channel(interaction, "pack-openings")
|
||||||
|
|
||||||
p_query = await db_get('packtypes', params=[('name', 'Standard')])
|
p_query = await db_get("packtypes", params=[("name", "Standard")])
|
||||||
if not p_query:
|
if not p_query:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'I was not able to pull this pack for you. '
|
content=f"I was not able to pull this pack for you. "
|
||||||
f'Maybe ping {get_cal_user(interaction).mention}?'
|
f"Maybe ping {get_cal_user(interaction).mention}?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Every second and fourth check-in
|
# Every second and fourth check-in
|
||||||
else:
|
else:
|
||||||
greeting = await interaction.edit_original_response(
|
greeting = await interaction.edit_original_response(
|
||||||
content=f'Hey, you just earned a player card!'
|
content="Hey, you just earned a player card!"
|
||||||
)
|
)
|
||||||
pack_channel = interaction.channel
|
pack_channel = interaction.channel
|
||||||
|
|
||||||
p_query = await db_get('packtypes', params=[('name', 'Check-In Player')])
|
p_query = await db_get(
|
||||||
|
"packtypes", params=[("name", "Check-In Player")]
|
||||||
|
)
|
||||||
if not p_query:
|
if not p_query:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'I was not able to pull this card for you. '
|
content=f"I was not able to pull this card for you. "
|
||||||
f'Maybe ping {get_cal_user(interaction).mention}?'
|
f"Maybe ping {get_cal_user(interaction).mention}?"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await give_packs(team, 1, p_query['packtypes'][0])
|
await give_packs(team, 1, p_query["packtypes"][0])
|
||||||
p_query = await db_get(
|
p_query = await db_get(
|
||||||
'packs',
|
"packs",
|
||||||
params=[('opened', False), ('team_id', team['id']), ('new_to_old', True), ('limit', 1)]
|
params=[
|
||||||
|
("opened", False),
|
||||||
|
("team_id", team["id"]),
|
||||||
|
("new_to_old", True),
|
||||||
|
("limit", 1),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
if not p_query['count']:
|
if not p_query["count"]:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'I do not see any packs in here. {await get_emoji(interaction, "ConfusedPsyduck")}')
|
content=f"I do not see any packs in here. {await get_emoji(interaction, 'ConfusedPsyduck')}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
pack_ids = await roll_for_cards(p_query['packs'], extra_val=check_ins['count'])
|
pack_ids = await roll_for_cards(
|
||||||
|
p_query["packs"], extra_val=check_ins["count"]
|
||||||
|
)
|
||||||
if not pack_ids:
|
if not pack_ids:
|
||||||
await greeting.edit(
|
await greeting.edit(
|
||||||
content=f'I was not able to create these cards {await get_emoji(interaction, "slight_frown")}'
|
content=f"I was not able to create these cards {await get_emoji(interaction, 'slight_frown')}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
all_cards = []
|
all_cards = []
|
||||||
for p_id in pack_ids:
|
for p_id in pack_ids:
|
||||||
new_cards = await db_get('cards', params=[('pack_id', p_id)])
|
new_cards = await db_get("cards", params=[("pack_id", p_id)])
|
||||||
all_cards.extend(new_cards['cards'])
|
all_cards.extend(new_cards["cards"])
|
||||||
|
|
||||||
if not all_cards:
|
if not all_cards:
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'I was not able to pull these cards {await get_emoji(interaction, "slight_frown")}'
|
content=f"I was not able to pull these cards {await get_emoji(interaction, 'slight_frown')}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await display_cards(all_cards, team, pack_channel, interaction.user, self.bot)
|
await display_cards(
|
||||||
|
all_cards, team, pack_channel, interaction.user, self.bot
|
||||||
|
)
|
||||||
await refresh_sheet(team, self.bot)
|
await refresh_sheet(team, self.bot)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -215,87 +283,102 @@ class Packs(commands.Cog):
|
|||||||
else:
|
else:
|
||||||
m_reward = 25
|
m_reward = 25
|
||||||
|
|
||||||
team = await db_post(f'teams/{team["id"]}/money/{m_reward}')
|
team = await db_post(f"teams/{team['id']}/money/{m_reward}")
|
||||||
await interaction.edit_original_response(
|
await interaction.edit_original_response(
|
||||||
content=f'You just earned {m_reward}₼! That brings your wallet to {team["wallet"]}₼!')
|
content=f"You just earned {m_reward}₼! That brings your wallet to {team['wallet']}₼!"
|
||||||
|
)
|
||||||
|
|
||||||
@app_commands.command(name='open-packs', description='Open packs from your inventory')
|
@app_commands.command(
|
||||||
|
name="open-packs", description="Open packs from your inventory"
|
||||||
|
)
|
||||||
@app_commands.checks.has_any_role(PD_PLAYERS)
|
@app_commands.checks.has_any_role(PD_PLAYERS)
|
||||||
async def open_packs_slash(self, interaction: discord.Interaction):
|
async def open_packs_slash(self, interaction: discord.Interaction):
|
||||||
if interaction.channel.name in ['paper-dynasty-chat', 'pd-news-ticker', 'pd-network-news']:
|
if interaction.channel.name in [
|
||||||
|
"paper-dynasty-chat",
|
||||||
|
"pd-news-ticker",
|
||||||
|
"pd-network-news",
|
||||||
|
]:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
f'Please head to down to {get_channel(interaction, "pd-bot-hole")} to run this command.',
|
f"Please head to down to {get_channel(interaction, 'pd-bot-hole')} to run this command.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
owner_team = await get_team_by_owner(interaction.user.id)
|
owner_team = await get_team_by_owner(interaction.user.id)
|
||||||
if not owner_team:
|
if not owner_team:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!'
|
"I don't see a team for you, yet. You can sign up with the `/newteam` command!"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
p_query = await db_get('packs', params=[
|
p_query = await db_get(
|
||||||
('team_id', owner_team['id']), ('opened', False)
|
"packs", params=[("team_id", owner_team["id"]), ("opened", False)]
|
||||||
])
|
)
|
||||||
if p_query['count'] == 0:
|
if p_query["count"] == 0:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
f'Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by '
|
"Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by "
|
||||||
f'donating to the league.'
|
"donating to the league."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Pack types that are auto-opened and should not appear in the manual open menu
|
||||||
|
AUTO_OPEN_TYPES = {"Check-In Player"}
|
||||||
|
|
||||||
# Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium)
|
# Group packs by type and customization (e.g. Standard, Standard-Orioles, Standard-2012, Premium)
|
||||||
p_count = 0
|
p_count = 0
|
||||||
p_data = {
|
p_data = {
|
||||||
'Standard': [],
|
"Standard": [],
|
||||||
'Premium': [],
|
"Premium": [],
|
||||||
'Daily': [],
|
"Daily": [],
|
||||||
'MVP': [],
|
"MVP": [],
|
||||||
'All Star': [],
|
"All Star": [],
|
||||||
'Mario': [],
|
"Mario": [],
|
||||||
'Team Choice': []
|
"Team Choice": [],
|
||||||
}
|
}
|
||||||
logger.debug(f'Parsing packs...')
|
logger.debug("Parsing packs...")
|
||||||
for pack in p_query['packs']:
|
for pack in p_query["packs"]:
|
||||||
p_group = None
|
p_group = None
|
||||||
logger.debug(f'pack: {pack}')
|
logger.debug(f"pack: {pack}")
|
||||||
logger.debug(f'pack cardset: {pack["pack_cardset"]}')
|
logger.debug(f"pack cardset: {pack['pack_cardset']}")
|
||||||
if pack['pack_team'] is None and pack['pack_cardset'] is None:
|
if pack["pack_type"]["name"] in AUTO_OPEN_TYPES:
|
||||||
p_group = pack['pack_type']['name']
|
logger.debug(
|
||||||
|
f"Skipping auto-open pack type: {pack['pack_type']['name']}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if pack["pack_team"] is None and pack["pack_cardset"] is None:
|
||||||
|
p_group = pack["pack_type"]["name"]
|
||||||
# Add to p_data if this is a new pack type
|
# Add to p_data if this is a new pack type
|
||||||
if p_group not in p_data:
|
if p_group not in p_data:
|
||||||
p_data[p_group] = []
|
p_data[p_group] = []
|
||||||
|
|
||||||
elif pack['pack_team'] is not None:
|
elif pack["pack_team"] is not None:
|
||||||
if pack['pack_type']['name'] == 'Standard':
|
if pack["pack_type"]["name"] == "Standard":
|
||||||
p_group = f'Standard-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}'
|
p_group = f"Standard-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
|
||||||
elif pack['pack_type']['name'] == 'Premium':
|
elif pack["pack_type"]["name"] == "Premium":
|
||||||
p_group = f'Premium-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}'
|
p_group = f"Premium-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
|
||||||
elif pack['pack_type']['name'] == 'Team Choice':
|
elif pack["pack_type"]["name"] == "Team Choice":
|
||||||
p_group = f'Team Choice-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}'
|
p_group = f"Team Choice-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
|
||||||
elif pack['pack_type']['name'] == 'MVP':
|
elif pack["pack_type"]["name"] == "MVP":
|
||||||
p_group = f'MVP-Team-{pack["pack_team"]["id"]}-{pack["pack_team"]["sname"]}'
|
p_group = f"MVP-Team-{pack['pack_team']['id']}-{pack['pack_team']['sname']}"
|
||||||
|
|
||||||
if pack['pack_cardset'] is not None:
|
if pack["pack_cardset"] is not None:
|
||||||
p_group += f'-Cardset-{pack["pack_cardset"]["id"]}'
|
p_group += f"-Cardset-{pack['pack_cardset']['id']}"
|
||||||
|
|
||||||
elif pack['pack_cardset'] is not None:
|
elif pack["pack_cardset"] is not None:
|
||||||
if pack['pack_type']['name'] == 'Standard':
|
if pack["pack_type"]["name"] == "Standard":
|
||||||
p_group = f'Standard-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}'
|
p_group = f"Standard-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
|
||||||
elif pack['pack_type']['name'] == 'Premium':
|
elif pack["pack_type"]["name"] == "Premium":
|
||||||
p_group = f'Premium-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}'
|
p_group = f"Premium-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
|
||||||
elif pack['pack_type']['name'] == 'Team Choice':
|
elif pack["pack_type"]["name"] == "Team Choice":
|
||||||
p_group = f'Team Choice-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}'
|
p_group = f"Team Choice-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
|
||||||
elif pack['pack_type']['name'] == 'All Star':
|
elif pack["pack_type"]["name"] == "All Star":
|
||||||
p_group = f'All Star-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}'
|
p_group = f"All Star-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
|
||||||
elif pack['pack_type']['name'] == 'MVP':
|
elif pack["pack_type"]["name"] == "MVP":
|
||||||
p_group = f'MVP-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}'
|
p_group = f"MVP-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
|
||||||
elif pack['pack_type']['name'] == 'Promo Choice':
|
elif pack["pack_type"]["name"] == "Promo Choice":
|
||||||
p_group = f'Promo Choice-Cardset-{pack["pack_cardset"]["id"]}-{pack["pack_cardset"]["name"]}'
|
p_group = f"Promo Choice-Cardset-{pack['pack_cardset']['id']}-{pack['pack_cardset']['name']}"
|
||||||
|
|
||||||
logger.info(f'p_group: {p_group}')
|
logger.info(f"p_group: {p_group}")
|
||||||
if p_group is not None:
|
if p_group is not None:
|
||||||
p_count += 1
|
p_count += 1
|
||||||
if p_group not in p_data:
|
if p_group not in p_data:
|
||||||
@ -305,34 +388,41 @@ class Packs(commands.Cog):
|
|||||||
|
|
||||||
if p_count == 0:
|
if p_count == 0:
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
f'Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by '
|
"Looks like you are clean out of packs, friendo. You can earn them by playing PD games or by "
|
||||||
f'donating to the league.'
|
"donating to the league."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Display options and ask which group to open
|
# Display options and ask which group to open
|
||||||
embed = get_team_embed(f'Unopened Packs', team=owner_team)
|
embed = get_team_embed("Unopened Packs", team=owner_team)
|
||||||
embed.description = owner_team['lname']
|
embed.description = owner_team["lname"]
|
||||||
select_options = []
|
select_options = []
|
||||||
for key in p_data:
|
for key in p_data:
|
||||||
if len(p_data[key]) > 0:
|
if len(p_data[key]) > 0:
|
||||||
pretty_name = None
|
pretty_name = None
|
||||||
# Not a specific pack
|
# Not a specific pack
|
||||||
if '-' not in key:
|
if "-" not in key:
|
||||||
|
pretty_name = key
|
||||||
|
elif "Team" in key:
|
||||||
|
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
|
||||||
|
elif "Cardset" in key:
|
||||||
|
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
|
||||||
|
else:
|
||||||
|
# Pack type name contains a hyphen (e.g. "Check-In Player")
|
||||||
pretty_name = key
|
pretty_name = key
|
||||||
elif 'Team' in key:
|
|
||||||
pretty_name = f'{key.split("-")[0]} - {key.split("-")[3]}'
|
|
||||||
elif 'Cardset' in key:
|
|
||||||
pretty_name = f'{key.split("-")[0]} - {key.split("-")[3]}'
|
|
||||||
|
|
||||||
if pretty_name is not None:
|
if pretty_name is not None:
|
||||||
embed.add_field(name=pretty_name, value=f'Qty: {len(p_data[key])}')
|
embed.add_field(name=pretty_name, value=f"Qty: {len(p_data[key])}")
|
||||||
select_options.append(discord.SelectOption(label=pretty_name, value=key))
|
select_options.append(
|
||||||
|
discord.SelectOption(label=pretty_name, value=key)
|
||||||
|
)
|
||||||
|
|
||||||
view = SelectView(select_objects=[SelectOpenPack(select_options, owner_team)], timeout=15)
|
view = SelectView(
|
||||||
|
select_objects=[SelectOpenPack(select_options, owner_team)], timeout=15
|
||||||
|
)
|
||||||
await interaction.response.send_message(embed=embed, view=view)
|
await interaction.response.send_message(embed=embed, view=view)
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
"""Setup function for the Packs cog."""
|
"""Setup function for the Packs cog."""
|
||||||
await bot.add_cog(Packs(bot))
|
await bot.add_cog(Packs(bot))
|
||||||
|
|||||||
@ -10,11 +10,14 @@ from discord import app_commands
|
|||||||
from discord.ext import commands, tasks
|
from discord.ext import commands, tasks
|
||||||
|
|
||||||
from api_calls import db_get
|
from api_calls import db_get
|
||||||
from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used
|
from helpers.scouting import (
|
||||||
|
SCOUT_TOKEN_COST,
|
||||||
|
SCOUT_TOKENS_PER_DAY,
|
||||||
|
get_scout_tokens_used,
|
||||||
|
)
|
||||||
from helpers.utils import int_timestamp
|
from helpers.utils import int_timestamp
|
||||||
from helpers.discord_utils import get_team_embed
|
from helpers.discord_utils import get_team_embed
|
||||||
from helpers.main import get_team_by_owner
|
from helpers.main import get_team_by_owner
|
||||||
from helpers.constants import PD_SEASON, IMAGES
|
|
||||||
|
|
||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
@ -54,7 +57,10 @@ class Scouting(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if tokens_remaining == 0:
|
if tokens_remaining == 0:
|
||||||
embed.description += "\n\nYou've used all your tokens! Check back tomorrow."
|
embed.description += (
|
||||||
|
f"\n\nYou've used all your free tokens! "
|
||||||
|
f"You can still scout by purchasing a token for **{SCOUT_TOKEN_COST}₼**."
|
||||||
|
)
|
||||||
|
|
||||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||||
|
|
||||||
|
|||||||
@ -15,131 +15,161 @@ from api_calls import db_get, db_post, db_patch, db_delete, get_team_by_abbrev
|
|||||||
from help_text import SHEET_SHARE_STEPS, HELP_SHEET_SCRIPTS
|
from 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, share_channel, get_role, get_cal_user, get_or_create_role,
|
get_team_by_owner,
|
||||||
display_cards, give_packs, get_all_pos, get_sheets, refresh_sheet,
|
share_channel,
|
||||||
post_ratings_guide, team_summary_embed, get_roster_sheet, Question, Confirm,
|
get_role,
|
||||||
ButtonOptions, legal_channel, get_channel, create_channel, get_context_user
|
get_cal_user,
|
||||||
|
get_or_create_role,
|
||||||
|
display_cards,
|
||||||
|
give_packs,
|
||||||
|
get_all_pos,
|
||||||
|
get_sheets,
|
||||||
|
refresh_sheet,
|
||||||
|
post_ratings_guide,
|
||||||
|
team_summary_embed,
|
||||||
|
get_roster_sheet,
|
||||||
|
Question,
|
||||||
|
Confirm,
|
||||||
|
ButtonOptions,
|
||||||
|
legal_channel,
|
||||||
|
get_channel,
|
||||||
|
create_channel,
|
||||||
|
get_context_user,
|
||||||
)
|
)
|
||||||
from api_calls import team_hash
|
from 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):
|
||||||
"""Team creation and Google Sheets integration functionality for Paper Dynasty."""
|
"""Team creation and Google Sheets integration functionality for Paper Dynasty."""
|
||||||
|
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
@app_commands.command(name='newteam', description='Get your fresh team for a new season')
|
@app_commands.command(
|
||||||
|
name="newteam", description="Get your fresh team for a new season"
|
||||||
|
)
|
||||||
@app_commands.checks.has_any_role(PD_PLAYERS)
|
@app_commands.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, interaction: discord.Interaction, gm_name: str, team_abbrev: str, team_full_name: str,
|
self,
|
||||||
team_short_name: str, mlb_anchor_team: str, team_logo_url: str = None, color: str = None):
|
interaction: discord.Interaction,
|
||||||
|
gm_name: str,
|
||||||
|
team_abbrev: str,
|
||||||
|
team_full_name: str,
|
||||||
|
team_short_name: str,
|
||||||
|
mlb_anchor_team: str,
|
||||||
|
team_logo_url: str = None,
|
||||||
|
color: str = None,
|
||||||
|
):
|
||||||
owner_team = await get_team_by_owner(interaction.user.id)
|
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}',
|
f"Let's head down to your private channel: {op_ch.mention}", ephemeral=True
|
||||||
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('Are you happy with this branding? Don\'t worry - you can update it later!',
|
question = await op_ch.send(
|
||||||
embed=embed, view=view)
|
"Are you happy with this branding? Don't worry - you can update it later!",
|
||||||
|
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
|
||||||
@ -147,26 +177,31 @@ 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 mlb_anchor_team.upper() in ALL_MLB_TEAMS[x] or mlb_anchor_team.title() in ALL_MLB_TEAMS[x]:
|
if (
|
||||||
|
mlb_anchor_team.upper() in ALL_MLB_TEAMS[x]
|
||||||
|
or mlb_anchor_team.title() in ALL_MLB_TEAMS[x]
|
||||||
|
):
|
||||||
team_choice = x
|
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 = f'I don\'t recognize **{team_string}**. I try to recognize abbreviations (BAL), ' \
|
prompt = (
|
||||||
f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you ' \
|
f"I don't recognize **{team_string}**. I try to recognize abbreviations (BAL), "
|
||||||
f'like to use as your anchor team?'
|
f'short names (Orioles), and long names ("Baltimore Orioles").\n\nWhat MLB club would you '
|
||||||
this_q = Question(self.bot, op_ch, prompt, 'text', 120)
|
f"like to use as your anchor team?"
|
||||||
|
)
|
||||||
|
this_q = Question(self.bot, op_ch, prompt, "text", 120)
|
||||||
team_string = await this_q.ask([interaction.user])
|
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
|
||||||
|
|
||||||
@ -176,166 +211,257 @@ class TeamSetup(commands.Cog):
|
|||||||
else:
|
else:
|
||||||
match = False
|
match = False
|
||||||
for x in ALL_MLB_TEAMS:
|
for x in ALL_MLB_TEAMS:
|
||||||
if team_string.upper() in ALL_MLB_TEAMS[x] or team_string.title() in ALL_MLB_TEAMS[x]:
|
if (
|
||||||
|
team_string.upper() in ALL_MLB_TEAMS[x]
|
||||||
|
or team_string.title() in ALL_MLB_TEAMS[x]
|
||||||
|
):
|
||||||
team_choice = x
|
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('teams', payload={
|
team = await db_post(
|
||||||
'abbrev': team_abbrev.upper(),
|
"teams",
|
||||||
'sname': team_short_name,
|
payload={
|
||||||
'lname': team_full_name,
|
"abbrev": team_abbrev.upper(),
|
||||||
'gmid': interaction.user.id,
|
"sname": team_short_name,
|
||||||
'gmname': gm_name,
|
"lname": team_full_name,
|
||||||
'gsheet': 'None',
|
"gmid": interaction.user.id,
|
||||||
'season': current['season'],
|
"gmname": gm_name,
|
||||||
'wallet': 100,
|
"gsheet": "None",
|
||||||
'color': color if color else 'a6ce39',
|
"season": current["season"],
|
||||||
'logo': team_logo_url if team_logo_url else None
|
"wallet": 100,
|
||||||
})
|
"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(f'Frick. {get_cal_user(interaction).mention}, can you help? I can\'t find this team.')
|
await op_ch.send(
|
||||||
|
f"Frick. {get_cal_user(interaction).mention}, can you help? I can't find this team."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
t_role = await get_or_create_role(interaction, f'{team_abbrev} - {team_full_name}')
|
t_role = await get_or_create_role(
|
||||||
|
interaction, f"{team_abbrev} - {team_full_name}"
|
||||||
|
)
|
||||||
await interaction.user.add_roles(t_role)
|
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), ('max_rarity', 3), ('franchise', team_choice), ('pos_exclude', 'RP'), ('limit', 1),
|
("min_rarity", 3),
|
||||||
('in_packs', True)
|
("max_rarity", 3),
|
||||||
]
|
("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), ('max_rarity', 2), ('franchise', team_choice), ('pos_exclude', 'RP'), ('limit', 2),
|
("min_rarity", 2),
|
||||||
('in_packs', True)
|
("max_rarity", 2),
|
||||||
]
|
("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(f'I am so sorry, but the {team_choice} do not have an All-Star to '
|
await op_ch.send(
|
||||||
f'provide as your anchor player. Let\'s start this process over - will you please '
|
f"I am so sorry, but the {team_choice} do not have an All-Star to "
|
||||||
f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the '
|
f"provide as your anchor player. Let's start this process over - will you please "
|
||||||
'command from last time and make edits.')
|
f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the "
|
||||||
await db_delete('teams', object_id=team['id'])
|
"command from last time and make edits."
|
||||||
|
)
|
||||||
|
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(f'I am so sorry, but the {team_choice} do not have two Starters to '
|
await op_ch.send(
|
||||||
f'provide as your anchor players. Let\'s start this process over - will you please '
|
f"I am so sorry, but the {team_choice} do not have two Starters to "
|
||||||
f'run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the '
|
f"provide as your anchor players. Let's start this process over - will you please "
|
||||||
'command from last time and make edits.')
|
f"run the `/newteam` command again with a new MLB club?\nHint: you can copy and paste the "
|
||||||
await db_delete('teams', object_id=team['id'])
|
"command from last time and make edits."
|
||||||
|
)
|
||||||
|
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('packs/one',
|
this_pack = await db_post(
|
||||||
payload={'team_id': team['id'], 'pack_type_id': 2,
|
"packs/one",
|
||||||
'open_time': datetime.datetime.timestamp(datetime.datetime.now())*1000})
|
payload={
|
||||||
|
"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('cards', payload={'cards': [
|
await db_post(
|
||||||
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in anchor_players]
|
"cards",
|
||||||
}, timeout=10)
|
payload={
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"player_id": x["player_id"],
|
||||||
|
"team_id": team["id"],
|
||||||
|
"pack_id": this_pack["id"],
|
||||||
|
}
|
||||||
|
for x in anchor_players
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
# Get 10 pitchers to seed team
|
# Get 10 pitchers to seed team
|
||||||
five_sps = await db_get('players/random', params=[('pos_include', 'SP'), ('max_rarity', 1), ('limit', 5)])
|
five_sps = await db_get(
|
||||||
five_rps = await db_get('players/random', params=[('pos_include', 'RP'), ('max_rarity', 1), ('limit', 5)])
|
"players/random",
|
||||||
team_sp = [x for x in five_sps['players']]
|
params=[("pos_include", "SP"), ("max_rarity", 1), ("limit", 5)],
|
||||||
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('cards', payload={'cards': [
|
await db_post(
|
||||||
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in [*team_sp, *team_rp]]
|
"cards",
|
||||||
}, timeout=10)
|
payload={
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"player_id": x["player_id"],
|
||||||
|
"team_id": team["id"],
|
||||||
|
"pack_id": this_pack["id"],
|
||||||
|
}
|
||||||
|
for x in [*team_sp, *team_rp]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
# TODO: track reserve vs replacement and if rep < res, get rep, else get res
|
|
||||||
# Collect infielders
|
# Collect infielders
|
||||||
team_infielders = []
|
team_infielders = []
|
||||||
for pos in ['C', '1B', '2B', '3B', 'SS']:
|
for pos in ["C", "1B", "2B", "3B", "SS"]:
|
||||||
max_rar = 1
|
if roster_counts["Replacement"] < roster_counts["Reserve"]:
|
||||||
if roster_counts['Replacement'] < roster_counts['Reserve']:
|
rarity_params = [("min_rarity", 0), ("max_rarity", 0)]
|
||||||
max_rar = 0
|
else:
|
||||||
|
rarity_params = [("min_rarity", 1), ("max_rarity", 1)]
|
||||||
|
|
||||||
r_draw = await db_get(
|
r_draw = await db_get(
|
||||||
'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False
|
"players/random",
|
||||||
|
params=[("pos_include", pos), *rarity_params, ("limit", 2)],
|
||||||
|
none_okay=False,
|
||||||
)
|
)
|
||||||
team_infielders.extend(r_draw['players'])
|
team_infielders.extend(r_draw["players"])
|
||||||
|
|
||||||
update_roster_counts(team_infielders)
|
update_roster_counts(team_infielders)
|
||||||
await db_post('cards', payload={'cards': [
|
await db_post(
|
||||||
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_infielders]
|
"cards",
|
||||||
}, timeout=10)
|
payload={
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"player_id": x["player_id"],
|
||||||
|
"team_id": team["id"],
|
||||||
|
"pack_id": this_pack["id"],
|
||||||
|
}
|
||||||
|
for x in team_infielders
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
# Collect outfielders
|
# Collect outfielders
|
||||||
team_outfielders = []
|
team_outfielders = []
|
||||||
for pos in ['LF', 'CF', 'RF']:
|
for pos in ["LF", "CF", "RF"]:
|
||||||
max_rar = 1
|
if roster_counts["Replacement"] < roster_counts["Reserve"]:
|
||||||
if roster_counts['Replacement'] < roster_counts['Reserve']:
|
rarity_params = [("min_rarity", 0), ("max_rarity", 0)]
|
||||||
max_rar = 0
|
else:
|
||||||
|
rarity_params = [("min_rarity", 1), ("max_rarity", 1)]
|
||||||
|
|
||||||
r_draw = await db_get(
|
r_draw = await db_get(
|
||||||
'players/random', params=[('pos_include', pos), ('max_rarity', max_rar), ('limit', 2)], none_okay=False
|
"players/random",
|
||||||
|
params=[("pos_include", pos), *rarity_params, ("limit", 2)],
|
||||||
|
none_okay=False,
|
||||||
)
|
)
|
||||||
team_outfielders.extend(r_draw['players'])
|
team_outfielders.extend(r_draw["players"])
|
||||||
|
|
||||||
update_roster_counts(team_outfielders)
|
update_roster_counts(team_outfielders)
|
||||||
await db_post('cards', payload={'cards': [
|
await db_post(
|
||||||
{'player_id': x['player_id'], 'team_id': team['id'], 'pack_id': this_pack['id']} for x in team_outfielders]
|
"cards",
|
||||||
}, timeout=10)
|
payload={
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"player_id": x["player_id"],
|
||||||
|
"team_id": team["id"],
|
||||||
|
"pack_id": this_pack["id"],
|
||||||
|
}
|
||||||
|
for x in team_outfielders
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
async with op_ch.typing():
|
async with op_ch.typing():
|
||||||
done_anc = await display_cards(
|
done_anc = await display_cards(
|
||||||
[{'player': x, 'team': team} for x in anchor_players], team, op_ch, interaction.user, self.bot,
|
[{"player": x, "team": team} for x in anchor_players],
|
||||||
cust_message=f'Let\'s take a look at your three {team_choice} anchor players.\n'
|
team,
|
||||||
f'Press `Close Pack` to continue.',
|
op_ch,
|
||||||
add_roster=False
|
interaction.user,
|
||||||
|
self.bot,
|
||||||
|
cust_message=f"Let's take a look at your three {team_choice} anchor players.\n"
|
||||||
|
f"Press `Close Pack` to continue.",
|
||||||
|
add_roster=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
error_text = f'Yikes - I can\'t display the rest of your team. {get_cal_user(interaction).mention} plz halp'
|
error_text = f"Yikes - I can't display the rest of your team. {get_cal_user(interaction).mention} plz halp"
|
||||||
if not done_anc:
|
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], team, op_ch, interaction.user, self.bot,
|
[{"player": x, "team": team} for x in team_sp],
|
||||||
cust_message=f'Here are your starting pitchers.\n'
|
team,
|
||||||
f'Press `Close Pack` to continue.',
|
op_ch,
|
||||||
add_roster=False
|
interaction.user,
|
||||||
|
self.bot,
|
||||||
|
cust_message=f"Here are your starting pitchers.\n"
|
||||||
|
f"Press `Close Pack` to continue.",
|
||||||
|
add_roster=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not done_sp:
|
if not done_sp:
|
||||||
@ -343,10 +469,14 @@ 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], team, op_ch, interaction.user, self.bot,
|
[{"player": x, "team": team} for x in team_rp],
|
||||||
cust_message=f'And now for your bullpen.\n'
|
team,
|
||||||
f'Press `Close Pack` to continue.',
|
op_ch,
|
||||||
add_roster=False
|
interaction.user,
|
||||||
|
self.bot,
|
||||||
|
cust_message=f"And now for your bullpen.\n"
|
||||||
|
f"Press `Close Pack` to continue.",
|
||||||
|
add_roster=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not done_rp:
|
if not done_rp:
|
||||||
@ -354,10 +484,14 @@ 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], team, op_ch, interaction.user, self.bot,
|
[{"player": x, "team": team} for x in team_infielders],
|
||||||
cust_message=f'Next let\'s take a look at your infielders.\n'
|
team,
|
||||||
f'Press `Close Pack` to continue.',
|
op_ch,
|
||||||
add_roster=False
|
interaction.user,
|
||||||
|
self.bot,
|
||||||
|
cust_message=f"Next let's take a look at your infielders.\n"
|
||||||
|
f"Press `Close Pack` to continue.",
|
||||||
|
add_roster=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not done_inf:
|
if not done_inf:
|
||||||
@ -365,10 +499,14 @@ 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], team, op_ch, interaction.user, self.bot,
|
[{"player": x, "team": team} for x in team_outfielders],
|
||||||
cust_message=f'Now let\'s take a look at your outfielders.\n'
|
team,
|
||||||
f'Press `Close Pack` to continue.',
|
op_ch,
|
||||||
add_roster=False
|
interaction.user,
|
||||||
|
self.bot,
|
||||||
|
cust_message=f"Now let's take a look at your outfielders.\n"
|
||||||
|
f"Press `Close Pack` to continue.",
|
||||||
|
add_roster=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not done_out:
|
if not done_out:
|
||||||
@ -376,129 +514,154 @@ 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(team, interaction, include_roster=False)
|
new_team_embed = await team_summary_embed(
|
||||||
|
team, interaction, include_roster=False
|
||||||
|
)
|
||||||
await send_to_channel(
|
await send_to_channel(
|
||||||
self.bot, "pd-network-news", content='A new challenger approaches...', embed=new_team_embed
|
self.bot,
|
||||||
|
"pd-network-news",
|
||||||
|
content="A new challenger approaches...",
|
||||||
|
embed=new_team_embed,
|
||||||
)
|
)
|
||||||
|
|
||||||
@commands.hybrid_command(name='newsheet', help='Link a new team sheet with your team')
|
@commands.hybrid_command(
|
||||||
|
name="newsheet", help="Link a new team sheet with your team"
|
||||||
|
)
|
||||||
@commands.has_any_role(PD_PLAYERS)
|
@commands.has_any_role(PD_PLAYERS)
|
||||||
async def share_sheet_command(
|
async def share_sheet_command(
|
||||||
self, ctx, google_sheet_url: str, team_abbrev: Optional[str], copy_rosters: Optional[bool] = True):
|
self,
|
||||||
|
ctx,
|
||||||
|
google_sheet_url: str,
|
||||||
|
team_abbrev: Optional[str],
|
||||||
|
copy_rosters: Optional[bool] = True,
|
||||||
|
):
|
||||||
owner_team = await get_team_by_owner(get_context_user(ctx).id)
|
owner_team = await get_team_by_owner(get_context_user(ctx).id)
|
||||||
if not owner_team:
|
if not owner_team:
|
||||||
await ctx.send(f'I don\'t see a team for you, yet. You can sign up with the `/newteam` command!')
|
await ctx.send(
|
||||||
|
f"I don't see a team for you, yet. You can sign up with the `/newteam` command!"
|
||||||
|
)
|
||||||
return
|
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(f'You can only update the team sheet for your own team, you goober.')
|
await ctx.send(
|
||||||
|
f"You can only update the team sheet for your own team, you goober."
|
||||||
|
)
|
||||||
return
|
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(f'Ope, looks like that is the template sheet. Would you please make a copy and then share?')
|
await ctx.send(
|
||||||
|
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([ctx.author], timeout=30, labels=['Main Team', 'Gauntlet Team', None, None, None])
|
view = ButtonOptions(
|
||||||
question = await ctx.send(f'Is this sheet for your main PD team or your active Gauntlet team?', view=view)
|
[ctx.author],
|
||||||
|
timeout=30,
|
||||||
|
labels=["Main Team", "Gauntlet Team", None, None, None],
|
||||||
|
)
|
||||||
|
question = await ctx.send(
|
||||||
|
f"Is this sheet for your main PD team or your active Gauntlet team?",
|
||||||
|
view=view,
|
||||||
|
)
|
||||||
await view.wait()
|
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.', view=None
|
content=f"Okay you keep thinking on it and get back to me when you're ready.",
|
||||||
|
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(f'I wasn\'t able to access that sheet. Did you remember to share it with my PD email?'
|
await ctx.send(
|
||||||
f'\n\nHere\'s a quick refresher:\n{SHEET_SHARE_STEPS}\n\n'
|
f"I wasn't able to access that sheet. Did you remember to share it with my PD email?"
|
||||||
f'{get_roster_sheet({"gsheet": current["gsheet_template"]})}')
|
f"\n\nHere's a quick refresher:\n{SHEET_SHARE_STEPS}\n\n"
|
||||||
|
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',
|
crange="B1:B2", values=[[f"{team['id']}"], [f"{team_hash(team)}"]]
|
||||||
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,
|
[
|
||||||
int(row[1].value) if row[1].value != '' else None,
|
row[0].value if row[0].value != "" else None,
|
||||||
row[2].value if row[2].value != '' else None,
|
int(row[1].value) if row[1].value != "" else None,
|
||||||
int(row[3].value) if row[3].value != '' else None,
|
row[2].value if row[2].value != "" else None,
|
||||||
row[4].value if row[4].value != '' else None,
|
int(row[3].value) if row[3].value != "" else None,
|
||||||
int(row[5].value) if row[5].value != '' else None
|
row[4].value if row[4].value != "" else None,
|
||||||
])
|
int(row[5].value) if row[5].value != "" else None,
|
||||||
logger.debug(f'new_l_data: {new_l_data}')
|
]
|
||||||
|
)
|
||||||
|
logger.debug(f"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(
|
new_r_sheet.update_values(crange="B3:B80", values=new_r_data)
|
||||||
crange='B3:B80',
|
new_r_sheet.update_values(crange="H4:M26", values=new_l_data)
|
||||||
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('teams', object_id=team['id'], params=[('gsheet', new_sheet.id)])
|
team = await db_patch(
|
||||||
|
"teams", object_id=team["id"], params=[("gsheet", new_sheet.id)]
|
||||||
|
)
|
||||||
await refresh_sheet(team, self.bot, sheets)
|
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):
|
||||||
"""Setup function for the TeamSetup cog."""
|
"""Setup function for the TeamSetup cog."""
|
||||||
await bot.add_cog(TeamSetup(bot))
|
await bot.add_cog(TeamSetup(bot))
|
||||||
|
|||||||
1808
cogs/gameplay.py
1808
cogs/gameplay.py
File diff suppressed because it is too large
Load Diff
1353
cogs/players.py
1353
cogs/players.py
File diff suppressed because it is too large
Load Diff
@ -12,65 +12,89 @@ import datetime
|
|||||||
from sqlmodel import Session
|
from 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, PD_PLAYERS_ROLE_NAME, get_team_embed, get_team_by_owner,
|
ACTIVE_EVENT_LITERAL,
|
||||||
legal_channel, Confirm, send_to_channel
|
PD_PLAYERS_ROLE_NAME,
|
||||||
|
get_team_embed,
|
||||||
|
get_team_by_owner,
|
||||||
|
legal_channel,
|
||||||
|
Confirm,
|
||||||
|
send_to_channel,
|
||||||
)
|
)
|
||||||
from helpers.utils import get_roster_sheet, get_cal_user
|
from 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("Gauntlets module not available - gauntlet commands will have limited functionality")
|
logger.warning(
|
||||||
|
"Gauntlets module not available - gauntlet commands will have limited functionality"
|
||||||
|
)
|
||||||
GAUNTLETS_AVAILABLE = False
|
GAUNTLETS_AVAILABLE = False
|
||||||
gauntlets = None
|
gauntlets = None
|
||||||
|
|
||||||
|
|
||||||
class Gauntlet(commands.Cog):
|
class Gauntlet(commands.Cog):
|
||||||
"""Gauntlet game mode functionality for Paper Dynasty."""
|
"""Gauntlet game mode functionality for Paper Dynasty."""
|
||||||
|
|
||||||
def __init__(self, bot):
|
def __init__(self, bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
|
|
||||||
group_gauntlet = app_commands.Group(name='gauntlets', description='Check your progress or start a new Gauntlet')
|
group_gauntlet = app_commands.Group(
|
||||||
|
name="gauntlets", description="Check your progress or start a new Gauntlet"
|
||||||
|
)
|
||||||
|
|
||||||
@group_gauntlet.command(name='status', description='View status of current Gauntlet run')
|
@group_gauntlet.command(
|
||||||
|
name="status", description="View status of current Gauntlet run"
|
||||||
|
)
|
||||||
@app_commands.describe(
|
@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, interaction: discord.Interaction, event_name: ACTIVE_EVENT_LITERAL, # type: ignore
|
self,
|
||||||
team_abbrev: Optional[str] = None):
|
interaction: discord.Interaction,
|
||||||
|
event_name: ACTIVE_EVENT_LITERAL, # type: ignore
|
||||||
|
team_abbrev: Optional[str] = None,
|
||||||
|
):
|
||||||
"""View status of current gauntlet run - corrected to match original business logic."""
|
"""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('events', params=[("name", event_name), ("active", True)])
|
e_query = await db_get(
|
||||||
if not e_query or e_query.get('count', 0) == 0:
|
"events", params=[("name", event_name), ("active", True)]
|
||||||
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('gauntletruns', params=[
|
r_query = await db_get(
|
||||||
('team_id', this_team['id']), ('is_active', True), ('gauntlet_id', this_event['id'])
|
"gauntletruns",
|
||||||
])
|
params=[
|
||||||
|
("team_id", this_team["id"]),
|
||||||
|
("is_active", True),
|
||||||
|
("gauntlet_id", this_event["id"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
if r_query and r_query.get('count', 0) != 0:
|
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"]}.'
|
||||||
@ -78,7 +102,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
|
||||||
|
|
||||||
@ -86,127 +110,168 @@ 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 interaction.channel and hasattr(interaction.channel, 'name') and 'hello' not in str(interaction.channel.name):
|
if (
|
||||||
|
interaction.channel
|
||||||
|
and hasattr(interaction.channel, "name")
|
||||||
|
and "hello" not in str(interaction.channel.name)
|
||||||
|
):
|
||||||
await interaction.response.send_message(
|
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(session, gm_id=interaction.user.id, main_team=True)
|
main_team = await get_team_or_none(
|
||||||
draft_team = await get_team_or_none(session, gm_id=interaction.user.id, gauntlet_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
|
||||||
|
)
|
||||||
|
|
||||||
# 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(content='Hmm...I don\'t see any active events.')
|
await interaction.edit_original_response(
|
||||||
|
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 = [event for event in e_query['events'] if event['name'] == event_choice][0]
|
this_event = [
|
||||||
|
event
|
||||||
logger.info(f'this_event: {this_event}')
|
for event in e_query["events"]
|
||||||
|
if event["name"] == event_choice
|
||||||
|
][0]
|
||||||
|
|
||||||
|
logger.info(f"this_event: {this_event}")
|
||||||
|
|
||||||
first_flag = draft_team is None
|
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=[('team_id', draft_team.id), ('gauntlet_id', this_event['id']), ('is_active', True)]
|
params=[
|
||||||
|
("team_id", draft_team.id),
|
||||||
|
("gauntlet_id", this_event["id"]),
|
||||||
|
("is_active", True),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if r_query and r_query.get('count', 0) != 0:
|
if r_query and r_query.get("count", 0) != 0:
|
||||||
await interaction.edit_original_response(
|
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(f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}')
|
logger.error(
|
||||||
await gauntlets.wipe_team(draft_team, interaction) # type: ignore
|
f'Failed to run {this_event["name"]} draft for the {main_team.sname if main_team else "unknown"}: {e}'
|
||||||
|
)
|
||||||
|
await gauntlets.wipe_team(draft_team, interaction) # type: ignore
|
||||||
await interaction.followup.send(
|
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(name='reset', description='Wipe your current team so you can re-draft')
|
@group_gauntlet.command(
|
||||||
|
name="reset", description="Wipe your current team so you can re-draft"
|
||||||
|
)
|
||||||
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
|
@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('events', params=[("name", event_name), ("active", True)])
|
e_query = await db_get(
|
||||||
if e_query['count'] == 0:
|
"events", params=[("name", event_name), ("active", True)]
|
||||||
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('gauntletruns', params=[
|
r_query = await db_get(
|
||||||
('team_id', draft_team['id']), ('is_active', True), ('gauntlet_id', this_event['id'])
|
"gauntletruns",
|
||||||
])
|
params=[
|
||||||
|
("team_id", draft_team["id"]),
|
||||||
|
("is_active", True),
|
||||||
|
("gauntlet_id", this_event["id"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
if r_query and r_query.get('count', 0) != 0:
|
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"]}.'
|
||||||
@ -214,27 +279,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(
|
await interaction.edit_original_response(content=conf_string, view=view)
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
async def setup(bot):
|
||||||
"""Setup function for the Gauntlet cog."""
|
"""Setup function for the Gauntlet cog."""
|
||||||
await bot.add_cog(Gauntlet(bot))
|
await bot.add_cog(Gauntlet(bot))
|
||||||
|
|||||||
423
cogs/refractor.py
Normal file
423
cogs/refractor.py
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
Refractor cog — /refractor status slash command.
|
||||||
|
|
||||||
|
Displays a team's refractor progress: formula value vs next threshold
|
||||||
|
with a progress bar, paginated 10 cards per page.
|
||||||
|
|
||||||
|
Tier names: Base Card (T0) / Base Chrome (T1) / Refractor (T2) /
|
||||||
|
Gold Refractor (T3) / Superfractor (T4).
|
||||||
|
|
||||||
|
Depends on WP-07 (refractor/cards API endpoint).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
from discord.app_commands import Choice
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from api_calls import db_get
|
||||||
|
from helpers.discord_utils import get_team_embed
|
||||||
|
from helpers.main import get_team_by_owner
|
||||||
|
|
||||||
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
|
PAGE_SIZE = 10
|
||||||
|
|
||||||
|
TIER_NAMES = {
|
||||||
|
0: "Base Card",
|
||||||
|
1: "Base Chrome",
|
||||||
|
2: "Refractor",
|
||||||
|
3: "Gold Refractor",
|
||||||
|
4: "Superfractor",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tier-specific labels for the status display.
|
||||||
|
TIER_SYMBOLS = {
|
||||||
|
0: "Base", # Base Card — used in summary only, not in per-card display
|
||||||
|
1: "T1", # Base Chrome
|
||||||
|
2: "T2", # Refractor
|
||||||
|
3: "T3", # Gold Refractor
|
||||||
|
4: "T4★", # Superfractor
|
||||||
|
}
|
||||||
|
|
||||||
|
_FULL_BAR = "▰" * 12
|
||||||
|
|
||||||
|
# Embed accent colors per tier (used for single-tier filtered views).
|
||||||
|
TIER_COLORS = {
|
||||||
|
0: 0x95A5A6, # slate grey
|
||||||
|
1: 0xBDC3C7, # silver/chrome
|
||||||
|
2: 0x3498DB, # refractor blue
|
||||||
|
3: 0xF1C40F, # gold
|
||||||
|
4: 0x1ABC9C, # teal superfractor
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
|
||||||
|
"""
|
||||||
|
Render a Unicode block progress bar.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
render_progress_bar(120, 149) -> '▰▰▰▰▰▰▰▰▰▰▱▱'
|
||||||
|
render_progress_bar(0, 100) -> '▱▱▱▱▱▱▱▱▱▱▱▱'
|
||||||
|
render_progress_bar(100, 100) -> '▰▰▰▰▰▰▰▰▰▰▰▰'
|
||||||
|
"""
|
||||||
|
if threshold <= 0:
|
||||||
|
filled = width
|
||||||
|
else:
|
||||||
|
ratio = max(0.0, min(current / threshold, 1.0))
|
||||||
|
filled = round(ratio * width)
|
||||||
|
empty = width - filled
|
||||||
|
return f"{'▰' * filled}{'▱' * empty}"
|
||||||
|
|
||||||
|
|
||||||
|
def _pct_label(current: int, threshold: int) -> str:
|
||||||
|
"""Return a percentage string like '80%'."""
|
||||||
|
if threshold <= 0:
|
||||||
|
return "100%"
|
||||||
|
return f"{min(current / threshold, 1.0):.0%}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_refractor_entry(card_state: dict) -> str:
|
||||||
|
"""
|
||||||
|
Format a single card state dict as a compact two-line display string.
|
||||||
|
|
||||||
|
Output example (base card — no suffix):
|
||||||
|
**Mike Trout**
|
||||||
|
▰▰▰▰▰▰▰▰▰▰▱▱ 120/149 (80%)
|
||||||
|
|
||||||
|
Output example (evolved — suffix tag):
|
||||||
|
**Mike Trout** — Base Chrome [T1]
|
||||||
|
▰▰▰▰▰▰▰▰▰▰▱▱ 120/149 (80%)
|
||||||
|
|
||||||
|
Output example (fully evolved):
|
||||||
|
**Barry Bonds** — Superfractor [T4★]
|
||||||
|
▰▰▰▰▰▰▰▰▰▰▰▰ `MAX`
|
||||||
|
"""
|
||||||
|
player_name = card_state.get("player_name", "Unknown")
|
||||||
|
current_tier = card_state.get("current_tier", 0)
|
||||||
|
formula_value = int(card_state.get("current_value", 0))
|
||||||
|
next_threshold = int(card_state.get("next_threshold") or 0) or None
|
||||||
|
|
||||||
|
if current_tier == 0:
|
||||||
|
first_line = f"**{player_name}**"
|
||||||
|
else:
|
||||||
|
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
|
||||||
|
symbol = TIER_SYMBOLS.get(current_tier, "")
|
||||||
|
first_line = f"**{player_name}** — {tier_label} [{symbol}]"
|
||||||
|
|
||||||
|
if current_tier >= 4 or next_threshold is None:
|
||||||
|
second_line = f"{_FULL_BAR} `MAX`"
|
||||||
|
else:
|
||||||
|
bar = render_progress_bar(formula_value, next_threshold)
|
||||||
|
pct = _pct_label(formula_value, next_threshold)
|
||||||
|
second_line = f"{bar} {formula_value}/{next_threshold} ({pct})"
|
||||||
|
|
||||||
|
return f"{first_line}\n{second_line}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_tier_summary(items: list, total_count: int) -> str:
|
||||||
|
"""
|
||||||
|
Build a one-line summary of tier distribution from the current page items.
|
||||||
|
|
||||||
|
Returns something like: 'T0: 3 T1: 12 T2: 8 T3: 5 T4★: 2 — 30 total'
|
||||||
|
"""
|
||||||
|
counts = {t: 0 for t in range(5)}
|
||||||
|
for item in items:
|
||||||
|
t = item.get("current_tier", 0)
|
||||||
|
if t in counts:
|
||||||
|
counts[t] += 1
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for t in range(5):
|
||||||
|
if counts[t] > 0:
|
||||||
|
parts.append(f"{TIER_SYMBOLS[t]}: {counts[t]}")
|
||||||
|
summary = " ".join(parts) if parts else "No cards"
|
||||||
|
return f"{summary} — {total_count} total"
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_embed(
|
||||||
|
team: dict,
|
||||||
|
items: list,
|
||||||
|
page: int,
|
||||||
|
total_pages: int,
|
||||||
|
total_count: int,
|
||||||
|
tier_filter: Optional[int] = None,
|
||||||
|
) -> discord.Embed:
|
||||||
|
"""
|
||||||
|
Build the refractor status embed with team branding.
|
||||||
|
|
||||||
|
Uses get_team_embed for consistent team color/logo/footer, then layers
|
||||||
|
on the refractor-specific content.
|
||||||
|
"""
|
||||||
|
embed = get_team_embed(f"{team['sname']} — Refractor Status", team=team)
|
||||||
|
|
||||||
|
# Override color for single-tier views to match the tier's identity.
|
||||||
|
if tier_filter is not None and tier_filter in TIER_COLORS:
|
||||||
|
embed.color = TIER_COLORS[tier_filter]
|
||||||
|
|
||||||
|
header = build_tier_summary(items, total_count)
|
||||||
|
lines = [format_refractor_entry(state) for state in items]
|
||||||
|
body = "\n\n".join(lines) if lines else "*No cards found.*"
|
||||||
|
embed.description = f"```{header}```\n{body}"
|
||||||
|
|
||||||
|
existing_footer = embed.footer.text or ""
|
||||||
|
page_text = f"Page {page}/{total_pages}"
|
||||||
|
embed.set_footer(
|
||||||
|
text=f"{page_text} · {existing_footer}" if existing_footer else page_text,
|
||||||
|
icon_url=embed.footer.icon_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
def apply_close_filter(card_states: list) -> list:
|
||||||
|
"""
|
||||||
|
Return only cards within 80% of their next tier threshold.
|
||||||
|
|
||||||
|
Fully evolved cards (T4 or no next_threshold) are excluded.
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for state in card_states:
|
||||||
|
current_tier = state.get("current_tier", 0)
|
||||||
|
formula_value = int(state.get("current_value", 0))
|
||||||
|
next_threshold = state.get("next_threshold")
|
||||||
|
if current_tier >= 4 or not next_threshold:
|
||||||
|
continue
|
||||||
|
if formula_value >= 0.8 * int(next_threshold):
|
||||||
|
result.append(state)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
|
||||||
|
"""
|
||||||
|
Slice items for the given 1-indexed page.
|
||||||
|
|
||||||
|
Returns (page_items, total_pages). Page is clamped to valid range.
|
||||||
|
"""
|
||||||
|
total_pages = max(1, (len(items) + page_size - 1) // page_size)
|
||||||
|
page = max(1, min(page, total_pages))
|
||||||
|
start = (page - 1) * page_size
|
||||||
|
return items[start : start + page_size], total_pages
|
||||||
|
|
||||||
|
|
||||||
|
class RefractorPaginationView(discord.ui.View):
|
||||||
|
"""Prev/Next buttons for refractor status pagination."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
team: dict,
|
||||||
|
page: int,
|
||||||
|
total_pages: int,
|
||||||
|
total_count: int,
|
||||||
|
params: list,
|
||||||
|
owner_id: int,
|
||||||
|
tier_filter: Optional[int] = None,
|
||||||
|
timeout: float = 120.0,
|
||||||
|
):
|
||||||
|
super().__init__(timeout=timeout)
|
||||||
|
self.team = team
|
||||||
|
self.page = page
|
||||||
|
self.total_pages = total_pages
|
||||||
|
self.total_count = total_count
|
||||||
|
self.base_params = params
|
||||||
|
self.owner_id = owner_id
|
||||||
|
self.tier_filter = tier_filter
|
||||||
|
self._update_buttons()
|
||||||
|
|
||||||
|
def _update_buttons(self):
|
||||||
|
self.prev_btn.disabled = self.page <= 1
|
||||||
|
self.next_btn.disabled = self.page >= self.total_pages
|
||||||
|
|
||||||
|
async def _fetch_and_update(self, interaction: discord.Interaction):
|
||||||
|
offset = (self.page - 1) * PAGE_SIZE
|
||||||
|
params = [(k, v) for k, v in self.base_params if k != "offset"]
|
||||||
|
params.append(("offset", offset))
|
||||||
|
|
||||||
|
data = await db_get("refractor/cards", params=params)
|
||||||
|
items = data.get("items", []) if isinstance(data, dict) else []
|
||||||
|
self.total_count = (
|
||||||
|
data.get("count", self.total_count)
|
||||||
|
if isinstance(data, dict)
|
||||||
|
else self.total_count
|
||||||
|
)
|
||||||
|
self.total_pages = max(1, (self.total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
self.page = min(self.page, self.total_pages)
|
||||||
|
|
||||||
|
embed = build_status_embed(
|
||||||
|
self.team,
|
||||||
|
items,
|
||||||
|
self.page,
|
||||||
|
self.total_pages,
|
||||||
|
self.total_count,
|
||||||
|
tier_filter=self.tier_filter,
|
||||||
|
)
|
||||||
|
self._update_buttons()
|
||||||
|
await interaction.response.edit_message(embed=embed, view=self)
|
||||||
|
|
||||||
|
@discord.ui.button(label="◀ Prev", style=discord.ButtonStyle.grey)
|
||||||
|
async def prev_btn(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
|
if interaction.user.id != self.owner_id:
|
||||||
|
return
|
||||||
|
self.page = max(1, self.page - 1)
|
||||||
|
await self._fetch_and_update(interaction)
|
||||||
|
|
||||||
|
@discord.ui.button(label="Next ▶", style=discord.ButtonStyle.grey)
|
||||||
|
async def next_btn(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
|
if interaction.user.id != self.owner_id:
|
||||||
|
return
|
||||||
|
self.page = min(self.total_pages, self.page + 1)
|
||||||
|
await self._fetch_and_update(interaction)
|
||||||
|
|
||||||
|
async def on_timeout(self):
|
||||||
|
self.prev_btn.disabled = True
|
||||||
|
self.next_btn.disabled = True
|
||||||
|
|
||||||
|
|
||||||
|
class Refractor(commands.Cog):
|
||||||
|
"""Refractor progress tracking slash commands."""
|
||||||
|
|
||||||
|
def __init__(self, bot):
|
||||||
|
self.bot = bot
|
||||||
|
|
||||||
|
group_refractor = app_commands.Group(
|
||||||
|
name="refractor", description="Refractor tracking commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
@group_refractor.command(
|
||||||
|
name="status", description="Show your team's refractor progress"
|
||||||
|
)
|
||||||
|
@app_commands.describe(
|
||||||
|
card_type="Filter by card type",
|
||||||
|
tier="Filter by current tier",
|
||||||
|
progress="Filter by advancement progress",
|
||||||
|
page="Page number (default: 1, 10 cards per page)",
|
||||||
|
)
|
||||||
|
@app_commands.choices(
|
||||||
|
card_type=[
|
||||||
|
Choice(value="batter", name="Batter"),
|
||||||
|
Choice(value="sp", name="Starting Pitcher"),
|
||||||
|
Choice(value="rp", name="Relief Pitcher"),
|
||||||
|
],
|
||||||
|
tier=[
|
||||||
|
Choice(value="0", name="T0 — Base Card"),
|
||||||
|
Choice(value="1", name="T1 — Base Chrome"),
|
||||||
|
Choice(value="2", name="T2 — Refractor"),
|
||||||
|
Choice(value="3", name="T3 — Gold Refractor"),
|
||||||
|
Choice(value="4", name="T4 — Superfractor"),
|
||||||
|
],
|
||||||
|
progress=[
|
||||||
|
Choice(value="close", name="Close to next tier (≥80%)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def refractor_status(
|
||||||
|
self,
|
||||||
|
interaction: discord.Interaction,
|
||||||
|
card_type: Optional[Choice[str]] = None,
|
||||||
|
tier: Optional[Choice[str]] = None,
|
||||||
|
progress: Optional[Choice[str]] = None,
|
||||||
|
page: int = 1,
|
||||||
|
):
|
||||||
|
"""Show a paginated view of the invoking user's team refractor progress."""
|
||||||
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
|
team = await get_team_by_owner(interaction.user.id)
|
||||||
|
if not team:
|
||||||
|
await interaction.edit_original_response(
|
||||||
|
content="You don't have a team. Sign up with /newteam first."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
page = max(1, page)
|
||||||
|
offset = (page - 1) * PAGE_SIZE
|
||||||
|
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
|
||||||
|
if card_type:
|
||||||
|
params.append(("card_type", card_type.value))
|
||||||
|
if tier is not None:
|
||||||
|
params.append(("tier", tier.value))
|
||||||
|
if progress:
|
||||||
|
params.append(("progress", progress.value))
|
||||||
|
|
||||||
|
tier_filter = int(tier.value) if tier is not None else None
|
||||||
|
|
||||||
|
data = await db_get("refractor/cards", params=params)
|
||||||
|
if not data:
|
||||||
|
logger.error(
|
||||||
|
"Refractor API returned empty response for team %s", team["id"]
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(
|
||||||
|
content="No refractor data found for your team."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# API error responses contain "detail" key
|
||||||
|
if isinstance(data, dict) and "detail" in data:
|
||||||
|
logger.error(
|
||||||
|
"Refractor API error for team %s: %s", team["id"], data["detail"]
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(
|
||||||
|
content="Something went wrong fetching refractor data. Please try again later."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data if isinstance(data, list) else data.get("items", [])
|
||||||
|
total_count = (
|
||||||
|
data.get("count", len(items)) if isinstance(data, dict) else len(items)
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
"Refractor status for team %s: %d items returned, %d total (page %d)",
|
||||||
|
team["id"],
|
||||||
|
len(items),
|
||||||
|
total_count,
|
||||||
|
page,
|
||||||
|
)
|
||||||
|
if not items:
|
||||||
|
has_filters = card_type or tier is not None or progress
|
||||||
|
if has_filters:
|
||||||
|
parts = []
|
||||||
|
if card_type:
|
||||||
|
parts.append(f"**{card_type.name}**")
|
||||||
|
if tier is not None:
|
||||||
|
parts.append(f"**{tier.name}**")
|
||||||
|
if progress:
|
||||||
|
parts.append(f"progress: **{progress.name}**")
|
||||||
|
filter_str = ", ".join(parts)
|
||||||
|
await interaction.edit_original_response(
|
||||||
|
content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.edit_original_response(
|
||||||
|
content="No refractor data found for your team."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
page = min(page, total_pages)
|
||||||
|
|
||||||
|
embed = build_status_embed(
|
||||||
|
team, items, page, total_pages, total_count, tier_filter=tier_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
if total_pages > 1:
|
||||||
|
view = RefractorPaginationView(
|
||||||
|
team=team,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
total_count=total_count,
|
||||||
|
params=params,
|
||||||
|
owner_id=interaction.user.id,
|
||||||
|
tier_filter=tier_filter,
|
||||||
|
)
|
||||||
|
await interaction.edit_original_response(embed=embed, view=view)
|
||||||
|
else:
|
||||||
|
await interaction.edit_original_response(embed=embed)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot):
|
||||||
|
await bot.add_cog(Refractor(bot))
|
||||||
@ -23,6 +23,7 @@ from helpers import (
|
|||||||
position_name_to_abbrev,
|
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 (
|
||||||
@ -1266,7 +1267,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 not owner_team.id in [this_game.away_team_id, this_game.home_team_id]:
|
if owner_team.id not in [this_game.away_team_id, this_game.home_team_id]:
|
||||||
if interaction.user.id != 258104532423147520:
|
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."
|
||||||
@ -4295,33 +4296,29 @@ 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:
|
||||||
await roll_back(db_game["id"])
|
if db_game is not None:
|
||||||
|
await roll_back(db_game["id"])
|
||||||
log_exception(e, msg="Unable to post game to API, rolling back")
|
log_exception(e, msg="Unable to post game to API, rolling back")
|
||||||
|
|
||||||
# Post game stats to API
|
# Post game stats to API
|
||||||
try:
|
try:
|
||||||
resp = await db_post("plays", payload=db_ready_plays)
|
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:
|
||||||
resp = await db_post("decisions", payload={"decisions": db_ready_decisions})
|
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(
|
||||||
@ -4345,6 +4342,20 @@ 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)
|
||||||
|
# WP-13: update season stats then evaluate refractor milestones for all
|
||||||
|
# participating players. Wrapped in try/except so any failure here is
|
||||||
|
# non-fatal — the game is already saved and refractor will catch up on the
|
||||||
|
# next evaluate call.
|
||||||
|
try:
|
||||||
|
await db_post(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await notify_tier_completion(interaction.channel, tier_up)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
|
||||||
|
|
||||||
session.delete(this_play)
|
session.delete(this_play)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|||||||
1444
db_calls_gameplay.py
1444
db_calls_gameplay.py
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ import discord
|
|||||||
from api_calls import db_get, db_post
|
from api_calls import db_get, db_post
|
||||||
from helpers.main import get_team_by_owner, get_card_embeds
|
from helpers.main import get_team_by_owner, get_card_embeds
|
||||||
from helpers.scouting import (
|
from helpers.scouting import (
|
||||||
|
SCOUT_TOKEN_COST,
|
||||||
SCOUT_TOKENS_PER_DAY,
|
SCOUT_TOKENS_PER_DAY,
|
||||||
build_scout_embed,
|
build_scout_embed,
|
||||||
get_scout_tokens_used,
|
get_scout_tokens_used,
|
||||||
@ -87,7 +88,7 @@ class ScoutView(discord.ui.View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self.message.edit(embed=embed, view=self)
|
await self.message.edit(embed=embed)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update scout message: {e}")
|
logger.error(f"Failed to update scout message: {e}")
|
||||||
|
|
||||||
@ -167,11 +168,27 @@ class ScoutButton(discord.ui.Button):
|
|||||||
tokens_used = await get_scout_tokens_used(scouter_team["id"])
|
tokens_used = await get_scout_tokens_used(scouter_team["id"])
|
||||||
|
|
||||||
if tokens_used >= SCOUT_TOKENS_PER_DAY:
|
if tokens_used >= SCOUT_TOKENS_PER_DAY:
|
||||||
await interaction.followup.send(
|
# Offer to buy an extra scout token
|
||||||
"You're out of scout tokens for today! You get 2 per day, resetting at midnight Central.",
|
buy_view = BuyScoutTokenView(
|
||||||
ephemeral=True,
|
scouter_team=scouter_team,
|
||||||
|
responder_id=interaction.user.id,
|
||||||
)
|
)
|
||||||
return
|
buy_msg = await interaction.followup.send(
|
||||||
|
f"You're out of scout tokens for today! "
|
||||||
|
f"You can buy one for **{SCOUT_TOKEN_COST}₼** "
|
||||||
|
f"(wallet: {scouter_team['wallet']}₼).",
|
||||||
|
view=buy_view,
|
||||||
|
ephemeral=True,
|
||||||
|
wait=True,
|
||||||
|
)
|
||||||
|
buy_view.message = buy_msg
|
||||||
|
await buy_view.wait()
|
||||||
|
|
||||||
|
if not buy_view.value:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Refresh team data after purchase
|
||||||
|
scouter_team = buy_view.scouter_team
|
||||||
|
|
||||||
# Record the claim in the database
|
# Record the claim in the database
|
||||||
try:
|
try:
|
||||||
@ -272,3 +289,86 @@ class ScoutButton(discord.ui.Button):
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
view.processing_users.discard(interaction.user.id)
|
view.processing_users.discard(interaction.user.id)
|
||||||
|
|
||||||
|
|
||||||
|
class BuyScoutTokenView(discord.ui.View):
|
||||||
|
"""Ephemeral confirmation view for purchasing an extra scout token."""
|
||||||
|
|
||||||
|
def __init__(self, scouter_team: dict, responder_id: int):
|
||||||
|
super().__init__(timeout=30.0)
|
||||||
|
self.scouter_team = scouter_team
|
||||||
|
self.responder_id = responder_id
|
||||||
|
self.value = False
|
||||||
|
self.message: discord.Message | None = None
|
||||||
|
|
||||||
|
if scouter_team["wallet"] < SCOUT_TOKEN_COST:
|
||||||
|
self.buy_button.disabled = True
|
||||||
|
self.buy_button.label = (
|
||||||
|
f"Not enough ₼ ({scouter_team['wallet']}/{SCOUT_TOKEN_COST})"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_timeout(self):
|
||||||
|
"""Disable buttons when the buy window expires."""
|
||||||
|
for item in self.children:
|
||||||
|
item.disabled = True
|
||||||
|
if self.message:
|
||||||
|
try:
|
||||||
|
await self.message.edit(view=self)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@discord.ui.button(
|
||||||
|
label=f"Buy Scout Token ({SCOUT_TOKEN_COST}₼)",
|
||||||
|
style=discord.ButtonStyle.green,
|
||||||
|
)
|
||||||
|
async def buy_button(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
|
if interaction.user.id != self.responder_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Re-fetch team to get current wallet (prevent stale data)
|
||||||
|
team = await get_team_by_owner(interaction.user.id)
|
||||||
|
if not team or team["wallet"] < SCOUT_TOKEN_COST:
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content="You don't have enough ₼ for a scout token.",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Deduct currency
|
||||||
|
new_wallet = team["wallet"] - SCOUT_TOKEN_COST
|
||||||
|
try:
|
||||||
|
await db_post(f'teams/{team["id"]}/money/-{SCOUT_TOKEN_COST}')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to deduct scout token cost: {e}")
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content="Something went wrong processing your purchase. Try again!",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
self.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.scouter_team = team
|
||||||
|
self.scouter_team["wallet"] = new_wallet
|
||||||
|
self.value = True
|
||||||
|
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content=f"Scout token purchased for {SCOUT_TOKEN_COST}₼! Scouting your card...",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
@discord.ui.button(label="No thanks", style=discord.ButtonStyle.grey)
|
||||||
|
async def cancel_button(
|
||||||
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
|
):
|
||||||
|
if interaction.user.id != self.responder_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
await interaction.response.edit_message(
|
||||||
|
content="Saving that money. Smart.",
|
||||||
|
view=None,
|
||||||
|
)
|
||||||
|
self.stop()
|
||||||
|
|||||||
@ -3,126 +3,148 @@ Discord Select UI components.
|
|||||||
|
|
||||||
Contains all Select classes for various team, cardset, and pack selections.
|
Contains all Select classes for various team, cardset, and pack selections.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import discord
|
import discord
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
from helpers.constants import ALL_MLB_TEAMS, IMAGES, normalize_franchise
|
from helpers.constants import ALL_MLB_TEAMS, IMAGES, normalize_franchise
|
||||||
|
|
||||||
logger = logging.getLogger('discord_app')
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
# Team name to ID mappings
|
# Team name to ID mappings
|
||||||
AL_TEAM_IDS = {
|
AL_TEAM_IDS = {
|
||||||
'Baltimore Orioles': 3,
|
"Baltimore Orioles": 3,
|
||||||
'Boston Red Sox': 4,
|
"Boston Red Sox": 4,
|
||||||
'Chicago White Sox': 6,
|
"Chicago White Sox": 6,
|
||||||
'Cleveland Guardians': 8,
|
"Cleveland Guardians": 8,
|
||||||
'Detroit Tigers': 10,
|
"Detroit Tigers": 10,
|
||||||
'Houston Astros': 11,
|
"Houston Astros": 11,
|
||||||
'Kansas City Royals': 12,
|
"Kansas City Royals": 12,
|
||||||
'Los Angeles Angels': 13,
|
"Los Angeles Angels": 13,
|
||||||
'Minnesota Twins': 17,
|
"Minnesota Twins": 17,
|
||||||
'New York Yankees': 19,
|
"New York Yankees": 19,
|
||||||
'Oakland Athletics': 20,
|
"Oakland Athletics": 20,
|
||||||
'Athletics': 20, # Alias for post-Oakland move
|
"Athletics": 20, # Alias for post-Oakland move
|
||||||
'Seattle Mariners': 24,
|
"Seattle Mariners": 24,
|
||||||
'Tampa Bay Rays': 27,
|
"Tampa Bay Rays": 27,
|
||||||
'Texas Rangers': 28,
|
"Texas Rangers": 28,
|
||||||
'Toronto Blue Jays': 29
|
"Toronto Blue Jays": 29,
|
||||||
}
|
}
|
||||||
|
|
||||||
NL_TEAM_IDS = {
|
NL_TEAM_IDS = {
|
||||||
'Arizona Diamondbacks': 1,
|
"Arizona Diamondbacks": 1,
|
||||||
'Atlanta Braves': 2,
|
"Atlanta Braves": 2,
|
||||||
'Chicago Cubs': 5,
|
"Chicago Cubs": 5,
|
||||||
'Cincinnati Reds': 7,
|
"Cincinnati Reds": 7,
|
||||||
'Colorado Rockies': 9,
|
"Colorado Rockies": 9,
|
||||||
'Los Angeles Dodgers': 14,
|
"Los Angeles Dodgers": 14,
|
||||||
'Miami Marlins': 15,
|
"Miami Marlins": 15,
|
||||||
'Milwaukee Brewers': 16,
|
"Milwaukee Brewers": 16,
|
||||||
'New York Mets': 18,
|
"New York Mets": 18,
|
||||||
'Philadelphia Phillies': 21,
|
"Philadelphia Phillies": 21,
|
||||||
'Pittsburgh Pirates': 22,
|
"Pittsburgh Pirates": 22,
|
||||||
'San Diego Padres': 23,
|
"San Diego Padres": 23,
|
||||||
'San Francisco Giants': 25,
|
"San Francisco Giants": 25,
|
||||||
'St Louis Cardinals': 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals'
|
"St Louis Cardinals": 26, # Note: constants has 'St Louis Cardinals' not 'St. Louis Cardinals'
|
||||||
'Washington Nationals': 30
|
"Washington Nationals": 30,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get AL teams from constants
|
# Get AL teams from constants
|
||||||
AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS]
|
AL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in AL_TEAM_IDS]
|
||||||
NL_TEAMS = [team for team in ALL_MLB_TEAMS.keys() if team in NL_TEAM_IDS or team == 'St Louis Cardinals']
|
NL_TEAMS = [
|
||||||
|
team
|
||||||
|
for team in ALL_MLB_TEAMS.keys()
|
||||||
|
if team in NL_TEAM_IDS or team == "St Louis Cardinals"
|
||||||
|
]
|
||||||
|
|
||||||
# Cardset mappings
|
# Cardset mappings
|
||||||
CARDSET_LABELS_TO_IDS = {
|
CARDSET_LABELS_TO_IDS = {
|
||||||
'2022 Season': 3,
|
"2022 Season": 3,
|
||||||
'2022 Promos': 4,
|
"2022 Promos": 4,
|
||||||
'2021 Season': 1,
|
"2021 Season": 1,
|
||||||
'2019 Season': 5,
|
"2019 Season": 5,
|
||||||
'2013 Season': 6,
|
"2013 Season": 6,
|
||||||
'2012 Season': 7,
|
"2012 Season": 7,
|
||||||
'Mario Super Sluggers': 8,
|
"Mario Super Sluggers": 8,
|
||||||
'2023 Season': 9,
|
"2023 Season": 9,
|
||||||
'2016 Season': 11,
|
"2016 Season": 11,
|
||||||
'2008 Season': 12,
|
"2008 Season": 12,
|
||||||
'2018 Season': 13,
|
"2018 Season": 13,
|
||||||
'2024 Season': 17,
|
"2024 Season": 17,
|
||||||
'2024 Promos': 18,
|
"2024 Promos": 18,
|
||||||
'1998 Season': 20,
|
"1998 Season": 20,
|
||||||
'2025 Season': 24,
|
"2025 Season": 24,
|
||||||
'2005 Live': 27,
|
"2005 Live": 27,
|
||||||
'Pokemon - Brilliant Stars': 23
|
"Pokemon - Brilliant Stars": 23,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_team_id(team_name: str, league: Literal['AL', 'NL']) -> int:
|
def _get_team_id(team_name: str, league: Literal["AL", "NL"]) -> int:
|
||||||
"""Get team ID from team name and league."""
|
"""Get team ID from team name and league."""
|
||||||
if league == 'AL':
|
if league == "AL":
|
||||||
return AL_TEAM_IDS.get(team_name)
|
return AL_TEAM_IDS.get(team_name)
|
||||||
else:
|
else:
|
||||||
# Handle the St. Louis Cardinals special case
|
# Handle the St. Louis Cardinals special case
|
||||||
if team_name == 'St. Louis Cardinals':
|
if team_name == "St. Louis Cardinals":
|
||||||
return NL_TEAM_IDS.get('St Louis Cardinals')
|
return NL_TEAM_IDS.get("St Louis Cardinals")
|
||||||
return NL_TEAM_IDS.get(team_name)
|
return NL_TEAM_IDS.get(team_name)
|
||||||
|
|
||||||
|
|
||||||
class SelectChoicePackTeam(discord.ui.Select):
|
class SelectChoicePackTeam(discord.ui.Select):
|
||||||
def __init__(self, which: Literal['AL', 'NL'], team, cardset_id: Optional[int] = None):
|
def __init__(
|
||||||
|
self, which: Literal["AL", "NL"], team, cardset_id: Optional[int] = None
|
||||||
|
):
|
||||||
self.which = which
|
self.which = which
|
||||||
self.owner_team = team
|
self.owner_team = team
|
||||||
self.cardset_id = cardset_id
|
self.cardset_id = cardset_id
|
||||||
|
|
||||||
if which == 'AL':
|
if which == "AL":
|
||||||
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
||||||
else:
|
else:
|
||||||
# Handle St. Louis Cardinals display name
|
# Handle St. Louis Cardinals display name
|
||||||
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
|
options = [
|
||||||
for team in NL_TEAMS]
|
discord.SelectOption(
|
||||||
|
label="St. Louis Cardinals"
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(placeholder=f"Select an {which} team", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_get, db_patch
|
from api_calls import db_get, db_patch
|
||||||
from helpers import open_choice_pack
|
from helpers import open_choice_pack
|
||||||
|
|
||||||
team_id = _get_team_id(self.values[0], self.which)
|
team_id = _get_team_id(self.values[0], self.which)
|
||||||
if team_id is None:
|
if team_id is None:
|
||||||
raise ValueError(f'Unknown team: {self.values[0]}')
|
raise ValueError(f"Unknown team: {self.values[0]}")
|
||||||
|
|
||||||
await interaction.response.edit_message(content=f'You selected the **{self.values[0]}**', view=None)
|
await interaction.response.edit_message(
|
||||||
|
content=f"You selected the **{self.values[0]}**", view=None
|
||||||
|
)
|
||||||
# Get the selected packs
|
# Get the selected packs
|
||||||
params = [
|
params = [
|
||||||
('pack_type_id', 8), ('team_id', self.owner_team['id']), ('opened', False), ('limit', 1),
|
("pack_type_id", 8),
|
||||||
('exact_match', True)
|
("team_id", self.owner_team["id"]),
|
||||||
|
("opened", False),
|
||||||
|
("limit", 1),
|
||||||
|
("exact_match", True),
|
||||||
]
|
]
|
||||||
if self.cardset_id is not None:
|
if self.cardset_id is not None:
|
||||||
params.append(('pack_cardset_id', self.cardset_id))
|
params.append(("pack_cardset_id", self.cardset_id))
|
||||||
p_query = await db_get('packs', params=params)
|
p_query = await db_get("packs", params=params)
|
||||||
if p_query['count'] == 0:
|
if p_query["count"] == 0:
|
||||||
logger.error(f'open-packs - no packs found with params: {params}')
|
logger.error(f"open-packs - no packs found with params: {params}")
|
||||||
raise ValueError(f'Unable to open packs')
|
raise ValueError("Unable to open packs")
|
||||||
|
|
||||||
this_pack = await db_patch('packs', object_id=p_query['packs'][0]['id'], params=[('pack_team_id', team_id)])
|
this_pack = await db_patch(
|
||||||
|
"packs",
|
||||||
|
object_id=p_query["packs"][0]["id"],
|
||||||
|
params=[("pack_team_id", team_id)],
|
||||||
|
)
|
||||||
|
|
||||||
await open_choice_pack(this_pack, self.owner_team, interaction, self.cardset_id)
|
await open_choice_pack(this_pack, self.owner_team, interaction, self.cardset_id)
|
||||||
|
|
||||||
@ -130,104 +152,124 @@ class SelectChoicePackTeam(discord.ui.Select):
|
|||||||
class SelectOpenPack(discord.ui.Select):
|
class SelectOpenPack(discord.ui.Select):
|
||||||
def __init__(self, options: list, team: dict):
|
def __init__(self, options: list, team: dict):
|
||||||
self.owner_team = team
|
self.owner_team = team
|
||||||
super().__init__(placeholder='Select a Pack Type', options=options)
|
super().__init__(placeholder="Select a Pack Type", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_get
|
from api_calls import db_get
|
||||||
from helpers import open_st_pr_packs, open_choice_pack
|
from helpers import open_st_pr_packs, open_choice_pack
|
||||||
|
|
||||||
logger.info(f'SelectPackChoice - selection: {self.values[0]}')
|
logger.info(f"SelectPackChoice - selection: {self.values[0]}")
|
||||||
pack_vals = self.values[0].split('-')
|
pack_vals = self.values[0].split("-")
|
||||||
logger.info(f'pack_vals: {pack_vals}')
|
logger.info(f"pack_vals: {pack_vals}")
|
||||||
|
|
||||||
# Get the selected packs
|
# Get the selected packs
|
||||||
params = [('team_id', self.owner_team['id']), ('opened', False), ('limit', 5), ('exact_match', True)]
|
params = [
|
||||||
|
("team_id", self.owner_team["id"]),
|
||||||
|
("opened", False),
|
||||||
|
("limit", 5),
|
||||||
|
("exact_match", True),
|
||||||
|
]
|
||||||
|
|
||||||
open_type = 'standard'
|
open_type = "standard"
|
||||||
if 'Standard' in pack_vals:
|
if "Standard" in pack_vals:
|
||||||
open_type = 'standard'
|
open_type = "standard"
|
||||||
params.append(('pack_type_id', 1))
|
params.append(("pack_type_id", 1))
|
||||||
elif 'Premium' in pack_vals:
|
elif "Premium" in pack_vals:
|
||||||
open_type = 'standard'
|
open_type = "standard"
|
||||||
params.append(('pack_type_id', 3))
|
params.append(("pack_type_id", 3))
|
||||||
elif 'Daily' in pack_vals:
|
elif "Daily" in pack_vals:
|
||||||
params.append(('pack_type_id', 4))
|
params.append(("pack_type_id", 4))
|
||||||
elif 'Promo Choice' in pack_vals:
|
elif "Promo Choice" in pack_vals:
|
||||||
open_type = 'choice'
|
open_type = "choice"
|
||||||
params.append(('pack_type_id', 9))
|
params.append(("pack_type_id", 9))
|
||||||
elif 'MVP' in pack_vals:
|
elif "MVP" in pack_vals:
|
||||||
open_type = 'choice'
|
open_type = "choice"
|
||||||
params.append(('pack_type_id', 5))
|
params.append(("pack_type_id", 5))
|
||||||
elif 'All Star' in pack_vals:
|
elif "All Star" in pack_vals:
|
||||||
open_type = 'choice'
|
open_type = "choice"
|
||||||
params.append(('pack_type_id', 6))
|
params.append(("pack_type_id", 6))
|
||||||
elif 'Mario' in pack_vals:
|
elif "Mario" in pack_vals:
|
||||||
open_type = 'choice'
|
open_type = "choice"
|
||||||
params.append(('pack_type_id', 7))
|
params.append(("pack_type_id", 7))
|
||||||
elif 'Team Choice' in pack_vals:
|
elif "Team Choice" in pack_vals:
|
||||||
open_type = 'choice'
|
open_type = "choice"
|
||||||
params.append(('pack_type_id', 8))
|
params.append(("pack_type_id", 8))
|
||||||
else:
|
else:
|
||||||
raise KeyError(f'Cannot identify pack details: {pack_vals}')
|
logger.error(
|
||||||
|
f"Unrecognized pack type in selector: {self.values[0]} (split: {pack_vals})"
|
||||||
|
)
|
||||||
|
await interaction.response.edit_message(view=None)
|
||||||
|
await interaction.followup.send(
|
||||||
|
content="This pack type cannot be opened manually. Please contact Cal.",
|
||||||
|
ephemeral=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# If team isn't already set on team choice pack, make team pack selection now
|
# If team isn't already set on team choice pack, make team pack selection now
|
||||||
await interaction.response.edit_message(view=None)
|
await interaction.response.edit_message(view=None)
|
||||||
|
|
||||||
cardset_id = None
|
cardset_id = None
|
||||||
# Handle Team Choice packs with no team/cardset assigned
|
# Handle Team Choice packs with no team/cardset assigned
|
||||||
if 'Team Choice' in pack_vals and 'Team' not in pack_vals and 'Cardset' not in pack_vals:
|
if (
|
||||||
|
"Team Choice" in pack_vals
|
||||||
|
and "Team" not in pack_vals
|
||||||
|
and "Cardset" not in pack_vals
|
||||||
|
):
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
content='This Team Choice pack needs to be assigned a team and cardset. '
|
content="This Team Choice pack needs to be assigned a team and cardset. "
|
||||||
'Please contact an admin to configure this pack.',
|
"Please contact Cal to configure this pack.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
elif 'Team Choice' in pack_vals and 'Cardset' in pack_vals:
|
elif "Team Choice" in pack_vals and "Cardset" in pack_vals:
|
||||||
# cardset_id = pack_vals[2]
|
# cardset_id = pack_vals[2]
|
||||||
cardset_index = pack_vals.index('Cardset')
|
cardset_index = pack_vals.index("Cardset")
|
||||||
cardset_id = pack_vals[cardset_index + 1]
|
cardset_id = pack_vals[cardset_index + 1]
|
||||||
params.append(('pack_cardset_id', cardset_id))
|
params.append(("pack_cardset_id", cardset_id))
|
||||||
if 'Team' not in pack_vals:
|
if "Team" not in pack_vals:
|
||||||
view = SelectView(
|
view = SelectView(
|
||||||
[SelectChoicePackTeam('AL', self.owner_team, cardset_id),
|
[
|
||||||
SelectChoicePackTeam('NL', self.owner_team, cardset_id)],
|
SelectChoicePackTeam("AL", self.owner_team, cardset_id),
|
||||||
timeout=30
|
SelectChoicePackTeam("NL", self.owner_team, cardset_id),
|
||||||
|
],
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
content='Please select a team for your Team Choice pack:',
|
content="Please select a team for your Team Choice pack:", view=view
|
||||||
view=view
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
|
|
||||||
else:
|
|
||||||
if 'Team' in pack_vals:
|
|
||||||
params.append(('pack_team_id', pack_vals[pack_vals.index('Team') + 1]))
|
|
||||||
if 'Cardset' in pack_vals:
|
|
||||||
cardset_id = pack_vals[pack_vals.index('Cardset') + 1]
|
|
||||||
params.append(('pack_cardset_id', cardset_id))
|
|
||||||
|
|
||||||
p_query = await db_get('packs', params=params)
|
params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
|
||||||
if p_query['count'] == 0:
|
else:
|
||||||
logger.error(f'open-packs - no packs found with params: {params}')
|
if "Team" in pack_vals:
|
||||||
|
params.append(("pack_team_id", pack_vals[pack_vals.index("Team") + 1]))
|
||||||
|
if "Cardset" in pack_vals:
|
||||||
|
cardset_id = pack_vals[pack_vals.index("Cardset") + 1]
|
||||||
|
params.append(("pack_cardset_id", cardset_id))
|
||||||
|
|
||||||
|
p_query = await db_get("packs", params=params)
|
||||||
|
if p_query["count"] == 0:
|
||||||
|
logger.error(f"open-packs - no packs found with params: {params}")
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
content='Unable to find the selected pack. Please contact an admin.',
|
content="Unable to find the selected pack. Please contact Cal.",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Open the packs
|
# Open the packs
|
||||||
try:
|
try:
|
||||||
if open_type == 'standard':
|
if open_type == "standard":
|
||||||
await open_st_pr_packs(p_query['packs'], self.owner_team, interaction)
|
await open_st_pr_packs(p_query["packs"], self.owner_team, interaction)
|
||||||
elif open_type == 'choice':
|
elif open_type == "choice":
|
||||||
await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id)
|
await open_choice_pack(
|
||||||
|
p_query["packs"][0], self.owner_team, interaction, cardset_id
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f'Failed to open pack: {e}')
|
logger.error(f"Failed to open pack: {e}")
|
||||||
await interaction.followup.send(
|
await interaction.followup.send(
|
||||||
content=f'Failed to open pack. Please contact an admin. Error: {str(e)}',
|
content=f"Failed to open pack. Please contact Cal. Error: {str(e)}",
|
||||||
ephemeral=True
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -235,275 +277,317 @@ class SelectOpenPack(discord.ui.Select):
|
|||||||
class SelectPaperdexCardset(discord.ui.Select):
|
class SelectPaperdexCardset(discord.ui.Select):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
options = [
|
options = [
|
||||||
discord.SelectOption(label='2005 Live'),
|
discord.SelectOption(label="2005 Live"),
|
||||||
discord.SelectOption(label='2025 Season'),
|
discord.SelectOption(label="2025 Season"),
|
||||||
discord.SelectOption(label='1998 Season'),
|
discord.SelectOption(label="1998 Season"),
|
||||||
discord.SelectOption(label='2024 Season'),
|
discord.SelectOption(label="2024 Season"),
|
||||||
discord.SelectOption(label='2023 Season'),
|
discord.SelectOption(label="2023 Season"),
|
||||||
discord.SelectOption(label='2022 Season'),
|
discord.SelectOption(label="2022 Season"),
|
||||||
discord.SelectOption(label='2022 Promos'),
|
discord.SelectOption(label="2022 Promos"),
|
||||||
discord.SelectOption(label='2021 Season'),
|
discord.SelectOption(label="2021 Season"),
|
||||||
discord.SelectOption(label='2019 Season'),
|
discord.SelectOption(label="2019 Season"),
|
||||||
discord.SelectOption(label='2018 Season'),
|
discord.SelectOption(label="2018 Season"),
|
||||||
discord.SelectOption(label='2016 Season'),
|
discord.SelectOption(label="2016 Season"),
|
||||||
discord.SelectOption(label='2013 Season'),
|
discord.SelectOption(label="2013 Season"),
|
||||||
discord.SelectOption(label='2012 Season'),
|
discord.SelectOption(label="2012 Season"),
|
||||||
discord.SelectOption(label='2008 Season'),
|
discord.SelectOption(label="2008 Season"),
|
||||||
discord.SelectOption(label='Mario Super Sluggers')
|
discord.SelectOption(label="Mario Super Sluggers"),
|
||||||
]
|
]
|
||||||
super().__init__(placeholder='Select a Cardset', options=options)
|
super().__init__(placeholder="Select a Cardset", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_get
|
from api_calls import db_get
|
||||||
from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination
|
from helpers import get_team_by_owner, paperdex_cardset_embed, embed_pagination
|
||||||
|
|
||||||
logger.info(f'SelectPaperdexCardset - selection: {self.values[0]}')
|
logger.info(f"SelectPaperdexCardset - selection: {self.values[0]}")
|
||||||
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
|
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
|
||||||
if cardset_id is None:
|
if cardset_id is None:
|
||||||
raise ValueError(f'Unknown cardset: {self.values[0]}')
|
raise ValueError(f"Unknown cardset: {self.values[0]}")
|
||||||
|
|
||||||
c_query = await db_get('cardsets', object_id=cardset_id, none_okay=False)
|
c_query = await db_get("cardsets", object_id=cardset_id, none_okay=False)
|
||||||
await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None)
|
await interaction.response.edit_message(
|
||||||
|
content="Okay, sifting through your cards...", view=None
|
||||||
|
)
|
||||||
|
|
||||||
cardset_embeds = await paperdex_cardset_embed(
|
cardset_embeds = await paperdex_cardset_embed(
|
||||||
team=await get_team_by_owner(interaction.user.id),
|
team=await get_team_by_owner(interaction.user.id), this_cardset=c_query
|
||||||
this_cardset=c_query
|
|
||||||
)
|
)
|
||||||
await embed_pagination(cardset_embeds, interaction.channel, interaction.user)
|
await embed_pagination(cardset_embeds, interaction.channel, interaction.user)
|
||||||
|
|
||||||
|
|
||||||
class SelectPaperdexTeam(discord.ui.Select):
|
class SelectPaperdexTeam(discord.ui.Select):
|
||||||
def __init__(self, which: Literal['AL', 'NL']):
|
def __init__(self, which: Literal["AL", "NL"]):
|
||||||
self.which = which
|
self.which = which
|
||||||
|
|
||||||
if which == 'AL':
|
if which == "AL":
|
||||||
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
||||||
else:
|
else:
|
||||||
# Handle St. Louis Cardinals display name
|
# Handle St. Louis Cardinals display name
|
||||||
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
|
options = [
|
||||||
for team in NL_TEAMS]
|
discord.SelectOption(
|
||||||
|
label="St. Louis Cardinals"
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(placeholder=f"Select an {which} team", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_get
|
from api_calls import db_get
|
||||||
from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination
|
from helpers import get_team_by_owner, paperdex_team_embed, embed_pagination
|
||||||
|
|
||||||
team_id = _get_team_id(self.values[0], self.which)
|
team_id = _get_team_id(self.values[0], self.which)
|
||||||
if team_id is None:
|
if team_id is None:
|
||||||
raise ValueError(f'Unknown team: {self.values[0]}')
|
raise ValueError(f"Unknown team: {self.values[0]}")
|
||||||
|
|
||||||
t_query = await db_get('teams', object_id=team_id, none_okay=False)
|
t_query = await db_get("teams", object_id=team_id, none_okay=False)
|
||||||
await interaction.response.edit_message(content=f'Okay, sifting through your cards...', view=None)
|
await interaction.response.edit_message(
|
||||||
|
content="Okay, sifting through your cards...", view=None
|
||||||
|
)
|
||||||
|
|
||||||
team_embeds = await paperdex_team_embed(team=await get_team_by_owner(interaction.user.id), mlb_team=t_query)
|
team_embeds = await paperdex_team_embed(
|
||||||
|
team=await get_team_by_owner(interaction.user.id), mlb_team=t_query
|
||||||
|
)
|
||||||
await embed_pagination(team_embeds, interaction.channel, interaction.user)
|
await embed_pagination(team_embeds, interaction.channel, interaction.user)
|
||||||
|
|
||||||
|
|
||||||
class SelectBuyPacksCardset(discord.ui.Select):
|
class SelectBuyPacksCardset(discord.ui.Select):
|
||||||
def __init__(self, team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed, cost: int):
|
def __init__(
|
||||||
|
self,
|
||||||
|
team: dict,
|
||||||
|
quantity: int,
|
||||||
|
pack_type_id: int,
|
||||||
|
pack_embed: discord.Embed,
|
||||||
|
cost: int,
|
||||||
|
):
|
||||||
options = [
|
options = [
|
||||||
discord.SelectOption(label='2005 Live'),
|
discord.SelectOption(label="2005 Live"),
|
||||||
discord.SelectOption(label='2025 Season'),
|
discord.SelectOption(label="2025 Season"),
|
||||||
discord.SelectOption(label='1998 Season'),
|
discord.SelectOption(label="1998 Season"),
|
||||||
discord.SelectOption(label='Pokemon - Brilliant Stars'),
|
discord.SelectOption(label="Pokemon - Brilliant Stars"),
|
||||||
discord.SelectOption(label='2024 Season'),
|
discord.SelectOption(label="2024 Season"),
|
||||||
discord.SelectOption(label='2023 Season'),
|
discord.SelectOption(label="2023 Season"),
|
||||||
discord.SelectOption(label='2022 Season'),
|
discord.SelectOption(label="2022 Season"),
|
||||||
discord.SelectOption(label='2021 Season'),
|
discord.SelectOption(label="2021 Season"),
|
||||||
discord.SelectOption(label='2019 Season'),
|
discord.SelectOption(label="2019 Season"),
|
||||||
discord.SelectOption(label='2018 Season'),
|
discord.SelectOption(label="2018 Season"),
|
||||||
discord.SelectOption(label='2016 Season'),
|
discord.SelectOption(label="2016 Season"),
|
||||||
discord.SelectOption(label='2013 Season'),
|
discord.SelectOption(label="2013 Season"),
|
||||||
discord.SelectOption(label='2012 Season'),
|
discord.SelectOption(label="2012 Season"),
|
||||||
discord.SelectOption(label='2008 Season')
|
discord.SelectOption(label="2008 Season"),
|
||||||
]
|
]
|
||||||
self.team = team
|
self.team = team
|
||||||
self.quantity = quantity
|
self.quantity = quantity
|
||||||
self.pack_type_id = pack_type_id
|
self.pack_type_id = pack_type_id
|
||||||
self.pack_embed = pack_embed
|
self.pack_embed = pack_embed
|
||||||
self.cost = cost
|
self.cost = cost
|
||||||
super().__init__(placeholder='Select a Cardset', options=options)
|
super().__init__(placeholder="Select a Cardset", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_post
|
from api_calls import db_post
|
||||||
from discord_ui.confirmations import Confirm
|
from discord_ui.confirmations import Confirm
|
||||||
|
|
||||||
logger.info(f'SelectBuyPacksCardset - selection: {self.values[0]}')
|
logger.info(f"SelectBuyPacksCardset - selection: {self.values[0]}")
|
||||||
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
|
cardset_id = CARDSET_LABELS_TO_IDS.get(self.values[0])
|
||||||
if cardset_id is None:
|
if cardset_id is None:
|
||||||
raise ValueError(f'Unknown cardset: {self.values[0]}')
|
raise ValueError(f"Unknown cardset: {self.values[0]}")
|
||||||
|
|
||||||
if self.values[0] == 'Pokemon - Brilliant Stars':
|
|
||||||
self.pack_embed.set_image(url=IMAGES['pack-pkmnbs'])
|
|
||||||
|
|
||||||
self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}'
|
if self.values[0] == "Pokemon - Brilliant Stars":
|
||||||
|
self.pack_embed.set_image(url=IMAGES["pack-pkmnbs"])
|
||||||
|
|
||||||
|
self.pack_embed.description = (
|
||||||
|
f"{self.pack_embed.description} - {self.values[0]}"
|
||||||
|
)
|
||||||
view = Confirm(responders=[interaction.user], timeout=30)
|
view = Confirm(responders=[interaction.user], timeout=30)
|
||||||
await interaction.response.edit_message(
|
await interaction.response.edit_message(
|
||||||
content=None,
|
content=None, embed=self.pack_embed, view=None
|
||||||
embed=self.pack_embed,
|
|
||||||
view=None
|
|
||||||
)
|
)
|
||||||
question = await interaction.channel.send(
|
question = await interaction.channel.send(
|
||||||
content=f'Your Wallet: {self.team["wallet"]}₼\n'
|
content=f"Your Wallet: {self.team['wallet']}₼\n"
|
||||||
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}₼\n'
|
f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}₼\n"
|
||||||
f'After Purchase: {self.team["wallet"] - self.cost}₼\n\n'
|
f"After Purchase: {self.team['wallet'] - self.cost}₼\n\n"
|
||||||
f'Would you like to make this purchase?',
|
f"Would you like to make this purchase?",
|
||||||
view=view
|
view=view,
|
||||||
)
|
)
|
||||||
await view.wait()
|
await view.wait()
|
||||||
|
|
||||||
if not view.value:
|
if not view.value:
|
||||||
await question.edit(
|
await question.edit(content="Saving that money. Smart.", view=None)
|
||||||
content='Saving that money. Smart.',
|
|
||||||
view=None
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
p_model = {
|
p_model = {
|
||||||
'team_id': self.team['id'],
|
"team_id": self.team["id"],
|
||||||
'pack_type_id': self.pack_type_id,
|
"pack_type_id": self.pack_type_id,
|
||||||
'pack_cardset_id': cardset_id
|
"pack_cardset_id": cardset_id,
|
||||||
}
|
}
|
||||||
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]})
|
await db_post(
|
||||||
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}')
|
"packs", payload={"packs": [p_model for x in range(self.quantity)]}
|
||||||
|
)
|
||||||
|
await db_post(f"teams/{self.team['id']}/money/-{self.cost}")
|
||||||
|
|
||||||
await question.edit(
|
await question.edit(
|
||||||
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`',
|
content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`",
|
||||||
view=None
|
view=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SelectBuyPacksTeam(discord.ui.Select):
|
class SelectBuyPacksTeam(discord.ui.Select):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, which: Literal['AL', 'NL'], team: dict, quantity: int, pack_type_id: int, pack_embed: discord.Embed,
|
self,
|
||||||
cost: int):
|
which: Literal["AL", "NL"],
|
||||||
|
team: dict,
|
||||||
|
quantity: int,
|
||||||
|
pack_type_id: int,
|
||||||
|
pack_embed: discord.Embed,
|
||||||
|
cost: int,
|
||||||
|
):
|
||||||
self.which = which
|
self.which = which
|
||||||
self.team = team
|
self.team = team
|
||||||
self.quantity = quantity
|
self.quantity = quantity
|
||||||
self.pack_type_id = pack_type_id
|
self.pack_type_id = pack_type_id
|
||||||
self.pack_embed = pack_embed
|
self.pack_embed = pack_embed
|
||||||
self.cost = cost
|
self.cost = cost
|
||||||
|
|
||||||
if which == 'AL':
|
if which == "AL":
|
||||||
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
||||||
else:
|
else:
|
||||||
# Handle St. Louis Cardinals display name
|
# Handle St. Louis Cardinals display name
|
||||||
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
|
options = [
|
||||||
for team in NL_TEAMS]
|
discord.SelectOption(
|
||||||
|
label="St. Louis Cardinals"
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(placeholder=f"Select an {which} team", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_post
|
from api_calls import db_post
|
||||||
from discord_ui.confirmations import Confirm
|
from discord_ui.confirmations import Confirm
|
||||||
|
|
||||||
team_id = _get_team_id(self.values[0], self.which)
|
team_id = _get_team_id(self.values[0], self.which)
|
||||||
if team_id is None:
|
if team_id is None:
|
||||||
raise ValueError(f'Unknown team: {self.values[0]}')
|
raise ValueError(f"Unknown team: {self.values[0]}")
|
||||||
|
|
||||||
self.pack_embed.description = f'{self.pack_embed.description} - {self.values[0]}'
|
self.pack_embed.description = (
|
||||||
|
f"{self.pack_embed.description} - {self.values[0]}"
|
||||||
|
)
|
||||||
view = Confirm(responders=[interaction.user], timeout=30)
|
view = Confirm(responders=[interaction.user], timeout=30)
|
||||||
await interaction.response.edit_message(
|
await interaction.response.edit_message(
|
||||||
content=None,
|
content=None, embed=self.pack_embed, view=None
|
||||||
embed=self.pack_embed,
|
|
||||||
view=None
|
|
||||||
)
|
)
|
||||||
question = await interaction.channel.send(
|
question = await interaction.channel.send(
|
||||||
content=f'Your Wallet: {self.team["wallet"]}₼\n'
|
content=f"Your Wallet: {self.team['wallet']}₼\n"
|
||||||
f'Pack{"s" if self.quantity > 1 else ""} Price: {self.cost}₼\n'
|
f"Pack{'s' if self.quantity > 1 else ''} Price: {self.cost}₼\n"
|
||||||
f'After Purchase: {self.team["wallet"] - self.cost}₼\n\n'
|
f"After Purchase: {self.team['wallet'] - self.cost}₼\n\n"
|
||||||
f'Would you like to make this purchase?',
|
f"Would you like to make this purchase?",
|
||||||
view=view
|
view=view,
|
||||||
)
|
)
|
||||||
await view.wait()
|
await view.wait()
|
||||||
|
|
||||||
if not view.value:
|
if not view.value:
|
||||||
await question.edit(
|
await question.edit(content="Saving that money. Smart.", view=None)
|
||||||
content='Saving that money. Smart.',
|
|
||||||
view=None
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
p_model = {
|
p_model = {
|
||||||
'team_id': self.team['id'],
|
"team_id": self.team["id"],
|
||||||
'pack_type_id': self.pack_type_id,
|
"pack_type_id": self.pack_type_id,
|
||||||
'pack_team_id': team_id
|
"pack_team_id": team_id,
|
||||||
}
|
}
|
||||||
await db_post('packs', payload={'packs': [p_model for x in range(self.quantity)]})
|
await db_post(
|
||||||
await db_post(f'teams/{self.team["id"]}/money/-{self.cost}')
|
"packs", payload={"packs": [p_model for x in range(self.quantity)]}
|
||||||
|
)
|
||||||
|
await db_post(f"teams/{self.team['id']}/money/-{self.cost}")
|
||||||
|
|
||||||
await question.edit(
|
await question.edit(
|
||||||
content=f'{"They are" if self.quantity > 1 else "It is"} all yours! Go rip \'em with `/open-packs`',
|
content=f"{'They are' if self.quantity > 1 else 'It is'} all yours! Go rip 'em with `/open-packs`",
|
||||||
view=None
|
view=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SelectUpdatePlayerTeam(discord.ui.Select):
|
class SelectUpdatePlayerTeam(discord.ui.Select):
|
||||||
def __init__(self, which: Literal['AL', 'NL'], player: dict, reporting_team: dict, bot):
|
def __init__(
|
||||||
|
self, which: Literal["AL", "NL"], player: dict, reporting_team: dict, bot
|
||||||
|
):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self.which = which
|
self.which = which
|
||||||
self.player = player
|
self.player = player
|
||||||
self.reporting_team = reporting_team
|
self.reporting_team = reporting_team
|
||||||
|
|
||||||
if which == 'AL':
|
if which == "AL":
|
||||||
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
options = [discord.SelectOption(label=team) for team in AL_TEAMS]
|
||||||
else:
|
else:
|
||||||
# Handle St. Louis Cardinals display name
|
# Handle St. Louis Cardinals display name
|
||||||
options = [discord.SelectOption(label='St. Louis Cardinals' if team == 'St Louis Cardinals' else team)
|
options = [
|
||||||
for team in NL_TEAMS]
|
discord.SelectOption(
|
||||||
|
label="St. Louis Cardinals"
|
||||||
super().__init__(placeholder=f'Select an {which} team', options=options)
|
if team == "St Louis Cardinals"
|
||||||
|
else team
|
||||||
|
)
|
||||||
|
for team in NL_TEAMS
|
||||||
|
]
|
||||||
|
|
||||||
|
super().__init__(placeholder=f"Select an {which} team", options=options)
|
||||||
|
|
||||||
async def callback(self, interaction: discord.Interaction):
|
async def callback(self, interaction: discord.Interaction):
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from api_calls import db_patch, db_post
|
from api_calls import db_patch, db_post
|
||||||
from discord_ui.confirmations import Confirm
|
from discord_ui.confirmations import Confirm
|
||||||
from helpers import player_desc, send_to_channel
|
from helpers import player_desc, send_to_channel
|
||||||
|
|
||||||
# Check if already assigned - compare against both normalized franchise and full mlbclub
|
# Check if already assigned - compare against both normalized franchise and full mlbclub
|
||||||
normalized_selection = normalize_franchise(self.values[0])
|
normalized_selection = normalize_franchise(self.values[0])
|
||||||
if normalized_selection == self.player['franchise'] or self.values[0] == self.player['mlbclub']:
|
if (
|
||||||
|
normalized_selection == self.player["franchise"]
|
||||||
|
or self.values[0] == self.player["mlbclub"]
|
||||||
|
):
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
content=f'Thank you for the help, but it looks like somebody beat you to it! '
|
content=f"Thank you for the help, but it looks like somebody beat you to it! "
|
||||||
f'**{player_desc(self.player)}** is already assigned to the **{self.player["mlbclub"]}**.'
|
f"**{player_desc(self.player)}** is already assigned to the **{self.player['mlbclub']}**."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
view = Confirm(responders=[interaction.user], timeout=15)
|
view = Confirm(responders=[interaction.user], timeout=15)
|
||||||
await interaction.response.edit_message(
|
await interaction.response.edit_message(
|
||||||
content=f'Should I update **{player_desc(self.player)}**\'s team to the **{self.values[0]}**?',
|
content=f"Should I update **{player_desc(self.player)}**'s team to the **{self.values[0]}**?",
|
||||||
view=None
|
view=None,
|
||||||
)
|
|
||||||
question = await interaction.channel.send(
|
|
||||||
content=None,
|
|
||||||
view=view
|
|
||||||
)
|
)
|
||||||
|
question = await interaction.channel.send(content=None, view=view)
|
||||||
await view.wait()
|
await view.wait()
|
||||||
|
|
||||||
if not view.value:
|
if not view.value:
|
||||||
await question.edit(
|
await question.edit(
|
||||||
content='That didnt\'t sound right to me, either. Let\'s not touch that.',
|
content="That didnt't sound right to me, either. Let's not touch that.",
|
||||||
view=None
|
view=None,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
await question.delete()
|
await question.delete()
|
||||||
|
|
||||||
await db_patch('players', object_id=self.player['player_id'], params=[
|
await db_patch(
|
||||||
('mlbclub', self.values[0]), ('franchise', normalize_franchise(self.values[0]))
|
"players",
|
||||||
])
|
object_id=self.player["player_id"],
|
||||||
await db_post(f'teams/{self.reporting_team["id"]}/money/25')
|
params=[
|
||||||
await send_to_channel(
|
("mlbclub", self.values[0]),
|
||||||
self.bot, 'pd-news-ticker',
|
("franchise", normalize_franchise(self.values[0])),
|
||||||
content=f'{interaction.user.name} just updated **{player_desc(self.player)}**\'s team to the '
|
],
|
||||||
f'**{self.values[0]}**'
|
|
||||||
)
|
)
|
||||||
await interaction.channel.send(f'All done!')
|
await db_post(f"teams/{self.reporting_team['id']}/money/25")
|
||||||
|
await send_to_channel(
|
||||||
|
self.bot,
|
||||||
|
"pd-news-ticker",
|
||||||
|
content=f"{interaction.user.name} just updated **{player_desc(self.player)}**'s team to the "
|
||||||
|
f"**{self.values[0]}**",
|
||||||
|
)
|
||||||
|
await interaction.channel.send("All done!")
|
||||||
|
|
||||||
|
|
||||||
class SelectView(discord.ui.View):
|
class SelectView(discord.ui.View):
|
||||||
@ -511,4 +595,4 @@ class SelectView(discord.ui.View):
|
|||||||
super().__init__(timeout=timeout)
|
super().__init__(timeout=timeout)
|
||||||
|
|
||||||
for x in select_objects:
|
for x in select_objects:
|
||||||
self.add_item(x)
|
self.add_item(x)
|
||||||
|
|||||||
252
discord_utils.py
252
discord_utils.py
@ -1,252 +0,0 @@
|
|||||||
"""
|
|
||||||
Discord Utilities
|
|
||||||
|
|
||||||
This module contains Discord helper functions for channels, roles, embeds,
|
|
||||||
and other Discord-specific operations.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import asyncio
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
from helpers.constants import SBA_COLOR, PD_SEASON, IMAGES
|
|
||||||
|
|
||||||
logger = logging.getLogger('discord_app')
|
|
||||||
|
|
||||||
|
|
||||||
async def send_to_bothole(ctx, content, embed):
|
|
||||||
"""Send a message to the pd-bot-hole channel."""
|
|
||||||
await discord.utils.get(ctx.guild.text_channels, name='pd-bot-hole') \
|
|
||||||
.send(content=content, embed=embed)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_to_news(ctx, content, embed):
|
|
||||||
"""Send a message to the pd-news-ticker channel."""
|
|
||||||
await discord.utils.get(ctx.guild.text_channels, name='pd-news-ticker') \
|
|
||||||
.send(content=content, embed=embed)
|
|
||||||
|
|
||||||
|
|
||||||
async def typing_pause(ctx, seconds=1):
|
|
||||||
"""Show typing indicator for specified seconds."""
|
|
||||||
async with ctx.typing():
|
|
||||||
await asyncio.sleep(seconds)
|
|
||||||
|
|
||||||
|
|
||||||
async def pause_then_type(ctx, message):
|
|
||||||
"""Show typing indicator based on message length, then send message."""
|
|
||||||
async with ctx.typing():
|
|
||||||
await asyncio.sleep(len(message) / 100)
|
|
||||||
await ctx.send(message)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_if_pdhole(ctx):
|
|
||||||
"""Check if the current channel is pd-bot-hole."""
|
|
||||||
if ctx.message.channel.name != 'pd-bot-hole':
|
|
||||||
await ctx.send('Slide on down to my bot-hole for running commands.')
|
|
||||||
await ctx.message.add_reaction('❌')
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def bad_channel(ctx):
|
|
||||||
"""Check if current channel is in the list of bad channels for commands."""
|
|
||||||
bad_channels = ['paper-dynasty-chat', 'pd-news-ticker']
|
|
||||||
if ctx.message.channel.name in bad_channels:
|
|
||||||
await ctx.message.add_reaction('❌')
|
|
||||||
bot_hole = discord.utils.get(
|
|
||||||
ctx.guild.text_channels,
|
|
||||||
name=f'pd-bot-hole'
|
|
||||||
)
|
|
||||||
await ctx.send(f'Slide on down to the {bot_hole.mention} ;)')
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_channel(ctx, name) -> Optional[discord.TextChannel]:
|
|
||||||
"""Get a text channel by name."""
|
|
||||||
# Handle both Context and Interaction objects
|
|
||||||
guild = ctx.guild if hasattr(ctx, 'guild') else None
|
|
||||||
if not guild:
|
|
||||||
return None
|
|
||||||
|
|
||||||
channel = discord.utils.get(
|
|
||||||
guild.text_channels,
|
|
||||||
name=name
|
|
||||||
)
|
|
||||||
if channel:
|
|
||||||
return channel
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_emoji(ctx, name, return_empty=True):
|
|
||||||
"""Get an emoji by name, with fallback options."""
|
|
||||||
try:
|
|
||||||
emoji = await commands.converter.EmojiConverter().convert(ctx, name)
|
|
||||||
except:
|
|
||||||
if return_empty:
|
|
||||||
emoji = ''
|
|
||||||
else:
|
|
||||||
return name
|
|
||||||
return emoji
|
|
||||||
|
|
||||||
|
|
||||||
async def react_and_reply(ctx, reaction, message):
|
|
||||||
"""Add a reaction to the message and send a reply."""
|
|
||||||
await ctx.message.add_reaction(reaction)
|
|
||||||
await ctx.send(message)
|
|
||||||
|
|
||||||
|
|
||||||
async def send_to_channel(bot, channel_name, content=None, embed=None):
|
|
||||||
"""Send a message to a specific channel by name or ID."""
|
|
||||||
guild = bot.get_guild(int(os.environ.get('GUILD_ID')))
|
|
||||||
if not guild:
|
|
||||||
logger.error('Cannot send to channel - bot not logged in')
|
|
||||||
return
|
|
||||||
|
|
||||||
this_channel = discord.utils.get(guild.text_channels, name=channel_name)
|
|
||||||
|
|
||||||
if not this_channel:
|
|
||||||
this_channel = discord.utils.get(guild.text_channels, id=channel_name)
|
|
||||||
if not this_channel:
|
|
||||||
raise NameError(f'**{channel_name}** channel not found')
|
|
||||||
|
|
||||||
return await this_channel.send(content=content, embed=embed)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_or_create_role(ctx, role_name, mentionable=True):
|
|
||||||
"""Get an existing role or create it if it doesn't exist."""
|
|
||||||
this_role = discord.utils.get(ctx.guild.roles, name=role_name)
|
|
||||||
|
|
||||||
if not this_role:
|
|
||||||
this_role = await ctx.guild.create_role(name=role_name, mentionable=mentionable)
|
|
||||||
|
|
||||||
return this_role
|
|
||||||
|
|
||||||
|
|
||||||
def get_special_embed(special):
|
|
||||||
"""Create an embed for a special item."""
|
|
||||||
embed = discord.Embed(title=f'{special.name} - Special #{special.get_id()}',
|
|
||||||
color=discord.Color.random(),
|
|
||||||
description=f'{special.short_desc}')
|
|
||||||
embed.add_field(name='Description', value=f'{special.long_desc}', inline=False)
|
|
||||||
if special.thumbnail.lower() != 'none':
|
|
||||||
embed.set_thumbnail(url=f'{special.thumbnail}')
|
|
||||||
if special.url.lower() != 'none':
|
|
||||||
embed.set_image(url=f'{special.url}')
|
|
||||||
|
|
||||||
return embed
|
|
||||||
|
|
||||||
|
|
||||||
def get_random_embed(title, thumb=None):
|
|
||||||
"""Create a basic embed with random color."""
|
|
||||||
embed = discord.Embed(title=title, color=discord.Color.random())
|
|
||||||
if thumb:
|
|
||||||
embed.set_thumbnail(url=thumb)
|
|
||||||
|
|
||||||
return embed
|
|
||||||
|
|
||||||
|
|
||||||
def get_team_embed(title, team=None, thumbnail: bool = True):
|
|
||||||
"""Create a team-branded embed."""
|
|
||||||
if team:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=title,
|
|
||||||
color=int(team["color"], 16) if team["color"] else int(SBA_COLOR, 16)
|
|
||||||
)
|
|
||||||
embed.set_footer(text=f'Paper Dynasty Season {team["season"]}', icon_url=IMAGES['logo'])
|
|
||||||
if thumbnail:
|
|
||||||
embed.set_thumbnail(url=team["logo"] if team["logo"] else IMAGES['logo'])
|
|
||||||
else:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=title,
|
|
||||||
color=int(SBA_COLOR, 16)
|
|
||||||
)
|
|
||||||
embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo'])
|
|
||||||
if thumbnail:
|
|
||||||
embed.set_thumbnail(url=IMAGES['logo'])
|
|
||||||
|
|
||||||
return embed
|
|
||||||
|
|
||||||
|
|
||||||
async def create_channel_old(
|
|
||||||
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None,
|
|
||||||
allowed_roles=None):
|
|
||||||
"""Create a text channel with specified permissions (legacy version)."""
|
|
||||||
this_category = discord.utils.get(ctx.guild.categories, name=category_name)
|
|
||||||
if not this_category:
|
|
||||||
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
|
|
||||||
|
|
||||||
overwrites = {
|
|
||||||
ctx.guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True),
|
|
||||||
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
|
|
||||||
}
|
|
||||||
if allowed_members:
|
|
||||||
if isinstance(allowed_members, list):
|
|
||||||
for member in allowed_members:
|
|
||||||
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
|
||||||
if allowed_roles:
|
|
||||||
if isinstance(allowed_roles, list):
|
|
||||||
for role in allowed_roles:
|
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
|
||||||
|
|
||||||
this_channel = await ctx.guild.create_text_channel(
|
|
||||||
channel_name,
|
|
||||||
overwrites=overwrites,
|
|
||||||
category=this_category
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
|
|
||||||
|
|
||||||
return this_channel
|
|
||||||
|
|
||||||
|
|
||||||
async def create_channel(
|
|
||||||
ctx, channel_name: str, category_name: str, everyone_send=False, everyone_read=True,
|
|
||||||
read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None):
|
|
||||||
"""Create a text channel with specified permissions."""
|
|
||||||
# Handle both Context and Interaction objects
|
|
||||||
guild = ctx.guild if hasattr(ctx, 'guild') else None
|
|
||||||
if not guild:
|
|
||||||
raise ValueError(f'Unable to access guild from context object')
|
|
||||||
|
|
||||||
# Get bot member - different for Context vs Interaction
|
|
||||||
if hasattr(ctx, 'me'): # Context object
|
|
||||||
bot_member = ctx.me
|
|
||||||
elif hasattr(ctx, 'client'): # Interaction object
|
|
||||||
bot_member = guild.get_member(ctx.client.user.id)
|
|
||||||
else:
|
|
||||||
# Fallback - try to find bot member by getting the first member with bot=True
|
|
||||||
bot_member = next((m for m in guild.members if m.bot), None)
|
|
||||||
if not bot_member:
|
|
||||||
raise ValueError(f'Unable to find bot member in guild')
|
|
||||||
|
|
||||||
this_category = discord.utils.get(guild.categories, name=category_name)
|
|
||||||
if not this_category:
|
|
||||||
raise ValueError(f'I couldn\'t find a category named **{category_name}**')
|
|
||||||
|
|
||||||
overwrites = {
|
|
||||||
bot_member: discord.PermissionOverwrite(read_messages=True, send_messages=True),
|
|
||||||
guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
|
|
||||||
}
|
|
||||||
if read_send_members:
|
|
||||||
for member in read_send_members:
|
|
||||||
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
|
||||||
if read_send_roles:
|
|
||||||
for role in read_send_roles:
|
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
|
||||||
if read_only_roles:
|
|
||||||
for role in read_only_roles:
|
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
|
|
||||||
|
|
||||||
this_channel = await guild.create_text_channel(
|
|
||||||
channel_name,
|
|
||||||
overwrites=overwrites,
|
|
||||||
category=this_category
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
|
|
||||||
|
|
||||||
return this_channel
|
|
||||||
67
docker-compose.example.yml
Normal file
67
docker-compose.example.yml
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
services:
|
||||||
|
discord-app:
|
||||||
|
image: manticorum67/paper-dynasty-discordapp:dev
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- /path/to/dev-storage:/usr/src/app/storage
|
||||||
|
- /path/to/dev-logs:/usr/src/app/logs
|
||||||
|
environment:
|
||||||
|
- PYTHONBUFFERED=0
|
||||||
|
- GUILD_ID=your-guild-id-here
|
||||||
|
- BOT_TOKEN=your-bot-token-here
|
||||||
|
# - API_TOKEN=your-old-api-token-here
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
- API_TOKEN=your-api-token-here
|
||||||
|
- SCOREBOARD_CHANNEL=your-scoreboard-channel-id-here
|
||||||
|
- TZ=America/Chicago
|
||||||
|
- PYTHONHASHSEED=1749583062
|
||||||
|
- DATABASE=Dev
|
||||||
|
- DB_USERNAME=postgres
|
||||||
|
- DB_PASSWORD=your-db-password-here
|
||||||
|
- DB_URL=db
|
||||||
|
- DB_NAME=postgres
|
||||||
|
- RESTART_WEBHOOK_URL=your-discord-webhook-url-here
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- 8081:8081
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python3 -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8081/health\", timeout=5)' || exit 1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: your-db-password-here
|
||||||
|
volumes:
|
||||||
|
- pd_postgres:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready"]
|
||||||
|
interval: 1s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- 8080:8080
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pd_postgres:
|
||||||
2153
helpers.py
2153
helpers.py
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ Discord Utilities
|
|||||||
This module contains Discord helper functions for channels, roles, embeds,
|
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
|
||||||
@ -13,19 +14,21 @@ 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') \
|
await discord.utils.get(ctx.guild.text_channels, name="pd-bot-hole").send(
|
||||||
.send(content=content, embed=embed)
|
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') \
|
await discord.utils.get(ctx.guild.text_channels, name="pd-news-ticker").send(
|
||||||
.send(content=content, embed=embed)
|
content=content, embed=embed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def typing_pause(ctx, seconds=1):
|
async def typing_pause(ctx, seconds=1):
|
||||||
@ -43,23 +46,20 @@ 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(
|
bot_hole = discord.utils.get(ctx.guild.text_channels, name=f"pd-bot-hole")
|
||||||
ctx.guild.text_channels,
|
await ctx.send(f"Slide on down to the {bot_hole.mention} ;)")
|
||||||
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,14 +68,11 @@ 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(
|
channel = discord.utils.get(guild.text_channels, name=name)
|
||||||
guild.text_channels,
|
|
||||||
name=name
|
|
||||||
)
|
|
||||||
if channel:
|
if channel:
|
||||||
return channel
|
return channel
|
||||||
return None
|
return None
|
||||||
@ -87,7 +84,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
|
||||||
@ -101,9 +98,13 @@ 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 = bot.get_guild(int(os.environ.get('GUILD_ID')))
|
guild_id = os.environ.get("GUILD_ID")
|
||||||
|
if not guild_id:
|
||||||
|
logger.error("GUILD_ID env var is not set")
|
||||||
|
return
|
||||||
|
guild = bot.get_guild(int(guild_id))
|
||||||
if not guild:
|
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)
|
||||||
@ -111,7 +112,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)
|
||||||
|
|
||||||
@ -128,14 +129,16 @@ 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(title=f'{special.name} - Special #{special.get_id()}',
|
embed = discord.Embed(
|
||||||
color=discord.Color.random(),
|
title=f"{special.name} - Special #{special.get_id()}",
|
||||||
description=f'{special.short_desc}')
|
color=discord.Color.random(),
|
||||||
embed.add_field(name='Description', value=f'{special.long_desc}', inline=False)
|
description=f"{special.short_desc}",
|
||||||
if special.thumbnail.lower() != 'none':
|
)
|
||||||
embed.set_thumbnail(url=f'{special.thumbnail}')
|
embed.add_field(name="Description", value=f"{special.long_desc}", inline=False)
|
||||||
if special.url.lower() != 'none':
|
if special.thumbnail.lower() != "none":
|
||||||
embed.set_image(url=f'{special.url}')
|
embed.set_thumbnail(url=f"{special.thumbnail}")
|
||||||
|
if special.url.lower() != "none":
|
||||||
|
embed.set_image(url=f"{special.url}")
|
||||||
|
|
||||||
return embed
|
return embed
|
||||||
|
|
||||||
@ -154,99 +157,125 @@ 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(
|
embed = discord.Embed(title=title, color=int(SBA_COLOR, 16))
|
||||||
title=title,
|
embed.set_footer(
|
||||||
color=int(SBA_COLOR, 16)
|
text=f"Paper Dynasty Season {PD_SEASON}", icon_url=IMAGES["logo"]
|
||||||
)
|
)
|
||||||
embed.set_footer(text=f'Paper Dynasty Season {PD_SEASON}', icon_url=IMAGES['logo'])
|
|
||||||
if thumbnail:
|
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, channel_name: str, category_name: str, everyone_send=False, everyone_read=True, allowed_members=None,
|
ctx,
|
||||||
allowed_roles=None):
|
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)."""
|
"""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(read_messages=True, send_messages=True),
|
ctx.guild.me: discord.PermissionOverwrite(
|
||||||
ctx.guild.default_role: discord.PermissionOverwrite(read_messages=everyone_read, send_messages=everyone_send)
|
read_messages=True, send_messages=True
|
||||||
|
),
|
||||||
|
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(read_messages=True, send_messages=True)
|
overwrites[member] = discord.PermissionOverwrite(
|
||||||
|
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(read_messages=True, send_messages=True)
|
overwrites[role] = discord.PermissionOverwrite(
|
||||||
|
read_messages=True, send_messages=True
|
||||||
|
)
|
||||||
|
|
||||||
this_channel = await ctx.guild.create_text_channel(
|
this_channel = await ctx.guild.create_text_channel(
|
||||||
channel_name,
|
channel_name, overwrites=overwrites, category=this_category
|
||||||
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, channel_name: str, category_name: str, everyone_send=False, everyone_read=True,
|
ctx,
|
||||||
read_send_members: list = None, read_send_roles: list = None, read_only_roles: list = None):
|
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."""
|
"""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(read_messages=everyone_read, send_messages=everyone_send)
|
guild.default_role: discord.PermissionOverwrite(
|
||||||
|
read_messages=everyone_read, send_messages=everyone_send
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if read_send_members:
|
if read_send_members:
|
||||||
for member in read_send_members:
|
for member in read_send_members:
|
||||||
overwrites[member] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
overwrites[member] = discord.PermissionOverwrite(
|
||||||
|
read_messages=True, send_messages=True
|
||||||
|
)
|
||||||
if read_send_roles:
|
if read_send_roles:
|
||||||
for role in read_send_roles:
|
for role in read_send_roles:
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=True)
|
overwrites[role] = discord.PermissionOverwrite(
|
||||||
|
read_messages=True, send_messages=True
|
||||||
|
)
|
||||||
if read_only_roles:
|
if read_only_roles:
|
||||||
for role in read_only_roles:
|
for role in read_only_roles:
|
||||||
overwrites[role] = discord.PermissionOverwrite(read_messages=True, send_messages=False)
|
overwrites[role] = discord.PermissionOverwrite(
|
||||||
|
read_messages=True, send_messages=False
|
||||||
|
)
|
||||||
|
|
||||||
this_channel = await guild.create_text_channel(
|
this_channel = await guild.create_text_channel(
|
||||||
channel_name,
|
channel_name, overwrites=overwrites, category=this_category
|
||||||
overwrites=overwrites,
|
|
||||||
category=this_category
|
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f'Creating channel ({channel_name}) in ({category_name})')
|
logger.info(f"Creating channel ({channel_name}) in ({category_name})")
|
||||||
|
|
||||||
return this_channel
|
return this_channel
|
||||||
|
|||||||
129
helpers/main.py
129
helpers/main.py
@ -2,38 +2,29 @@ import asyncio
|
|||||||
import datetime
|
import 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 difflib import get_close_matches
|
from typing import Optional, Union, List
|
||||||
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):
|
||||||
@ -122,8 +113,18 @@ 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:
|
||||||
|
tier_badge = ""
|
||||||
|
try:
|
||||||
|
evo_state = await db_get(f"refractor/cards/{card['id']}")
|
||||||
|
if evo_state and evo_state.get("current_tier", 0) > 0:
|
||||||
|
tier = evo_state["current_tier"]
|
||||||
|
badge = TIER_BADGES.get(tier)
|
||||||
|
tier_badge = f"[{badge}] " if badge else ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title=f"{card['player']['p_name']}",
|
title=f"{tier_badge}{card['player']['p_name']}",
|
||||||
color=int(card["player"]["rarity"]["color"], 16),
|
color=int(card["player"]["rarity"]["color"], 16),
|
||||||
)
|
)
|
||||||
# embed.description = card['team']['lname']
|
# embed.description = card['team']['lname']
|
||||||
@ -166,7 +167,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 = f"Only you"
|
coll_string = "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 ''}"
|
||||||
@ -174,18 +175,26 @@ 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 = f"0 teams"
|
coll_string = "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 ''}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO: check for dupes with the included paperdex data
|
if card["team"]["lname"] != "Paper Dynasty":
|
||||||
# if card['team']['lname'] != 'Paper Dynasty':
|
team_dex = await db_get(
|
||||||
# team_dex = await db_get('cards', params=[("player_id", card["player"]["player_id"]), ('team_id', card['team']['id'])])
|
"cards",
|
||||||
# count = 1 if not team_dex['count'] else team_dex['count']
|
params=[
|
||||||
# embed.add_field(name='# Dupes', value=f'{count - 1} dupe{"s" if count - 1 != 1 else ""}')
|
("player_id", card["player"]["player_id"]),
|
||||||
|
("team_id", card["team"]["id"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if team_dex is not None:
|
||||||
|
dupe_count = max(0, team_dex["count"] - 1)
|
||||||
|
embed.add_field(
|
||||||
|
name="Dupes", value=f"{dupe_count} dupe{'s' if dupe_count != 1 else ''}"
|
||||||
|
)
|
||||||
|
|
||||||
# embed.add_field(name='Team', value=f'{card["player"]["mlbclub"]}')
|
# embed.add_field(name='Team', value=f'{card["player"]["mlbclub"]}')
|
||||||
if card["player"]["franchise"] != "Pokemon":
|
if card["player"]["franchise"] != "Pokemon":
|
||||||
@ -215,7 +224,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
|
|||||||
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
|
embed.add_field(name="Evolves Into", value=f"{evo_mon['p_name']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
"could not pull evolution: {e}", exc_info=True, stack_info=True
|
f"could not pull evolution: {e}", exc_info=True, stack_info=True
|
||||||
)
|
)
|
||||||
if "420420" not in card["player"]["strat_code"]:
|
if "420420" not in card["player"]["strat_code"]:
|
||||||
try:
|
try:
|
||||||
@ -226,7 +235,7 @@ async def get_card_embeds(card, include_stats=False) -> list:
|
|||||||
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
|
embed.add_field(name="Evolves From", value=f"{evo_mon['p_name']}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
"could not pull evolution: {e}", exc_info=True, stack_info=True
|
f"could not pull evolution: {e}", exc_info=True, stack_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
if include_stats:
|
if include_stats:
|
||||||
@ -326,7 +335,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(f"Cards sorted successfully")
|
logger.debug("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")
|
||||||
@ -347,15 +356,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 = f"Close Pack"
|
view.cancel_button.label = "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(f"Pagination view created successfully")
|
logger.debug("Pagination view created successfully")
|
||||||
|
|
||||||
if pack_cover:
|
if pack_cover:
|
||||||
logger.debug(f"Sending pack cover message")
|
logger.debug("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),
|
||||||
@ -367,7 +376,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(f"Initial message sent successfully")
|
logger.debug("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
|
||||||
@ -384,12 +393,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(f"Follow-up message sent successfully")
|
logger.debug("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(f"Starting main interaction loop")
|
logger.debug("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}")
|
||||||
@ -455,7 +464,7 @@ async def display_cards(
|
|||||||
),
|
),
|
||||||
view=view,
|
view=view,
|
||||||
)
|
)
|
||||||
logger.debug(f"MVP display updated successfully")
|
logger.debug("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
|
||||||
@ -463,19 +472,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=f"<@&1163537676885033010> we've got an MVP!"
|
content="<@&1163537676885033010> we've got an MVP!"
|
||||||
)
|
)
|
||||||
await follow_up.edit(
|
await follow_up.edit(
|
||||||
content=f"<@&1163537676885033010> we've got an MVP!"
|
content="<@&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=f"We've got an MVP!")
|
await follow_up.edit(content="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=f"We've got an MVP!")
|
await follow_up.edit(content="We've got an MVP!")
|
||||||
await view.wait()
|
await view.wait()
|
||||||
|
|
||||||
view = Pagination([user], timeout=10)
|
view = Pagination([user], timeout=10)
|
||||||
@ -483,7 +492,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 = f"Close Pack"
|
view.cancel_button.label = "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)}"
|
||||||
@ -531,7 +540,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 = f"Cancel"
|
view.cancel_button.label = "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)}"
|
||||||
@ -566,7 +575,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 = f"Cancel"
|
view.cancel_button.label = "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)}"
|
||||||
@ -880,7 +889,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(f"Failed to create this pack of cards.")
|
raise ConnectionError("Failed to create this pack of cards.")
|
||||||
|
|
||||||
await db_patch(
|
await db_patch(
|
||||||
"packs",
|
"packs",
|
||||||
@ -946,7 +955,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(
|
||||||
f"Bot has not authenticated with discord; please try again in 1 minute."
|
"Bot has not authenticated with discord; please try again in 1 minute."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1056,7 +1065,7 @@ def get_blank_team_card(player):
|
|||||||
def get_rosters(team, bot, roster_num: Optional[int] = None) -> list:
|
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(f"My Rosters")
|
r_sheet = this_sheet.worksheet_by_title("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]
|
||||||
@ -1137,11 +1146,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 as e:
|
except ValueError:
|
||||||
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(
|
||||||
f"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
|
"Uh oh. Looks like your roster might not be saved. I am reading blanks when I try to "
|
||||||
f"get the card IDs"
|
"get the card IDs"
|
||||||
)
|
)
|
||||||
logger.debug(f"lineup_cells: {lineup_cells}")
|
logger.debug(f"lineup_cells: {lineup_cells}")
|
||||||
|
|
||||||
@ -1536,7 +1545,7 @@ def get_ratings_guide(sheets):
|
|||||||
}
|
}
|
||||||
for x in p_data
|
for x in p_data
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"valid": False}
|
return {"valid": False}
|
||||||
|
|
||||||
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers}
|
return {"valid": True, "batter_ratings": batters, "pitcher_ratings": pitchers}
|
||||||
@ -1748,7 +1757,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
|
|||||||
pack_ids = await roll_for_cards(all_packs)
|
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(f"I was not able to unpack these cards")
|
raise ValueError("I was not able to unpack these cards")
|
||||||
|
|
||||||
all_cards = []
|
all_cards = []
|
||||||
for p_id in pack_ids:
|
for p_id in pack_ids:
|
||||||
@ -1759,7 +1768,7 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
|
|||||||
|
|
||||||
if not all_cards:
|
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(f"I was not able to display these cards")
|
raise ValueError("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:
|
||||||
@ -1818,7 +1827,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 = f"Take This Card"
|
view.cancel_button.label = "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)}"
|
||||||
@ -1836,7 +1845,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 = f"Take This Card"
|
view.cancel_button.label = "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)}"
|
||||||
|
|
||||||
@ -1879,7 +1888,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 = f"Take This Card"
|
view.cancel_button.label = "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:
|
||||||
@ -1925,7 +1934,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(f"Team not listed for Team Choice pack")
|
raise KeyError("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"]
|
||||||
@ -1964,7 +1973,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(f"Cardset not listed for Promo Choice pack")
|
raise KeyError("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"]
|
||||||
@ -2021,8 +2030,8 @@ async def open_choice_pack(
|
|||||||
rarity_id += 3
|
rarity_id += 3
|
||||||
|
|
||||||
if len(players) == 0:
|
if len(players) == 0:
|
||||||
logger.error(f"Could not create choice pack")
|
logger.error("Could not create choice pack")
|
||||||
raise ConnectionError(f"Could not create choice pack")
|
raise ConnectionError("Could not create choice pack")
|
||||||
|
|
||||||
if type(context) == commands.Context:
|
if type(context) == commands.Context:
|
||||||
author = context.author
|
author = context.author
|
||||||
@ -2045,7 +2054,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 = f"Take This Card"
|
view.cancel_button.label = "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)}"
|
||||||
@ -2063,10 +2072,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=f"<@&1163537676885033010> we've got an MVP!"
|
content="<@&1163537676885033010> we've got an MVP!"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
tmp_msg = await pack_channel.send(content=f"We've got a choice pack here!")
|
tmp_msg = await pack_channel.send(content="We've got a choice pack here!")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
await view.wait()
|
await view.wait()
|
||||||
@ -2081,7 +2090,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(f"Failed to distribute these cards.")
|
raise ConnectionError("Failed to distribute these cards.")
|
||||||
|
|
||||||
await db_patch(
|
await db_patch(
|
||||||
"packs",
|
"packs",
|
||||||
@ -2115,7 +2124,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 = f"Take This Card"
|
view.cancel_button.label = "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:
|
||||||
|
|||||||
108
helpers/refractor_notifs.py
Normal file
108
helpers/refractor_notifs.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""
|
||||||
|
Refractor Tier Completion Notifications
|
||||||
|
|
||||||
|
Builds and sends Discord embeds when a player completes a refractor tier
|
||||||
|
during post-game evaluation. Each tier-up event gets its own embed.
|
||||||
|
|
||||||
|
Notification failures are non-fatal: the send is wrapped in try/except so
|
||||||
|
a Discord API hiccup never disrupts game flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
|
# Human-readable display names for each tier number.
|
||||||
|
TIER_NAMES = {
|
||||||
|
0: "Base Card",
|
||||||
|
1: "Base Chrome",
|
||||||
|
2: "Refractor",
|
||||||
|
3: "Gold Refractor",
|
||||||
|
4: "Superfractor",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tier-specific embed colors.
|
||||||
|
TIER_COLORS = {
|
||||||
|
1: 0x2ECC71, # green
|
||||||
|
2: 0xF1C40F, # gold
|
||||||
|
3: 0x9B59B6, # purple
|
||||||
|
4: 0x1ABC9C, # teal (superfractor)
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOTER_TEXT = "Paper Dynasty Refractor"
|
||||||
|
|
||||||
|
|
||||||
|
def build_tier_up_embed(tier_up: dict) -> discord.Embed:
|
||||||
|
"""Build a Discord embed for a tier-up event.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
tier_up:
|
||||||
|
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
discord.Embed
|
||||||
|
A fully configured embed ready to send to a channel.
|
||||||
|
"""
|
||||||
|
player_name: str = tier_up["player_name"]
|
||||||
|
new_tier: int = tier_up["new_tier"]
|
||||||
|
track_name: str = tier_up["track_name"]
|
||||||
|
|
||||||
|
tier_name = TIER_NAMES.get(new_tier, f"Tier {new_tier}")
|
||||||
|
color = TIER_COLORS.get(new_tier, 0x2ECC71)
|
||||||
|
|
||||||
|
if new_tier >= 4:
|
||||||
|
# Superfractor — special title and description.
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="SUPERFRACTOR!",
|
||||||
|
description=(
|
||||||
|
f"**{player_name}** has reached maximum refractor tier on the **{track_name}** track"
|
||||||
|
),
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
embed.add_field(
|
||||||
|
name="Rating Boosts",
|
||||||
|
value="Rating boosts coming in a future update!",
|
||||||
|
inline=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="Refractor Tier Up!",
|
||||||
|
description=(
|
||||||
|
f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track"
|
||||||
|
),
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
embed.set_footer(text=FOOTER_TEXT)
|
||||||
|
return embed
|
||||||
|
|
||||||
|
|
||||||
|
async def notify_tier_completion(
|
||||||
|
channel: discord.abc.Messageable, tier_up: dict
|
||||||
|
) -> None:
|
||||||
|
"""Send a tier-up notification embed to the given channel.
|
||||||
|
|
||||||
|
Non-fatal: any exception during send is caught and logged so that a
|
||||||
|
Discord API failure never interrupts game evaluation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
channel:
|
||||||
|
A discord.abc.Messageable (e.g. discord.TextChannel).
|
||||||
|
tier_up:
|
||||||
|
Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"Failed to send tier-up notification for %s (tier %s): %s",
|
||||||
|
tier_up.get("player_name", "unknown"),
|
||||||
|
tier_up.get("new_tier"),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
@ -20,6 +20,7 @@ from helpers.constants import IMAGES, PD_SEASON
|
|||||||
logger = logging.getLogger("discord_app")
|
logger = logging.getLogger("discord_app")
|
||||||
|
|
||||||
SCOUT_TOKENS_PER_DAY = 2
|
SCOUT_TOKENS_PER_DAY = 2
|
||||||
|
SCOUT_TOKEN_COST = 200 # Currency cost to buy an extra scout token
|
||||||
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
|
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
|
||||||
_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium")
|
_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium")
|
||||||
SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()}
|
SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()}
|
||||||
|
|||||||
@ -1315,47 +1315,9 @@ 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__":
|
||||||
|
|||||||
@ -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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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:
|
except Exception:
|
||||||
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()
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import discord
|
import discord
|
||||||
import datetime
|
|
||||||
import logging
|
import logging
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -54,6 +53,7 @@ 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()
|
||||||
|
|||||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.0.2
|
||||||
|
pytest-asyncio==1.3.0
|
||||||
@ -1,15 +1,13 @@
|
|||||||
discord.py
|
discord.py==2.7.1
|
||||||
pygsheets
|
pygsheets==2.0.6
|
||||||
pydantic
|
pydantic==2.12.5
|
||||||
gsheets
|
gsheets==0.6.1
|
||||||
bs4
|
bs4==0.0.2
|
||||||
peewee
|
peewee==4.0.1
|
||||||
sqlmodel
|
sqlmodel==0.0.37
|
||||||
alembic
|
alembic==1.18.4
|
||||||
pytest
|
numpy==1.26.4
|
||||||
pytest-asyncio
|
pandas==3.0.1
|
||||||
numpy<2
|
psycopg2-binary==2.9.11
|
||||||
pandas
|
aiohttp==3.13.3
|
||||||
psycopg2-binary
|
|
||||||
aiohttp
|
|
||||||
# psycopg[binary]
|
# psycopg[binary]
|
||||||
|
|||||||
37
ruff.toml
Normal file
37
ruff.toml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Ruff configuration for paper-dynasty discord bot
|
||||||
|
# See https://docs.astral.sh/ruff/configuration/
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
# Rules suppressed globally because they reflect intentional project patterns:
|
||||||
|
# F403/F405: star imports — __init__.py files use `from .module import *` for re-exports
|
||||||
|
# E712: SQLAlchemy/SQLModel ORM comparisons require == syntax (not `is`)
|
||||||
|
# F541: f-strings without placeholders — 1000+ legacy occurrences; cosmetic, deferred
|
||||||
|
ignore = ["F403", "F405", "F541", "E712"]
|
||||||
|
|
||||||
|
# Per-file suppressions for pre-existing violations in legacy code.
|
||||||
|
# New files outside these paths get the full rule set.
|
||||||
|
# Remove entries here as files are cleaned up.
|
||||||
|
[lint.per-file-ignores]
|
||||||
|
# Core cogs — F841/F401 widespread; E711/E713/F811 pre-existing
|
||||||
|
"cogs/**" = ["F841", "F401", "E711", "E713", "F811"]
|
||||||
|
# Game engine — F841/F401 widespread; E722/F811 pre-existing bare-excepts and redefinitions
|
||||||
|
"in_game/**" = ["F841", "F401", "E722", "F811"]
|
||||||
|
# Helpers — F841/F401 widespread; E721/E722 pre-existing type-comparison and bare-excepts
|
||||||
|
"helpers/**" = ["F841", "F401", "E721", "E722"]
|
||||||
|
# Game logic and commands
|
||||||
|
"command_logic/**" = ["F841", "F401"]
|
||||||
|
# Test suite — E711/F811/F821 pre-existing test assertion patterns
|
||||||
|
"tests/**" = ["F841", "F401", "E711", "F811", "F821"]
|
||||||
|
# Utilities
|
||||||
|
"utilities/**" = ["F841", "F401"]
|
||||||
|
# Migrations
|
||||||
|
"migrations/**" = ["F401"]
|
||||||
|
# Top-level legacy files
|
||||||
|
"db_calls_gameplay.py" = ["F841", "F401"]
|
||||||
|
"gauntlets.py" = ["F841", "F401"]
|
||||||
|
"dice.py" = ["F841", "E711"]
|
||||||
|
"manual_pack_distribution.py" = ["F841"]
|
||||||
|
"play_lock.py" = ["F821"]
|
||||||
|
"paperdynasty.py" = ["F401"]
|
||||||
|
"api_calls.py" = ["F401"]
|
||||||
|
"health_server.py" = ["F401"]
|
||||||
700
tests/refractor-integration-test-plan.md
Normal file
700
tests/refractor-integration-test-plan.md
Normal file
@ -0,0 +1,700 @@
|
|||||||
|
# Refractor System -- In-App Integration Test Plan
|
||||||
|
|
||||||
|
**Target environment**: Dev Discord server (Guild ID: `613880856032968834`)
|
||||||
|
**Dev API**: `pddev.manticorum.com`
|
||||||
|
**Bot container**: `paper-dynasty_discord-app_1` on `sba-bots`
|
||||||
|
**Date created**: 2026-03-25
|
||||||
|
|
||||||
|
This test plan is designed for browser automation (Playwright against the Discord
|
||||||
|
web client) or manual execution. Each test case specifies an exact slash command,
|
||||||
|
the expected bot response, and pass/fail criteria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Prerequisites](#prerequisites)
|
||||||
|
2. [API Health Checks](#1-api-health-checks)
|
||||||
|
3. [/refractor status -- Basic Functionality](#2-refractor-status----basic-functionality)
|
||||||
|
4. [/refractor status -- Filters](#3-refractor-status----filters)
|
||||||
|
5. [/refractor status -- Pagination](#4-refractor-status----pagination)
|
||||||
|
6. [/refractor status -- Edge Cases and Errors](#5-refractor-status----edge-cases-and-errors)
|
||||||
|
7. [Tier Badges on Card Embeds](#6-tier-badges-on-card-embeds)
|
||||||
|
8. [Post-Game Hook -- Stat Accumulation and Evaluation](#7-post-game-hook----stat-accumulation-and-evaluation)
|
||||||
|
9. [Tier-Up Notifications](#8-tier-up-notifications)
|
||||||
|
10. [Cross-Command Badge Propagation](#9-cross-command-badge-propagation)
|
||||||
|
11. [Known Gaps and Risks](#known-gaps-and-risks)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before running these tests, ensure the following state exists:
|
||||||
|
|
||||||
|
### Bot State
|
||||||
|
- [ ] Bot is online and healthy: `GET http://sba-bots:8080/health` returns 200
|
||||||
|
- [ ] Refractor cog is loaded: check bot logs for `Loaded extension 'cogs.refractor'`
|
||||||
|
- [ ] Test user has the `PD Players` role on the dev server
|
||||||
|
|
||||||
|
### Team and Card State
|
||||||
|
- [ ] Test user owns a team (verify with `/team` or `/myteam`)
|
||||||
|
- [ ] Team has at least 15 cards on its roster (needed for pagination tests)
|
||||||
|
- [ ] At least one batter card, one SP card, and one RP card exist on the roster
|
||||||
|
- [ ] At least one card has refractor state initialized in the database (the API must have a `RefractorCardState` row for this player+team pair)
|
||||||
|
- [ ] Record the team ID, and at least 3 card IDs for use in tests:
|
||||||
|
- `CARD_BATTER` -- a batter card ID with refractor state
|
||||||
|
- `CARD_SP` -- a starting pitcher card ID with refractor state
|
||||||
|
- `CARD_RP` -- a relief pitcher card ID with refractor state
|
||||||
|
- `CARD_NO_STATE` -- a card ID that exists but has no RefractorCardState row
|
||||||
|
- `CARD_INVALID` -- a card ID that does not exist (e.g. 999999)
|
||||||
|
|
||||||
|
### API State
|
||||||
|
- [ ] Refractor tracks are seeded: `GET /api/v2/refractor/tracks` returns at least 3 tracks (batter, sp, rp)
|
||||||
|
- [ ] At least one RefractorCardState row exists for a card on the test team
|
||||||
|
- [ ] Verify manually: `GET /api/v2/refractor/cards/{CARD_BATTER}` returns a valid response
|
||||||
|
- [ ] Verify list endpoint: `GET /api/v2/refractor/cards?team_id={TEAM_ID}` returns cards for the test team
|
||||||
|
|
||||||
|
### Data Setup Script (run against dev API)
|
||||||
|
If refractor state does not yet exist for test cards, trigger initialization:
|
||||||
|
```bash
|
||||||
|
# Force-evaluate a specific card to create its RefractorCardState
|
||||||
|
curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_BATTER}/evaluate" \
|
||||||
|
-H "Authorization: Bearer ${API_TOKEN}"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. API Health Checks
|
||||||
|
|
||||||
|
These are pre-flight checks run before any Discord interaction. They verify the
|
||||||
|
API layer is functional. Execute via shell or Playwright network interception.
|
||||||
|
|
||||||
|
### REF-API-01: Bot health endpoint
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -sf http://sba-bots:8080/health` |
|
||||||
|
| **Expected** | HTTP 200, body contains health status |
|
||||||
|
| **Pass criteria** | Non-empty 200 response |
|
||||||
|
|
||||||
|
### REF-API-02: Refractor tracks endpoint responds
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/tracks" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with `count >= 3` and `items` array containing batter, sp, rp tracks |
|
||||||
|
| **Pass criteria** | `count` field >= 3; each item has `card_type`, `t1_threshold`, `t2_threshold`, `t3_threshold`, `t4_threshold` |
|
||||||
|
|
||||||
|
### REF-API-03: Single card refractor state endpoint
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_BATTER}" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with `player_id`, `team_id`, `current_tier`, `current_value`, `fully_evolved`, `next_threshold`, `track` |
|
||||||
|
| **Pass criteria** | `current_tier` is an integer 0-4; `track` object exists with threshold fields |
|
||||||
|
|
||||||
|
### REF-API-04: Card state 404 for nonexistent card
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/refractor/cards/999999" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | HTTP 404 |
|
||||||
|
| **Pass criteria** | Status code is exactly 404 |
|
||||||
|
|
||||||
|
### REF-API-05: Old evolution endpoint removed
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/evolution/cards/1" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | HTTP 404 |
|
||||||
|
| **Pass criteria** | Status code is 404 (confirms evolution->refractor rename is complete) |
|
||||||
|
|
||||||
|
### REF-API-06: Team-level card list endpoint
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with `count` >= 1 and `items` array containing card state objects |
|
||||||
|
| **Pass criteria** | 1. `count` reflects total cards with refractor state for the team |
|
||||||
|
| | 2. Each item has `player_id`, `team_id`, `current_tier`, `current_value`, `progress_pct`, `player_name` |
|
||||||
|
| | 3. Items sorted by `current_tier` DESC, `current_value` DESC |
|
||||||
|
|
||||||
|
### REF-API-07: Card list with card_type filter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&card_type=batter" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with only batter card states |
|
||||||
|
| **Pass criteria** | All items have batter track; count <= total from REF-API-06 |
|
||||||
|
|
||||||
|
### REF-API-08: Card list with tier filter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&tier=0" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with only T0 card states |
|
||||||
|
| **Pass criteria** | All items have `current_tier: 0` |
|
||||||
|
|
||||||
|
### REF-API-09: Card list with progress=close filter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&progress=close" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with only cards at >= 80% of next tier threshold |
|
||||||
|
| **Pass criteria** | Each item's `progress_pct` >= 80.0; no fully evolved cards |
|
||||||
|
|
||||||
|
### REF-API-10: Card list pagination
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -s "https://pddev.manticorum.com/api/v2/refractor/cards?team_id=${TEAM_ID}&limit=2&offset=0" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected** | JSON with `count` reflecting total (not page size) and `items` array with at most 2 entries |
|
||||||
|
| **Pass criteria** | 1. `count` same as REF-API-06 (total matching, not page size) |
|
||||||
|
| | 2. `items` length <= 2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. /refractor status -- Basic Functionality
|
||||||
|
|
||||||
|
### REF-01: Basic status command (no filters)
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Invoke /refractor status with no arguments; verify the embed appears |
|
||||||
|
| **Discord command** | `/refractor status` |
|
||||||
|
| **Expected result** | An ephemeral embed with: |
|
||||||
|
| | - Title: `{team short name} Refractor Status` |
|
||||||
|
| | - Purple embed color (hex `0x6F42C1` = RGB 111, 66, 193) |
|
||||||
|
| | - Description containing card entries (player names, progress bars, tier labels) |
|
||||||
|
| | - Footer: `Page 1/N . M card(s) total` |
|
||||||
|
| **Pass criteria** | 1. Embed title contains team short name and "Refractor Status" |
|
||||||
|
| | 2. Embed color is purple (`#6F42C1`) |
|
||||||
|
| | 3. At least one card entry is visible in the description |
|
||||||
|
| | 4. Footer contains page number and total card count |
|
||||||
|
| | 5. Response is ephemeral (only visible to the invoking user) |
|
||||||
|
|
||||||
|
### REF-02: Card entry format -- batter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Verify a batter card entry has correct format in the status embed |
|
||||||
|
| **Discord command** | `/refractor status card_type:batter` |
|
||||||
|
| **Expected result** | Each entry in the embed follows this pattern: |
|
||||||
|
| | Line 1: `**{badge} Player Name** (Tier Label)` |
|
||||||
|
| | Line 2: `[====------] value/threshold (PA+TB x 2) -- T{n} -> T{n+1}` |
|
||||||
|
| **Pass criteria** | 1. Player name appears in bold (`**...**`) |
|
||||||
|
| | 2. Tier label is one of: Base Card, Base Chrome, Refractor, Gold Refractor, Superfractor |
|
||||||
|
| | 3. Progress bar has format `[====------]` (10 chars of `=` and `-` between brackets) |
|
||||||
|
| | 4. Formula label shows `PA+TB x 2` for batters |
|
||||||
|
| | 5. Tier progression arrow shows `T{current} -> T{next}` |
|
||||||
|
|
||||||
|
### REF-03: Card entry format -- starting pitcher
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Verify SP cards show the correct formula label |
|
||||||
|
| **Discord command** | `/refractor status card_type:sp` |
|
||||||
|
| **Expected result** | SP card entries show `IP+K` as the formula label |
|
||||||
|
| **Pass criteria** | Formula label in progress line is `IP+K` (not `PA+TB x 2`) |
|
||||||
|
|
||||||
|
### REF-04: Card entry format -- relief pitcher
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Verify RP cards show the correct formula label |
|
||||||
|
| **Discord command** | `/refractor status card_type:rp` |
|
||||||
|
| **Expected result** | RP card entries show `IP+K` as the formula label |
|
||||||
|
| **Pass criteria** | Formula label in progress line is `IP+K` |
|
||||||
|
|
||||||
|
### REF-05: Tier badge display per tier
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Verify correct tier badges appear for each tier level |
|
||||||
|
| **Discord command** | `/refractor status` (examine entries across tiers) |
|
||||||
|
| **Expected result** | Badge mapping: |
|
||||||
|
| | T0 (Base Card): no badge prefix |
|
||||||
|
| | T1 (Base Chrome): `[BC]` prefix |
|
||||||
|
| | T2 (Refractor): `[R]` prefix |
|
||||||
|
| | T3 (Gold Refractor): `[GR]` prefix |
|
||||||
|
| | T4 (Superfractor): `[SF]` prefix |
|
||||||
|
| **Pass criteria** | Each card's badge matches its tier per the mapping above |
|
||||||
|
|
||||||
|
### REF-06: Fully evolved card display
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Verify T4 (Superfractor) cards show the fully evolved indicator |
|
||||||
|
| **Discord command** | `/refractor status tier:4` |
|
||||||
|
| **Expected result** | Fully evolved cards show: |
|
||||||
|
| | Line 1: `**[SF] Player Name** (Superfractor)` |
|
||||||
|
| | Line 2: `[==========] FULLY EVOLVED (star)` |
|
||||||
|
| **Pass criteria** | 1. Progress bar is completely filled (`[==========]`) |
|
||||||
|
| | 2. Text says "FULLY EVOLVED" with a star character |
|
||||||
|
| | 3. No tier progression arrow (no `->` text) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. /refractor status -- Filters
|
||||||
|
|
||||||
|
### REF-10: Filter by card_type=batter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status card_type:batter` |
|
||||||
|
| **Expected result** | Only batter cards appear; formula label is `PA+TB x 2` on all entries |
|
||||||
|
| **Pass criteria** | No entries show `IP+K` formula label |
|
||||||
|
|
||||||
|
### REF-11: Filter by card_type=sp
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status card_type:sp` |
|
||||||
|
| **Expected result** | Only SP cards appear; formula label is `IP+K` on all entries |
|
||||||
|
| **Pass criteria** | No entries show `PA+TB x 2` formula label |
|
||||||
|
|
||||||
|
### REF-12: Filter by card_type=rp
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status card_type:rp` |
|
||||||
|
| **Expected result** | Only RP cards appear; formula label is `IP+K` on all entries |
|
||||||
|
| **Pass criteria** | No entries show `PA+TB x 2` formula label |
|
||||||
|
|
||||||
|
### REF-13: Filter by tier=0
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status tier:0` |
|
||||||
|
| **Expected result** | Only T0 (Base Card) entries appear; no tier badges on any entry |
|
||||||
|
| **Pass criteria** | No entries contain `[BC]`, `[R]`, `[GR]`, or `[SF]` badges |
|
||||||
|
|
||||||
|
### REF-14: Filter by tier=1
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status tier:1` |
|
||||||
|
| **Expected result** | Only T1 entries appear; all show `[BC]` badge and `(Base Chrome)` label |
|
||||||
|
| **Pass criteria** | Every entry contains `[BC]` and `(Base Chrome)` |
|
||||||
|
|
||||||
|
### REF-15: Filter by tier=4
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status tier:4` |
|
||||||
|
| **Expected result** | Only T4 entries appear; all show `[SF]` badge and `FULLY EVOLVED` |
|
||||||
|
| **Pass criteria** | Every entry contains `[SF]`, `(Superfractor)`, and `FULLY EVOLVED` |
|
||||||
|
|
||||||
|
### REF-16: Filter by progress=close
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status progress:close` |
|
||||||
|
| **Expected result** | Only cards within 80% of their next tier threshold appear |
|
||||||
|
| **Pass criteria** | 1. For each entry, the formula_value/next_threshold ratio >= 0.8 |
|
||||||
|
| | 2. No fully evolved (T4) cards appear |
|
||||||
|
| | 3. If no cards qualify, message says "No cards are currently close to a tier advancement." |
|
||||||
|
|
||||||
|
### REF-17: Combined filter -- tier + card_type
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status card_type:batter tier:1` |
|
||||||
|
| **Expected result** | Only T1 batter cards appear |
|
||||||
|
| **Pass criteria** | All entries have `[BC]` badge AND `PA+TB x 2` formula label |
|
||||||
|
|
||||||
|
### REF-18: Combined filter -- tier=4 + progress=close (empty result)
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status tier:4 progress:close` |
|
||||||
|
| **Expected result** | Message: "No cards are currently close to a tier advancement." |
|
||||||
|
| **Pass criteria** | No embed appears; plain text message about no close cards |
|
||||||
|
| **Notes** | T4 cards are fully evolved and cannot be "close" to any threshold |
|
||||||
|
|
||||||
|
### REF-19: Filter by season
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status season:1` |
|
||||||
|
| **Expected result** | Only cards from season 1 appear (or empty message if none exist) |
|
||||||
|
| **Pass criteria** | Response is either a valid embed or the "no data" message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. /refractor status -- Pagination
|
||||||
|
|
||||||
|
### REF-20: Page 1 shows first 10 cards
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status page:1` |
|
||||||
|
| **Expected result** | Embed shows up to 10 card entries; footer says `Page 1/N` |
|
||||||
|
| **Pass criteria** | 1. At most 10 card entries in the description |
|
||||||
|
| | 2. Footer page number is `1` |
|
||||||
|
| | 3. Total pages `N` matches `ceil(total_cards / 10)` |
|
||||||
|
|
||||||
|
### REF-21: Page 2 shows next batch
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status page:2` |
|
||||||
|
| **Expected result** | Embed shows cards 11-20; footer says `Page 2/N` |
|
||||||
|
| **Pass criteria** | 1. Different cards than page 1 |
|
||||||
|
| | 2. Footer shows `Page 2/N` |
|
||||||
|
| **Prerequisite** | Team has > 10 cards with refractor state |
|
||||||
|
|
||||||
|
### REF-22: Page beyond total clamps to last page
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status page:999` |
|
||||||
|
| **Expected result** | Embed shows the last page of cards |
|
||||||
|
| **Pass criteria** | 1. Footer shows `Page N/N` (last page) |
|
||||||
|
| | 2. No error or empty response |
|
||||||
|
|
||||||
|
### REF-23: Page 0 clamps to page 1
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status page:0` |
|
||||||
|
| **Expected result** | Embed shows page 1 |
|
||||||
|
| **Pass criteria** | Footer shows `Page 1/N` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. /refractor status -- Edge Cases and Errors
|
||||||
|
|
||||||
|
### REF-30: User with no team
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Invoke command as a user who does not own a team |
|
||||||
|
| **Discord command** | `/refractor status` (from a user with no team) |
|
||||||
|
| **Expected result** | Plain text message: "You don't have a team. Sign up with /newteam first." |
|
||||||
|
| **Pass criteria** | 1. No embed appears |
|
||||||
|
| | 2. Message mentions `/newteam` |
|
||||||
|
| | 3. Response is ephemeral |
|
||||||
|
|
||||||
|
### REF-31: Team with no refractor data
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Invoke command for a team that has cards but no RefractorCardState rows |
|
||||||
|
| **Discord command** | `/refractor status` (from a team with no refractor initialization) |
|
||||||
|
| **Expected result** | Plain text message: "No refractor data found for your team." |
|
||||||
|
| **Pass criteria** | 1. No embed appears |
|
||||||
|
| | 2. Message mentions "no refractor data" |
|
||||||
|
|
||||||
|
### REF-32: Invalid card_type filter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status card_type:xyz` |
|
||||||
|
| **Expected result** | Empty result -- "No refractor data found for your team." |
|
||||||
|
| **Pass criteria** | No crash; clean empty-state message |
|
||||||
|
|
||||||
|
### REF-33: Negative tier filter
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status tier:-1` |
|
||||||
|
| **Expected result** | Empty result or Discord input validation rejection |
|
||||||
|
| **Pass criteria** | No crash; either a clean message or Discord prevents submission |
|
||||||
|
|
||||||
|
### REF-34: Negative page number
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/refractor status page:-5` |
|
||||||
|
| **Expected result** | Clamps to page 1 |
|
||||||
|
| **Pass criteria** | Footer shows `Page 1/N`; no crash |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tier Badges on Card Embeds
|
||||||
|
|
||||||
|
These tests verify that tier badges appear in card embed titles across all
|
||||||
|
commands that display card embeds via `get_card_embeds()`.
|
||||||
|
|
||||||
|
### REF-40: Tier badge on /card command (player lookup)
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Look up a card that has a refractor tier > 0 |
|
||||||
|
| **Discord command** | `/card {player_name}` (use a player known to have refractor state) |
|
||||||
|
| **Expected result** | Embed title is `[BC] Player Name` (or appropriate badge for their tier) |
|
||||||
|
| **Pass criteria** | 1. Embed title starts with the correct tier badge in brackets |
|
||||||
|
| | 2. Player name follows the badge |
|
||||||
|
| | 3. Embed color is still from the card's rarity (not refractor-related) |
|
||||||
|
|
||||||
|
### REF-41: No badge for T0 card
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Look up a card with current_tier=0 |
|
||||||
|
| **Discord command** | `/card {player_name}` (use a player at T0) |
|
||||||
|
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
||||||
|
| **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` |
|
||||||
|
|
||||||
|
### REF-42: No badge when refractor state is missing
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Look up a card that has no RefractorCardState row |
|
||||||
|
| **Discord command** | `/card {player_name}` (use a player with no refractor state) |
|
||||||
|
| **Expected result** | Embed title is just `Player Name` with no bracket prefix |
|
||||||
|
| **Pass criteria** | 1. Title has no badge prefix |
|
||||||
|
| | 2. No error in bot logs about the refractor API call |
|
||||||
|
| | 3. Card display is otherwise normal |
|
||||||
|
|
||||||
|
### REF-43: Badge on /buy confirmation embed
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Start a card purchase for a player with refractor state |
|
||||||
|
| **Discord command** | `/buy {player_name}` |
|
||||||
|
| **Expected result** | The card embed shown during purchase confirmation includes the tier badge |
|
||||||
|
| **Pass criteria** | Embed title includes tier badge if the player has refractor state |
|
||||||
|
| **Notes** | The buy flow uses `get_card_embeds(get_blank_team_card(...))`. Since blank team cards have no team association, the refractor lookup by card_id may 404. Verify graceful fallback. |
|
||||||
|
|
||||||
|
### REF-44: Badge on pack opening cards
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Open a pack and check if revealed cards show tier badges |
|
||||||
|
| **Discord command** | `/openpack` (or equivalent pack opening command) |
|
||||||
|
| **Expected result** | Cards displayed via `display_cards()` -> `get_card_embeds()` show tier badges if applicable |
|
||||||
|
| **Pass criteria** | Cards with refractor state show badges; cards without state show no badge and no error |
|
||||||
|
|
||||||
|
### REF-45: Badge consistency between /card and /refractor status
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Compare the badge shown for the same player in both views |
|
||||||
|
| **Discord command** | Run both `/card {player}` and `/refractor status` for the same player |
|
||||||
|
| **Expected result** | The badge in the `/card` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` |
|
||||||
|
| **Pass criteria** | Tier badge letter matches: T1=[BC], T2=[R], T3=[GR], T4=[SF] |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Post-Game Hook -- Stat Accumulation and Evaluation
|
||||||
|
|
||||||
|
These tests verify the end-to-end flow: play a game -> stats update -> refractor
|
||||||
|
evaluation -> optional tier-up notification.
|
||||||
|
|
||||||
|
### Prerequisites for Game Tests
|
||||||
|
- Two teams exist on the dev server (the test user's team + an AI opponent)
|
||||||
|
- The test user's team has a valid lineup and starting pitcher set
|
||||||
|
- Record the game ID from the game channel name after starting
|
||||||
|
|
||||||
|
**Note:** Cal will perform the test game manually in Discord. Sections 7 and 8
|
||||||
|
(REF-50 through REF-64) are not automated via Playwright — game simulation
|
||||||
|
requires interactive play that is impractical to automate through the Discord
|
||||||
|
web client. After the game completes, the verification checks (REF-52 through
|
||||||
|
REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
|
||||||
|
|
||||||
|
### REF-50: Start a game against AI
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Start a new game to create a game context |
|
||||||
|
| **Discord command** | `/new-game mlb-campaign league:Minor League away_team_abbrev:{user_team} home_team_abbrev:{ai_team}` |
|
||||||
|
| **Expected result** | Game channel is created; game starts successfully |
|
||||||
|
| **Pass criteria** | A new channel appears; scorebug embed is posted |
|
||||||
|
| **Notes** | This is setup for REF-51 through REF-54 |
|
||||||
|
|
||||||
|
### REF-51: Complete a game (manual or auto-roll)
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Play the game through to completion |
|
||||||
|
| **Discord command** | Use `/log ab` repeatedly or auto-roll to finish the game |
|
||||||
|
| **Expected result** | Game ends; final score is posted; game summary embed appears |
|
||||||
|
| **Pass criteria** | 1. Game over message appears |
|
||||||
|
| | 2. No errors in bot logs during the post-game hook |
|
||||||
|
|
||||||
|
### REF-52: Verify season stats updated post-game
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | After game completion, check that season stats were updated |
|
||||||
|
| **Verification** | Check bot logs for successful POST to `season-stats/update-game/{game_id}` |
|
||||||
|
| **Pass criteria** | 1. Bot logs show the season-stats POST was made |
|
||||||
|
| | 2. No error logged for that call |
|
||||||
|
| **API check** | `curl "https://pddev.manticorum.com/api/v2/season-stats?team_id={team_id}" -H "Authorization: Bearer $TOKEN"` returns updated stats |
|
||||||
|
|
||||||
|
### REF-53: Verify refractor evaluation triggered post-game
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | After game completion, check that refractor evaluation was called |
|
||||||
|
| **Verification** | Check bot logs for successful POST to `refractor/evaluate-game/{game_id}` |
|
||||||
|
| **Pass criteria** | 1. Bot logs show the refractor evaluate-game POST was made |
|
||||||
|
| | 2. The call happened AFTER the season-stats call (ordering matters) |
|
||||||
|
| | 3. Log does not show "Post-game refractor processing failed" |
|
||||||
|
|
||||||
|
### REF-54: Verify refractor values changed after game
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | After a completed game, check that formula values increased for participating players |
|
||||||
|
| **Discord command** | `/refractor status` (compare before/after values for a participating player) |
|
||||||
|
| **Expected result** | `formula_value` for batters who had PAs and pitchers who recorded outs should be higher than before the game |
|
||||||
|
| **Pass criteria** | At least one card's formula_value has increased |
|
||||||
|
| **API check** | `curl "https://pddev.manticorum.com/api/v2/refractor/cards/{CARD_BATTER}" -H "Authorization: Bearer $TOKEN"` -- compare `current_value` before and after |
|
||||||
|
|
||||||
|
### REF-55: Post-game hook is non-fatal
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Even if the refractor API fails, the game completion should succeed |
|
||||||
|
| **Verification** | This is tested via unit tests (test_complete_game_hook.py). For integration: verify that if the API has a momentary error, the game result is still saved and the channel reflects the final score. |
|
||||||
|
| **Pass criteria** | Game results persist even if refractor evaluation errors appear in logs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Tier-Up Notifications
|
||||||
|
|
||||||
|
### REF-60: Tier-up embed format (T0 -> T1)
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | When a card tiers up from T0 to T1 (Base Chrome), a notification embed is sent |
|
||||||
|
| **Trigger** | Complete a game where a player's formula_value crosses the T1 threshold |
|
||||||
|
| **Expected result** | An embed appears in the game channel with: |
|
||||||
|
| | - Title: "Refractor Tier Up!" |
|
||||||
|
| | - Description: `**{Player Name}** reached **Tier 1 (Base Chrome)** on the **{Track Name}** track` |
|
||||||
|
| | - Color: green (`0x2ECC71`) |
|
||||||
|
| | - Footer: "Paper Dynasty Refractor" |
|
||||||
|
| **Pass criteria** | 1. Embed title is exactly "Refractor Tier Up!" |
|
||||||
|
| | 2. Player name appears bold in description |
|
||||||
|
| | 3. Tier number and name are correct |
|
||||||
|
| | 4. Track name is one of: Batter Track, Starting Pitcher Track, Relief Pitcher Track |
|
||||||
|
| | 5. Footer text is "Paper Dynasty Refractor" |
|
||||||
|
|
||||||
|
### REF-61: Tier-up embed colors per tier
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Each tier has a distinct embed color |
|
||||||
|
| **Expected colors** | T1: green (`0x2ECC71`), T2: gold (`0xF1C40F`), T3: purple (`0x9B59B6`), T4: teal (`0x1ABC9C`) |
|
||||||
|
| **Pass criteria** | Embed color matches the target tier |
|
||||||
|
| **Notes** | May require manual API manipulation to trigger specific tier transitions |
|
||||||
|
|
||||||
|
### REF-62: Superfractor notification (T3 -> T4)
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | The Superfractor tier-up has special formatting |
|
||||||
|
| **Trigger** | A player crosses the T4 threshold |
|
||||||
|
| **Expected result** | Embed with: |
|
||||||
|
| | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") |
|
||||||
|
| | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` |
|
||||||
|
| | - Color: teal (`0x1ABC9C`) |
|
||||||
|
| | - Extra field: "Rating Boosts" with value "Rating boosts coming in a future update!" |
|
||||||
|
| **Pass criteria** | 1. Title is "SUPERFRACTOR!" |
|
||||||
|
| | 2. Description mentions "maximum refractor tier" |
|
||||||
|
| | 3. "Rating Boosts" field is present |
|
||||||
|
|
||||||
|
### REF-63: Multiple tier-ups in one game
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | When multiple players tier up in the same game, each gets a separate notification |
|
||||||
|
| **Trigger** | Complete a game where 2+ players cross thresholds |
|
||||||
|
| **Expected result** | One embed per tier-up, posted sequentially in the game channel |
|
||||||
|
| **Pass criteria** | Each tier-up gets its own embed; no tier-ups are lost |
|
||||||
|
|
||||||
|
### REF-64: No notification when no tier-ups occur
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Most games will not produce any tier-ups; verify no spurious notifications |
|
||||||
|
| **Trigger** | Complete a game where no thresholds are crossed |
|
||||||
|
| **Expected result** | No tier-up embeds appear in the channel |
|
||||||
|
| **Pass criteria** | The only game-end messages are the standard game summary and rewards |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cross-Command Badge Propagation
|
||||||
|
|
||||||
|
These tests verify that tier badges appear (or correctly do not appear) in all
|
||||||
|
commands that display card information.
|
||||||
|
|
||||||
|
### REF-70: /roster command -- cards show tier badges
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/roster` or equivalent command that lists team cards |
|
||||||
|
| **Expected result** | If roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
|
||||||
|
| **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
|
||||||
|
|
||||||
|
### REF-71: /show-card defense (in-game) -- no badge expected
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | During an active game, the `/show-card defense` command uses `image_embed()` directly, NOT `get_card_embeds()` |
|
||||||
|
| **Discord command** | `/show-card defense position:Catcher` (during an active game) |
|
||||||
|
| **Expected result** | Card image is shown without a tier badge in the embed title |
|
||||||
|
| **Pass criteria** | This is EXPECTED behavior -- in-game card display does not fetch refractor state |
|
||||||
|
| **Notes** | This is a known limitation, not a bug. Document for future consideration. |
|
||||||
|
|
||||||
|
### REF-72: /scouting view -- badge on scouted cards
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Discord command** | `/scout {player_name}` (if the scouting cog uses get_card_embeds) |
|
||||||
|
| **Expected result** | If the scouting view calls get_card_embeds, badges should appear |
|
||||||
|
| **Pass criteria** | Verify whether scouting uses get_card_embeds or its own embed builder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Force-Evaluate Endpoint (Admin/Debug)
|
||||||
|
|
||||||
|
### REF-80: Force evaluate a single card
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Description** | Use the API to force-recalculate a card's refractor state |
|
||||||
|
| **Command** | `curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_BATTER}/evaluate" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected result** | JSON response with updated `current_tier`, `current_value` |
|
||||||
|
| **Pass criteria** | Response includes tier and value fields; no 500 error |
|
||||||
|
|
||||||
|
### REF-81: Force evaluate a card with no stats
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/${CARD_NO_STATS}/evaluate" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected result** | Either 404 or a response with `current_tier: 0` and `current_value: 0` |
|
||||||
|
| **Pass criteria** | No 500 error; graceful handling |
|
||||||
|
|
||||||
|
### REF-82: Force evaluate nonexistent card
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Command** | `curl -X POST "https://pddev.manticorum.com/api/v2/refractor/cards/999999/evaluate" -H "Authorization: Bearer $TOKEN"` |
|
||||||
|
| **Expected result** | HTTP 404 with `"Card 999999 not found"` |
|
||||||
|
| **Pass criteria** | Status 404; clear error message |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known Gaps and Risks
|
||||||
|
|
||||||
|
### ~~RESOLVED: Team-level refractor cards list endpoint~~
|
||||||
|
|
||||||
|
The `GET /api/v2/refractor/cards` list endpoint was added in database PR #173
|
||||||
|
(merged 2026-03-25). It accepts `team_id` (required), `card_type`, `tier`,
|
||||||
|
`season`, `progress`, `limit`, and `offset` query parameters. The response
|
||||||
|
includes `progress_pct` (computed) and `player_name` (via LEFT JOIN on Player).
|
||||||
|
Sorting: `current_tier` DESC, `current_value` DESC. A non-unique index on
|
||||||
|
`refractor_card_state.team_id` was added for query performance.
|
||||||
|
|
||||||
|
Test cases REF-API-06 through REF-API-10 now cover this endpoint directly.
|
||||||
|
|
||||||
|
### In-game card display does not show badges
|
||||||
|
|
||||||
|
The `/show-card defense` command in the gameplay cog uses `image_embed()` which
|
||||||
|
renders the card image directly. It does not call `get_card_embeds()` and
|
||||||
|
therefore does not fetch or display refractor tier badges. This is a design
|
||||||
|
decision, not a bug, but should be documented as a known limitation.
|
||||||
|
|
||||||
|
### Tier badge format inconsistency (by design)
|
||||||
|
|
||||||
|
Two `TIER_BADGES` dicts exist:
|
||||||
|
- `cogs/refractor.py`: `{1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}` (with brackets)
|
||||||
|
- `helpers/main.py`: `{1: "BC", 2: "R", 3: "GR", 4: "SF"}` (without brackets)
|
||||||
|
|
||||||
|
This is intentional -- `helpers/main.py` wraps the value in brackets when building
|
||||||
|
the embed title (`f"[{badge}] "`). The existing unit test
|
||||||
|
`TestTierBadgesFormatConsistency` in `test_card_embed_refractor.py` enforces this
|
||||||
|
contract. Both dicts must stay in sync.
|
||||||
|
|
||||||
|
### Notification delivery is non-fatal
|
||||||
|
|
||||||
|
The tier-up notification send is wrapped in `try/except`. If Discord's API has a
|
||||||
|
momentary error, the notification is lost silently (logged but not retried). There
|
||||||
|
is no notification queue or retry mechanism. This is acceptable for the current
|
||||||
|
design but means tier-up notifications are best-effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Execution Checklist
|
||||||
|
|
||||||
|
Run order for Playwright automation:
|
||||||
|
|
||||||
|
1. [~] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
|
||||||
|
- Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
|
||||||
|
- Not yet tested: REF-API-01, REF-API-02, REF-API-04, REF-API-05, REF-API-08, REF-API-09
|
||||||
|
2. [~] Execute REF-01 through REF-06 (basic /refractor status)
|
||||||
|
- Tested 2026-03-25: REF-01 (embed appears ✓), REF-02 (batter entry format ✓), REF-05 (tier badges [BC] ✓)
|
||||||
|
- Bugs found and fixed: wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
|
||||||
|
- Not yet tested: REF-03 (SP format), REF-04 (RP format), REF-06 (fully evolved)
|
||||||
|
3. [~] Execute REF-10 through REF-19 (filters)
|
||||||
|
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
|
||||||
|
- Choice dropdown menus added for all filter params (PR #126)
|
||||||
|
- Not yet tested: REF-11 through REF-19
|
||||||
|
4. [~] Execute REF-20 through REF-23 (pagination)
|
||||||
|
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
|
||||||
|
- Not yet tested: REF-21 (page 2), REF-22 (beyond total), REF-23 (page 0)
|
||||||
|
5. [ ] Execute REF-30 through REF-34 (edge cases)
|
||||||
|
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds)
|
||||||
|
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
|
||||||
|
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
|
||||||
|
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation)
|
||||||
|
10. [~] Execute REF-80 through REF-82 (force-evaluate API)
|
||||||
|
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
|
||||||
|
- Not yet tested: REF-81, REF-82
|
||||||
|
|
||||||
|
### Approximate Time Estimates
|
||||||
|
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
|
||||||
|
- /refractor status tests (REF-01 through REF-34): 10-15 minutes
|
||||||
|
- Tier badge tests (REF-40 through REF-45): 5-10 minutes
|
||||||
|
- Game simulation tests (REF-50 through REF-55): 15-30 minutes (depends on game length)
|
||||||
|
- Tier-up notification tests (REF-60 through REF-64): Requires setup; 10-20 minutes
|
||||||
|
- Cross-command tests (REF-70 through REF-72): 5 minutes
|
||||||
|
- Force-evaluate API tests (REF-80 through REF-82): 2-3 minutes
|
||||||
|
|
||||||
|
**Total estimated time**: 50-90 minutes for full suite (87 test cases)
|
||||||
22
tests/refractor-preflight.sh
Executable file
22
tests/refractor-preflight.sh
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# refractor-preflight.sh — run from workstation after dev deploy
|
||||||
|
# Verifies the Refractor system endpoints and bot health
|
||||||
|
|
||||||
|
echo "=== Dev API ==="
|
||||||
|
# Refractor endpoint exists (expect 401 = auth required)
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/refractor/cards/1")
|
||||||
|
[ "$STATUS" = "401" ] && echo "PASS: refractor/cards responds (401)" || echo "FAIL: refractor/cards ($STATUS, expected 401)"
|
||||||
|
|
||||||
|
# Old evolution endpoint removed (expect 404)
|
||||||
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/api/v2/evolution/cards/1")
|
||||||
|
[ "$STATUS" = "404" ] && echo "PASS: evolution/cards removed (404)" || echo "FAIL: evolution/cards ($STATUS, expected 404)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Discord Bot ==="
|
||||||
|
# Health check
|
||||||
|
curl -sf http://sba-bots:8080/health >/dev/null 2>&1 && echo "PASS: bot health OK" || echo "FAIL: bot health endpoint"
|
||||||
|
|
||||||
|
# Recent refractor activity in logs
|
||||||
|
echo ""
|
||||||
|
echo "=== Recent Bot Logs (refractor) ==="
|
||||||
|
ssh sba-bots "docker logs --since 10m paper-dynasty_discord-app_1 2>&1 | grep -i refract" || echo "(no recent refractor activity)"
|
||||||
298
tests/test_card_embed_refractor.py
Normal file
298
tests/test_card_embed_refractor.py
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
"""
|
||||||
|
Tests for WP-12: Tier Badge on Card Embed.
|
||||||
|
|
||||||
|
Verifies that get_card_embeds() prepends a tier badge to the card title when a
|
||||||
|
card has Refractor tier progression, and falls back gracefully when the Refractor
|
||||||
|
API is unavailable or returns no state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_card(card_id=1, player_name="Mike Trout", rarity_color="FFD700"):
|
||||||
|
"""Minimal card dict matching the API shape consumed by get_card_embeds."""
|
||||||
|
return {
|
||||||
|
"id": card_id,
|
||||||
|
"player": {
|
||||||
|
"player_id": 101,
|
||||||
|
"p_name": player_name,
|
||||||
|
"rarity": {"name": "MVP", "value": 5, "color": rarity_color},
|
||||||
|
"cost": 500,
|
||||||
|
"image": "https://example.com/card.png",
|
||||||
|
"image2": None,
|
||||||
|
"mlbclub": "Los Angeles Angels",
|
||||||
|
"franchise": "Los Angeles Angels",
|
||||||
|
"headshot": "https://example.com/headshot.jpg",
|
||||||
|
"cardset": {"name": "2023 Season"},
|
||||||
|
"pos_1": "CF",
|
||||||
|
"pos_2": None,
|
||||||
|
"pos_3": None,
|
||||||
|
"pos_4": None,
|
||||||
|
"pos_5": None,
|
||||||
|
"pos_6": None,
|
||||||
|
"pos_7": None,
|
||||||
|
"bbref_id": "troutmi01",
|
||||||
|
"strat_code": "420420",
|
||||||
|
"fangr_id": None,
|
||||||
|
"vanity_card": None,
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"id": 10,
|
||||||
|
"lname": "Paper Dynasty",
|
||||||
|
"logo": "https://example.com/logo.png",
|
||||||
|
"season": 7,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_paperdex():
|
||||||
|
"""Minimal paperdex response."""
|
||||||
|
return {"count": 0, "paperdex": []}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers to patch the async dependencies of get_card_embeds
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_db_get(evo_response=None, paperdex_response=None):
|
||||||
|
"""
|
||||||
|
Return a side_effect callable that routes db_get calls to the right mock
|
||||||
|
responses, so other get_card_embeds internals still behave.
|
||||||
|
"""
|
||||||
|
if paperdex_response is None:
|
||||||
|
paperdex_response = _make_paperdex()
|
||||||
|
|
||||||
|
async def _side_effect(endpoint, *args, **kwargs):
|
||||||
|
if str(endpoint).startswith("refractor/cards/"):
|
||||||
|
return evo_response
|
||||||
|
if endpoint == "paperdex":
|
||||||
|
return paperdex_response
|
||||||
|
# Fallback for any other endpoint (e.g. plays/batting, plays/pitching)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _side_effect
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierBadgeFormat:
|
||||||
|
"""Unit: tier badge string format for each tier level."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tier_zero_no_badge(self):
|
||||||
|
"""T0 evolution state (current_tier=0) should produce no badge in title."""
|
||||||
|
card = _make_card()
|
||||||
|
evo_state = {"current_tier": 0, "card_id": 1}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "Mike Trout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tier_one_badge(self):
|
||||||
|
"""current_tier=1 should prefix title with [BC] (Base Chrome)."""
|
||||||
|
card = _make_card()
|
||||||
|
evo_state = {"current_tier": 1, "card_id": 1}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "[BC] Mike Trout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tier_two_badge(self):
|
||||||
|
"""current_tier=2 should prefix title with [R] (Refractor)."""
|
||||||
|
card = _make_card()
|
||||||
|
evo_state = {"current_tier": 2, "card_id": 1}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "[R] Mike Trout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tier_three_badge(self):
|
||||||
|
"""current_tier=3 should prefix title with [GR] (Gold Refractor)."""
|
||||||
|
card = _make_card()
|
||||||
|
evo_state = {"current_tier": 3, "card_id": 1}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "[GR] Mike Trout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tier_four_superfractor_badge(self):
|
||||||
|
"""current_tier=4 (Superfractor) should prefix title with [SF]."""
|
||||||
|
card = _make_card()
|
||||||
|
evo_state = {"current_tier": 4, "card_id": 1}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "[SF] Mike Trout"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierBadgeInTitle:
|
||||||
|
"""Unit: badge appears correctly in the embed title."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_badge_prepended_to_player_name(self):
|
||||||
|
"""Badge should be prepended so title reads '[Tx] <player_name>'."""
|
||||||
|
card = _make_card(player_name="Juan Soto")
|
||||||
|
evo_state = {"current_tier": 2, "card_id": 1}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title.startswith("[R] ")
|
||||||
|
assert "Juan Soto" in embeds[0].title
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullyEvolvedBadge:
|
||||||
|
"""Unit: fully evolved card shows [SF] badge (Superfractor)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fully_evolved_badge(self):
|
||||||
|
"""T4 card should show [SF] prefix, not [T4]."""
|
||||||
|
card = _make_card()
|
||||||
|
evo_state = {"current_tier": 4}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title.startswith("[SF] ")
|
||||||
|
assert "[T4]" not in embeds[0].title
|
||||||
|
|
||||||
|
|
||||||
|
class TestNoBadgeGracefulFallback:
|
||||||
|
"""Unit: embed renders correctly when evolution state is absent or API fails."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_evolution_state_no_badge(self):
|
||||||
|
"""When evolution API returns None (404), title has no badge."""
|
||||||
|
card = _make_card()
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=None)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "Mike Trout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_exception_no_badge(self):
|
||||||
|
"""When evolution API raises an exception, card display is unaffected."""
|
||||||
|
card = _make_card()
|
||||||
|
|
||||||
|
async def _failing_db_get(endpoint, *args, **kwargs):
|
||||||
|
if str(endpoint).startswith("refractor/cards/"):
|
||||||
|
raise ConnectionError("API unreachable")
|
||||||
|
if endpoint == "paperdex":
|
||||||
|
return _make_paperdex()
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _failing_db_get
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].title == "Mike Trout"
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmbedColorUnchanged:
|
||||||
|
"""Unit: embed color comes from card rarity, not affected by evolution state."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embed_color_from_rarity_with_evolution(self):
|
||||||
|
"""Color is still derived from rarity even when a tier badge is present."""
|
||||||
|
rarity_color = "FF0000"
|
||||||
|
card = _make_card(rarity_color=rarity_color)
|
||||||
|
evo_state = {"current_tier": 2}
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=evo_state)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].color == discord.Color(int(rarity_color, 16))
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embed_color_from_rarity_without_evolution(self):
|
||||||
|
"""Color is derived from rarity when no evolution state exists."""
|
||||||
|
rarity_color = "00FF00"
|
||||||
|
card = _make_card(rarity_color=rarity_color)
|
||||||
|
|
||||||
|
with patch("helpers.main.db_get", new_callable=AsyncMock) as mock_db:
|
||||||
|
mock_db.side_effect = _patch_db_get(evo_response=None)
|
||||||
|
embeds = await _call_get_card_embeds(card)
|
||||||
|
|
||||||
|
assert embeds[0].color == discord.Color(int(rarity_color, 16))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T1-7: TIER_BADGES format consistency check across modules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierSymbolsCompleteness:
|
||||||
|
"""
|
||||||
|
T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
|
||||||
|
and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
|
||||||
|
|
||||||
|
Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
|
||||||
|
while card embed titles use helpers.main TIER_BADGES in bracket format.
|
||||||
|
Both must cover the full tier range for their respective contexts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_tier_symbols_covers_all_tiers(self):
|
||||||
|
"""TIER_SYMBOLS must have entries for T0 through T4."""
|
||||||
|
from cogs.refractor import TIER_SYMBOLS
|
||||||
|
|
||||||
|
for tier in range(5):
|
||||||
|
assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
|
||||||
|
|
||||||
|
def test_tier_badges_covers_evolved_tiers(self):
|
||||||
|
"""helpers.main TIER_BADGES must have entries for T1 through T4."""
|
||||||
|
from helpers.main import TIER_BADGES
|
||||||
|
|
||||||
|
for tier in range(1, 5):
|
||||||
|
assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
|
||||||
|
|
||||||
|
def test_tier_symbols_are_unique(self):
|
||||||
|
"""Each tier must have a distinct symbol."""
|
||||||
|
from cogs.refractor import TIER_SYMBOLS
|
||||||
|
|
||||||
|
values = list(TIER_SYMBOLS.values())
|
||||||
|
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: call get_card_embeds and return embed list
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_get_card_embeds(card):
|
||||||
|
"""Import and call get_card_embeds, returning the list of embeds."""
|
||||||
|
from helpers.main import get_card_embeds
|
||||||
|
|
||||||
|
result = await get_card_embeds(card)
|
||||||
|
if isinstance(result, list):
|
||||||
|
return result
|
||||||
|
return [result]
|
||||||
205
tests/test_complete_game_hook.py
Normal file
205
tests/test_complete_game_hook.py
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Tests for the WP-13 post-game callback integration hook.
|
||||||
|
|
||||||
|
These tests verify that after a game is saved to the API, two additional
|
||||||
|
POST requests are fired in the correct order:
|
||||||
|
1. POST season-stats/update-game/{game_id} — update player_season_stats
|
||||||
|
2. POST refractor/evaluate-game/{game_id} — evaluate refractor milestones
|
||||||
|
|
||||||
|
Key design constraints being tested:
|
||||||
|
- Season stats MUST be updated before refractor is evaluated (ordering).
|
||||||
|
- Failure of either refractor call must NOT propagate — the game result has
|
||||||
|
already been committed; refractor will self-heal on the next evaluate pass.
|
||||||
|
- Tier-up dicts returned by the refractor endpoint are passed to
|
||||||
|
notify_tier_completion so WP-14 can present them to the player.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_channel(channel_id: int = 999) -> MagicMock:
|
||||||
|
ch = MagicMock()
|
||||||
|
ch.id = channel_id
|
||||||
|
return ch
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_hook(db_post_mock, db_game_id: int = 42):
|
||||||
|
"""
|
||||||
|
Execute the post-game hook in isolation.
|
||||||
|
|
||||||
|
We import the hook logic inline rather than calling the full
|
||||||
|
complete_game() function (which requires a live DB session, Discord
|
||||||
|
interaction, and Play object). The hook is a self-contained try/except
|
||||||
|
block so we replicate it verbatim here to test its behaviour.
|
||||||
|
"""
|
||||||
|
channel = _make_channel()
|
||||||
|
from command_logic.logic_gameplay import notify_tier_completion
|
||||||
|
|
||||||
|
db_game = {"id": db_game_id}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
except Exception:
|
||||||
|
pass # non-fatal — mirrors the logger.warning in production
|
||||||
|
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_posts_to_both_endpoints_in_order():
|
||||||
|
"""
|
||||||
|
Both refractor endpoints are called, and season-stats comes first.
|
||||||
|
|
||||||
|
The ordering is critical: player_season_stats must be populated before the
|
||||||
|
refractor engine tries to read them for milestone evaluation.
|
||||||
|
"""
|
||||||
|
db_post_mock = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
await _run_hook(db_post_mock, db_game_id=42)
|
||||||
|
|
||||||
|
assert db_post_mock.call_count == 2
|
||||||
|
calls = db_post_mock.call_args_list
|
||||||
|
# First call must be season-stats
|
||||||
|
assert calls[0] == call("season-stats/update-game/42")
|
||||||
|
# Second call must be refractor evaluate
|
||||||
|
assert calls[1] == call("refractor/evaluate-game/42")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_is_nonfatal_when_db_post_raises():
|
||||||
|
"""
|
||||||
|
A failure inside the hook must not raise to the caller.
|
||||||
|
|
||||||
|
The game result is already persisted when the hook runs. If the refractor
|
||||||
|
API is down or returns an error, we log a warning and continue — the game
|
||||||
|
completion flow must not be interrupted.
|
||||||
|
"""
|
||||||
|
db_post_mock = AsyncMock(side_effect=Exception("refractor API unavailable"))
|
||||||
|
|
||||||
|
# Should not raise
|
||||||
|
try:
|
||||||
|
await _run_hook(db_post_mock, db_game_id=7)
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.fail(f"Hook raised unexpectedly: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_processes_tier_ups_from_evo_result():
|
||||||
|
"""
|
||||||
|
When the refractor endpoint returns tier_ups, each entry is forwarded to
|
||||||
|
notify_tier_completion.
|
||||||
|
|
||||||
|
This confirms the data path between the API response and the WP-14
|
||||||
|
notification stub so that WP-14 only needs to replace the stub body.
|
||||||
|
"""
|
||||||
|
tier_ups = [
|
||||||
|
{"player_id": 101, "old_tier": 1, "new_tier": 2},
|
||||||
|
{"player_id": 202, "old_tier": 2, "new_tier": 3},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def fake_db_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": tier_ups}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
db_post_mock = AsyncMock(side_effect=fake_db_post)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify:
|
||||||
|
channel = _make_channel()
|
||||||
|
db_game = {"id": 99}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await mock_notify(channel, tier_up)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert mock_notify.call_count == 2
|
||||||
|
# Verify both tier_up dicts were forwarded
|
||||||
|
forwarded = [c.args[1] for c in mock_notify.call_args_list]
|
||||||
|
assert {"player_id": 101, "old_tier": 1, "new_tier": 2} in forwarded
|
||||||
|
assert {"player_id": 202, "old_tier": 2, "new_tier": 3} in forwarded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_no_tier_ups_does_not_call_notify():
|
||||||
|
"""
|
||||||
|
When the refractor response has no tier_ups (empty list or missing key),
|
||||||
|
notify_tier_completion is never called.
|
||||||
|
|
||||||
|
Avoids spurious Discord messages for routine game completions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def fake_db_post(endpoint):
|
||||||
|
if "refractor" in endpoint:
|
||||||
|
return {"tier_ups": []}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
db_post_mock = AsyncMock(side_effect=fake_db_post)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"command_logic.logic_gameplay.notify_tier_completion",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_notify:
|
||||||
|
channel = _make_channel()
|
||||||
|
db_game = {"id": 55}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await db_post_mock(f"season-stats/update-game/{db_game['id']}")
|
||||||
|
evo_result = await db_post_mock(f"refractor/evaluate-game/{db_game['id']}")
|
||||||
|
if evo_result and evo_result.get("tier_ups"):
|
||||||
|
for tier_up in evo_result["tier_ups"]:
|
||||||
|
await mock_notify(channel, tier_up)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notify_tier_completion_sends_embed_and_does_not_raise():
|
||||||
|
"""
|
||||||
|
notify_tier_completion sends a Discord embed and does not raise.
|
||||||
|
|
||||||
|
Now that WP-14 is wired, the function imported via logic_gameplay is the
|
||||||
|
real embed-sending implementation from helpers.refractor_notifs.
|
||||||
|
"""
|
||||||
|
from command_logic.logic_gameplay import notify_tier_completion
|
||||||
|
|
||||||
|
channel = AsyncMock()
|
||||||
|
# Full API response shape — the evaluate-game endpoint returns all these keys
|
||||||
|
tier_up = {
|
||||||
|
"player_id": 77,
|
||||||
|
"team_id": 1,
|
||||||
|
"player_name": "Mike Trout",
|
||||||
|
"old_tier": 0,
|
||||||
|
"new_tier": 1,
|
||||||
|
"current_value": 45.0,
|
||||||
|
"track_name": "Batter Track",
|
||||||
|
}
|
||||||
|
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
|
||||||
|
channel.send.assert_called_once()
|
||||||
|
embed = channel.send.call_args.kwargs["embed"]
|
||||||
|
assert "Mike Trout" in embed.description
|
||||||
740
tests/test_refractor_commands.py
Normal file
740
tests/test_refractor_commands.py
Normal file
@ -0,0 +1,740 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for refractor command helper functions (WP-11).
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- render_progress_bar: ASCII bar rendering at various fill levels
|
||||||
|
- format_refractor_entry: Full card state formatting including fully evolved case
|
||||||
|
- apply_close_filter: 80% proximity filter logic
|
||||||
|
- paginate: 1-indexed page slicing and total-page calculation
|
||||||
|
- TIER_NAMES: Display names for all tiers
|
||||||
|
- Slash command: empty roster and no-team responses (async, uses mocks)
|
||||||
|
|
||||||
|
All tests are pure-unit unless marked otherwise; no network calls are made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
# Make the repo root importable
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from cogs.refractor import (
|
||||||
|
render_progress_bar,
|
||||||
|
format_refractor_entry,
|
||||||
|
apply_close_filter,
|
||||||
|
paginate,
|
||||||
|
TIER_NAMES,
|
||||||
|
TIER_SYMBOLS,
|
||||||
|
PAGE_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def batter_state():
|
||||||
|
"""A mid-progress batter card state (API response shape)."""
|
||||||
|
return {
|
||||||
|
"player_name": "Mike Trout",
|
||||||
|
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
||||||
|
"current_tier": 1,
|
||||||
|
"current_value": 120,
|
||||||
|
"next_threshold": 149,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def evolved_state():
|
||||||
|
"""A fully evolved card state (T4)."""
|
||||||
|
return {
|
||||||
|
"player_name": "Shohei Ohtani",
|
||||||
|
"track": {"card_type": "batter", "formula": "pa + tb * 2"},
|
||||||
|
"current_tier": 4,
|
||||||
|
"current_value": 300,
|
||||||
|
"next_threshold": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sp_state():
|
||||||
|
"""A starting pitcher card state at T2."""
|
||||||
|
return {
|
||||||
|
"player_name": "Sandy Alcantara",
|
||||||
|
"track": {"card_type": "sp", "formula": "ip + k"},
|
||||||
|
"current_tier": 2,
|
||||||
|
"current_value": 95,
|
||||||
|
"next_threshold": 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# render_progress_bar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderProgressBar:
|
||||||
|
"""
|
||||||
|
Tests for render_progress_bar().
|
||||||
|
|
||||||
|
Verifies width, fill character, empty character, boundary conditions,
|
||||||
|
and clamping when current exceeds threshold. Default width is 12.
|
||||||
|
Uses Unicode block chars: ▰ (filled) and ▱ (empty).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_empty_bar(self):
|
||||||
|
"""current=0 → all empty blocks."""
|
||||||
|
assert render_progress_bar(0, 100) == "▱" * 12
|
||||||
|
|
||||||
|
def test_full_bar(self):
|
||||||
|
"""current == threshold → all filled blocks."""
|
||||||
|
assert render_progress_bar(100, 100) == "▰" * 12
|
||||||
|
|
||||||
|
def test_partial_fill(self):
|
||||||
|
"""120/149 ≈ 80.5% → ~10 filled of 12."""
|
||||||
|
bar = render_progress_bar(120, 149)
|
||||||
|
filled = bar.count("▰")
|
||||||
|
empty = bar.count("▱")
|
||||||
|
assert filled + empty == 12
|
||||||
|
assert filled == 10 # round(0.805 * 12) = 10
|
||||||
|
|
||||||
|
def test_half_fill(self):
|
||||||
|
"""50/100 = 50% → 6 filled."""
|
||||||
|
bar = render_progress_bar(50, 100)
|
||||||
|
assert bar.count("▰") == 6
|
||||||
|
assert bar.count("▱") == 6
|
||||||
|
|
||||||
|
def test_over_threshold_clamps_to_full(self):
|
||||||
|
"""current > threshold should not overflow the bar."""
|
||||||
|
assert render_progress_bar(200, 100) == "▰" * 12
|
||||||
|
|
||||||
|
def test_zero_threshold_returns_full_bar(self):
|
||||||
|
"""threshold=0 avoids division by zero and returns full bar."""
|
||||||
|
assert render_progress_bar(0, 0) == "▰" * 12
|
||||||
|
|
||||||
|
def test_custom_width(self):
|
||||||
|
"""Width parameter controls bar length."""
|
||||||
|
bar = render_progress_bar(5, 10, width=4)
|
||||||
|
assert bar == "▰▰▱▱"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# format_refractor_entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatRefractorEntry:
|
||||||
|
"""
|
||||||
|
Tests for format_refractor_entry().
|
||||||
|
|
||||||
|
Verifies player name, tier label, progress bar, formula label,
|
||||||
|
and the special fully-evolved formatting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_player_name_in_output(self, batter_state):
|
||||||
|
"""Player name appears bold in the first line (badge may prefix it)."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
assert "Mike Trout" in result
|
||||||
|
assert "**" in result
|
||||||
|
|
||||||
|
def test_tier_label_in_output(self, batter_state):
|
||||||
|
"""Current tier name (Base Chrome for T1) appears in output."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
assert "Base Chrome" in result
|
||||||
|
|
||||||
|
def test_progress_values_in_output(self, batter_state):
|
||||||
|
"""current/threshold values appear in output."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
assert "120/149" in result
|
||||||
|
|
||||||
|
def test_percentage_in_output(self, batter_state):
|
||||||
|
"""Percentage appears in parentheses in output."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
assert "(80%)" in result or "(81%)" in result
|
||||||
|
|
||||||
|
def test_fully_evolved_no_threshold(self, evolved_state):
|
||||||
|
"""T4 card with next_threshold=None shows MAX."""
|
||||||
|
result = format_refractor_entry(evolved_state)
|
||||||
|
assert "`MAX`" in result
|
||||||
|
|
||||||
|
def test_fully_evolved_by_tier(self, batter_state):
|
||||||
|
"""current_tier=4 triggers fully evolved display even with a threshold."""
|
||||||
|
batter_state["current_tier"] = 4
|
||||||
|
batter_state["next_threshold"] = 200
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
assert "`MAX`" in result
|
||||||
|
|
||||||
|
def test_two_line_output(self, batter_state):
|
||||||
|
"""Output always has exactly two lines (name line + bar line)."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert len(lines) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TIER_BADGES
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierSymbols:
|
||||||
|
"""
|
||||||
|
Verify TIER_SYMBOLS values and that format_refractor_entry prepends
|
||||||
|
the correct label for each tier. Labels use short readable text (T0-T4).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_t0_symbol(self):
|
||||||
|
"""T0 label is empty (base cards get no prefix)."""
|
||||||
|
assert TIER_SYMBOLS[0] == "Base"
|
||||||
|
|
||||||
|
def test_t1_symbol(self):
|
||||||
|
"""T1 label is 'T1'."""
|
||||||
|
assert TIER_SYMBOLS[1] == "T1"
|
||||||
|
|
||||||
|
def test_t2_symbol(self):
|
||||||
|
"""T2 label is 'T2'."""
|
||||||
|
assert TIER_SYMBOLS[2] == "T2"
|
||||||
|
|
||||||
|
def test_t3_symbol(self):
|
||||||
|
"""T3 label is 'T3'."""
|
||||||
|
assert TIER_SYMBOLS[3] == "T3"
|
||||||
|
|
||||||
|
def test_t4_symbol(self):
|
||||||
|
"""T4 label is 'T4★'."""
|
||||||
|
assert TIER_SYMBOLS[4] == "T4★"
|
||||||
|
|
||||||
|
def test_format_entry_t1_suffix_tag(self, batter_state):
|
||||||
|
"""T1 cards show [T1] suffix tag after the tier name."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
assert "[T1]" in result
|
||||||
|
|
||||||
|
def test_format_entry_t2_suffix_tag(self, sp_state):
|
||||||
|
"""T2 cards show [T2] suffix tag."""
|
||||||
|
result = format_refractor_entry(sp_state)
|
||||||
|
assert "[T2]" in result
|
||||||
|
|
||||||
|
def test_format_entry_t4_suffix_tag(self, evolved_state):
|
||||||
|
"""T4 cards show [T4★] suffix tag."""
|
||||||
|
result = format_refractor_entry(evolved_state)
|
||||||
|
assert "[T4★]" in result
|
||||||
|
|
||||||
|
def test_format_entry_t0_name_only(self):
|
||||||
|
"""T0 cards show just the bold name, no tier suffix."""
|
||||||
|
state = {
|
||||||
|
"player_name": "Rookie Player",
|
||||||
|
"current_tier": 0,
|
||||||
|
"current_value": 10,
|
||||||
|
"next_threshold": 50,
|
||||||
|
}
|
||||||
|
result = format_refractor_entry(state)
|
||||||
|
first_line = result.split("\n")[0]
|
||||||
|
assert first_line == "**Rookie Player**"
|
||||||
|
|
||||||
|
def test_format_entry_tag_after_name(self, batter_state):
|
||||||
|
"""Tag appears after the player name in the first line."""
|
||||||
|
result = format_refractor_entry(batter_state)
|
||||||
|
first_line = result.split("\n")[0]
|
||||||
|
name_pos = first_line.find("Mike Trout")
|
||||||
|
tag_pos = first_line.find("[T1]")
|
||||||
|
assert name_pos < tag_pos
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# apply_close_filter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyCloseFilter:
|
||||||
|
"""
|
||||||
|
Tests for apply_close_filter().
|
||||||
|
|
||||||
|
'Close' means formula_value >= 80% of next_threshold.
|
||||||
|
Fully evolved (T4 or no threshold) cards are excluded from results.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_close_card_included(self):
|
||||||
|
"""Card at exactly 80% is included."""
|
||||||
|
state = {"current_tier": 1, "current_value": 80, "next_threshold": 100}
|
||||||
|
assert apply_close_filter([state]) == [state]
|
||||||
|
|
||||||
|
def test_above_80_percent_included(self):
|
||||||
|
"""Card above 80% is included."""
|
||||||
|
state = {"current_tier": 0, "current_value": 95, "next_threshold": 100}
|
||||||
|
assert apply_close_filter([state]) == [state]
|
||||||
|
|
||||||
|
def test_below_80_percent_excluded(self):
|
||||||
|
"""Card below 80% threshold is excluded."""
|
||||||
|
state = {"current_tier": 1, "current_value": 79, "next_threshold": 100}
|
||||||
|
assert apply_close_filter([state]) == []
|
||||||
|
|
||||||
|
def test_fully_evolved_excluded(self):
|
||||||
|
"""T4 cards are never returned by close filter."""
|
||||||
|
state = {"current_tier": 4, "current_value": 300, "next_threshold": None}
|
||||||
|
assert apply_close_filter([state]) == []
|
||||||
|
|
||||||
|
def test_none_threshold_excluded(self):
|
||||||
|
"""Cards with no next_threshold (regardless of tier) are excluded."""
|
||||||
|
state = {"current_tier": 3, "current_value": 200, "next_threshold": None}
|
||||||
|
assert apply_close_filter([state]) == []
|
||||||
|
|
||||||
|
def test_mixed_list(self):
|
||||||
|
"""Only qualifying cards are returned from a mixed list."""
|
||||||
|
close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
|
||||||
|
not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
|
||||||
|
evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
|
||||||
|
result = apply_close_filter([close, not_close, evolved])
|
||||||
|
assert result == [close]
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
"""Empty input returns empty list."""
|
||||||
|
assert apply_close_filter([]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# paginate
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaginate:
|
||||||
|
"""
|
||||||
|
Tests for paginate().
|
||||||
|
|
||||||
|
Verifies 1-indexed page slicing, total page count calculation,
|
||||||
|
page clamping, and PAGE_SIZE default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _items(self, n):
|
||||||
|
return list(range(n))
|
||||||
|
|
||||||
|
def test_single_page_all_items(self):
|
||||||
|
"""Fewer items than page size returns all on page 1."""
|
||||||
|
items, total = paginate(self._items(5), page=1)
|
||||||
|
assert items == [0, 1, 2, 3, 4]
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
def test_first_page(self):
|
||||||
|
"""Page 1 returns first PAGE_SIZE items."""
|
||||||
|
items, total = paginate(self._items(25), page=1)
|
||||||
|
assert items == list(range(10))
|
||||||
|
assert total == 3
|
||||||
|
|
||||||
|
def test_second_page(self):
|
||||||
|
"""Page 2 returns next PAGE_SIZE items."""
|
||||||
|
items, total = paginate(self._items(25), page=2)
|
||||||
|
assert items == list(range(10, 20))
|
||||||
|
|
||||||
|
def test_last_page_partial(self):
|
||||||
|
"""Last page returns remaining items (fewer than PAGE_SIZE)."""
|
||||||
|
items, total = paginate(self._items(25), page=3)
|
||||||
|
assert items == [20, 21, 22, 23, 24]
|
||||||
|
assert total == 3
|
||||||
|
|
||||||
|
def test_page_clamp_low(self):
|
||||||
|
"""Page 0 or negative is clamped to page 1."""
|
||||||
|
items, _ = paginate(self._items(15), page=0)
|
||||||
|
assert items == list(range(10))
|
||||||
|
|
||||||
|
def test_page_clamp_high(self):
|
||||||
|
"""Page beyond total is clamped to last page."""
|
||||||
|
items, total = paginate(self._items(15), page=99)
|
||||||
|
assert items == [10, 11, 12, 13, 14]
|
||||||
|
assert total == 2
|
||||||
|
|
||||||
|
def test_empty_list_returns_empty_page(self):
|
||||||
|
"""Empty input returns empty page with total_pages=1."""
|
||||||
|
items, total = paginate([], page=1)
|
||||||
|
assert items == []
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
def test_exact_page_boundary(self):
|
||||||
|
"""Exactly PAGE_SIZE items → 1 full page."""
|
||||||
|
items, total = paginate(self._items(PAGE_SIZE), page=1)
|
||||||
|
assert len(items) == PAGE_SIZE
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TIER_NAMES
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierNames:
|
||||||
|
"""
|
||||||
|
Verify all tier display names are correctly defined.
|
||||||
|
|
||||||
|
T0=Base Card, T1=Base Chrome, T2=Refractor, T3=Gold Refractor, T4=Superfractor
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_t0_base_card(self):
|
||||||
|
assert TIER_NAMES[0] == "Base Card"
|
||||||
|
|
||||||
|
def test_t1_base_chrome(self):
|
||||||
|
assert TIER_NAMES[1] == "Base Chrome"
|
||||||
|
|
||||||
|
def test_t2_refractor(self):
|
||||||
|
assert TIER_NAMES[2] == "Refractor"
|
||||||
|
|
||||||
|
def test_t3_gold_refractor(self):
|
||||||
|
assert TIER_NAMES[3] == "Gold Refractor"
|
||||||
|
|
||||||
|
def test_t4_superfractor(self):
|
||||||
|
assert TIER_NAMES[4] == "Superfractor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slash command: empty roster / no-team scenarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_bot():
|
||||||
|
bot = AsyncMock(spec=commands.Bot)
|
||||||
|
return bot
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interaction():
|
||||||
|
interaction = AsyncMock(spec=discord.Interaction)
|
||||||
|
interaction.response = AsyncMock()
|
||||||
|
interaction.response.defer = AsyncMock()
|
||||||
|
interaction.edit_original_response = AsyncMock()
|
||||||
|
interaction.user = Mock()
|
||||||
|
interaction.user.id = 12345
|
||||||
|
return interaction
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T1-6: TIER_NAMES duplication divergence check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierNamesDivergenceCheck:
|
||||||
|
"""
|
||||||
|
T1-6: Assert that TIER_NAMES in cogs.refractor and helpers.refractor_notifs
|
||||||
|
are identical (same keys, same values).
|
||||||
|
|
||||||
|
Why: TIER_NAMES is duplicated in two modules. If one is updated and the
|
||||||
|
other is not (e.g. a tier is renamed or a new tier is added), tier labels
|
||||||
|
in the /refractor status embed and the tier-up notification embed will
|
||||||
|
diverge silently. This test acts as a divergence tripwire — it will fail
|
||||||
|
the moment the two copies fall out of sync, forcing an explicit fix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_tier_names_are_identical_across_modules(self):
|
||||||
|
"""
|
||||||
|
Import TIER_NAMES from both modules and assert deep equality.
|
||||||
|
|
||||||
|
The test imports the name at call-time rather than at module level to
|
||||||
|
ensure it always reads the current definition and is not affected by
|
||||||
|
module-level caching or monkeypatching in other tests.
|
||||||
|
"""
|
||||||
|
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||||
|
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||||
|
|
||||||
|
assert cog_tier_names == notifs_tier_names, (
|
||||||
|
"TIER_NAMES differs between cogs.refractor and helpers.refractor_notifs. "
|
||||||
|
"Both copies must be kept in sync. "
|
||||||
|
f"cogs.refractor: {cog_tier_names!r} "
|
||||||
|
f"helpers.refractor_notifs: {notifs_tier_names!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tier_names_have_same_keys(self):
|
||||||
|
"""Keys (tier numbers) must be identical in both modules."""
|
||||||
|
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||||
|
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||||
|
|
||||||
|
assert set(cog_tier_names.keys()) == set(notifs_tier_names.keys()), (
|
||||||
|
"TIER_NAMES key sets differ between modules."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_tier_names_have_same_values(self):
|
||||||
|
"""Display strings (values) must be identical for every shared key."""
|
||||||
|
from cogs.refractor import TIER_NAMES as cog_tier_names
|
||||||
|
from helpers.refractor_notifs import TIER_NAMES as notifs_tier_names
|
||||||
|
|
||||||
|
for tier, name in cog_tier_names.items():
|
||||||
|
assert notifs_tier_names.get(tier) == name, (
|
||||||
|
f"Tier {tier} name mismatch: "
|
||||||
|
f"cogs.refractor={name!r}, "
|
||||||
|
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T2-8: Filter combination — tier=4 + progress="close" yields empty result
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyCloseFilterWithAllT4Cards:
|
||||||
|
"""
|
||||||
|
T2-8: When all cards in the list are T4 (fully evolved), apply_close_filter
|
||||||
|
must return an empty list.
|
||||||
|
|
||||||
|
Why: T4 cards have no next tier to advance to, so they have no threshold.
|
||||||
|
The close filter explicitly excludes fully evolved cards (tier >= 4 or
|
||||||
|
next_threshold is None). If a user passes both tier=4 and progress="close"
|
||||||
|
to /refractor status, the combined result should be empty — the command
|
||||||
|
already handles this by showing "No cards are currently close to a tier
|
||||||
|
advancement." This test documents and protects that behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_all_t4_cards_returns_empty(self):
|
||||||
|
"""
|
||||||
|
A list of T4-only card states should produce an empty result from
|
||||||
|
apply_close_filter, because T4 cards are fully evolved and have no
|
||||||
|
next threshold to be "close" to.
|
||||||
|
|
||||||
|
This is the intended behaviour when tier=4 and progress="close" are
|
||||||
|
combined: there are no qualifying cards, and the command should show
|
||||||
|
the "no cards close to advancement" message rather than an empty embed.
|
||||||
|
"""
|
||||||
|
t4_cards = [
|
||||||
|
{"current_tier": 4, "current_value": 300, "next_threshold": None},
|
||||||
|
{"current_tier": 4, "current_value": 500, "next_threshold": None},
|
||||||
|
{"current_tier": 4, "current_value": 275, "next_threshold": None},
|
||||||
|
]
|
||||||
|
result = apply_close_filter(t4_cards)
|
||||||
|
assert result == [], (
|
||||||
|
"apply_close_filter must return [] for fully evolved T4 cards — "
|
||||||
|
"they have no next threshold and cannot be 'close' to advancement."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_t4_cards_excluded_even_with_high_formula_value(self):
|
||||||
|
"""
|
||||||
|
T4 cards are excluded regardless of their formula_value, since the
|
||||||
|
filter is based on tier (>= 4) and threshold (None), not raw values.
|
||||||
|
"""
|
||||||
|
t4_high_value = {
|
||||||
|
"current_tier": 4,
|
||||||
|
"current_value": 9999,
|
||||||
|
"next_threshold": None,
|
||||||
|
}
|
||||||
|
assert apply_close_filter([t4_high_value]) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T3-2: Malformed API response handling in format_refractor_entry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatRefractorEntryMalformedInput:
|
||||||
|
"""
|
||||||
|
T3-2: format_refractor_entry should not crash when given a card state dict
|
||||||
|
that is missing expected keys.
|
||||||
|
|
||||||
|
Why: API responses can be incomplete due to migration states, partially
|
||||||
|
populated records, or future schema changes. format_refractor_entry uses
|
||||||
|
.get() with fallbacks for all keys, so missing fields should gracefully
|
||||||
|
degrade to sensible defaults ("Unknown" for name, 0 for values) rather than
|
||||||
|
raising a KeyError or TypeError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_missing_player_name_uses_unknown(self):
|
||||||
|
"""
|
||||||
|
When player_name is absent, the output should contain "Unknown" rather
|
||||||
|
than crashing with a KeyError.
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"track": {"card_type": "batter"},
|
||||||
|
"current_tier": 1,
|
||||||
|
"current_value": 100,
|
||||||
|
"next_threshold": 150,
|
||||||
|
}
|
||||||
|
result = format_refractor_entry(state)
|
||||||
|
assert "Unknown" in result
|
||||||
|
|
||||||
|
def test_missing_formula_value_uses_zero(self):
|
||||||
|
"""
|
||||||
|
When current_value is absent, the progress calculation should use 0
|
||||||
|
without raising a TypeError.
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"player_name": "Test Player",
|
||||||
|
"track": {"card_type": "batter"},
|
||||||
|
"current_tier": 1,
|
||||||
|
"next_threshold": 150,
|
||||||
|
}
|
||||||
|
result = format_refractor_entry(state)
|
||||||
|
assert "0/150" in result
|
||||||
|
|
||||||
|
def test_completely_empty_dict_does_not_crash(self):
|
||||||
|
"""
|
||||||
|
An entirely empty dict should produce a valid (if sparse) string using
|
||||||
|
all fallback values, not raise any exception.
|
||||||
|
"""
|
||||||
|
result = format_refractor_entry({})
|
||||||
|
# Should not raise; output should be a string with two lines
|
||||||
|
assert isinstance(result, str)
|
||||||
|
lines = result.split("\n")
|
||||||
|
assert len(lines) == 2
|
||||||
|
|
||||||
|
def test_missing_card_type_does_not_crash(self):
|
||||||
|
"""
|
||||||
|
When card_type is absent from the track, the code should still
|
||||||
|
produce a valid two-line output without crashing.
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"player_name": "Test Player",
|
||||||
|
"current_tier": 1,
|
||||||
|
"current_value": 50,
|
||||||
|
"next_threshold": 100,
|
||||||
|
}
|
||||||
|
result = format_refractor_entry(state)
|
||||||
|
assert "50/100" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T3-3: Progress bar boundary precision
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderProgressBarBoundaryPrecision:
|
||||||
|
"""
|
||||||
|
T3-3: Verify the progress bar behaves correctly at edge values — near zero,
|
||||||
|
near full, exactly at extremes, and for negative input.
|
||||||
|
|
||||||
|
Why: Off-by-one errors in rounding or integer truncation can make a nearly-
|
||||||
|
full bar look full (or vice versa), confusing users about how close their
|
||||||
|
card is to a tier advancement. Defensive handling of negative values ensures
|
||||||
|
no bar is rendered longer than its declared width.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_one_of_hundred_shows_mostly_empty(self):
|
||||||
|
"""
|
||||||
|
1/100 = 1% — should produce a bar with 0 or 1 filled segment and the
|
||||||
|
rest empty. The bar must not appear more than minimally filled.
|
||||||
|
"""
|
||||||
|
bar = render_progress_bar(1, 100)
|
||||||
|
filled_count = bar.count("▰")
|
||||||
|
assert filled_count <= 1, (
|
||||||
|
f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ninety_nine_of_hundred_is_nearly_full(self):
|
||||||
|
"""
|
||||||
|
99/100 = 99% — should produce a bar with 11 or 12 filled segments.
|
||||||
|
The bar must NOT be completely empty or show fewer than 11 filled.
|
||||||
|
"""
|
||||||
|
bar = render_progress_bar(99, 100)
|
||||||
|
filled_count = bar.count("▰")
|
||||||
|
assert filled_count >= 11, (
|
||||||
|
f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
|
||||||
|
)
|
||||||
|
# Bar width must be exactly 12
|
||||||
|
assert len(bar) == 12
|
||||||
|
|
||||||
|
def test_zero_of_hundred_is_completely_empty(self):
|
||||||
|
"""0/100 = all empty blocks — re-verify the all-empty baseline."""
|
||||||
|
assert render_progress_bar(0, 100) == "▱" * 12
|
||||||
|
|
||||||
|
def test_negative_current_does_not_overflow_bar(self):
|
||||||
|
"""
|
||||||
|
A negative formula_value (data anomaly) must not produce a bar with
|
||||||
|
more filled segments than the width. The min(..., 1.0) clamp in
|
||||||
|
render_progress_bar should handle this, but this test guards against
|
||||||
|
a future refactor removing the clamp.
|
||||||
|
"""
|
||||||
|
bar = render_progress_bar(-5, 100)
|
||||||
|
filled_count = bar.count("▰")
|
||||||
|
assert filled_count == 0, (
|
||||||
|
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
|
||||||
|
)
|
||||||
|
# Bar width must be exactly 12
|
||||||
|
assert len(bar) == 12
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T3-4: RP formula label
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCardTypeVariants:
|
||||||
|
"""
|
||||||
|
T3-4/T3-5: Verify that format_refractor_entry produces valid output for
|
||||||
|
all card types including unknown ones, without crashing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_rp_card_produces_valid_output(self):
|
||||||
|
"""Relief pitcher card produces a valid two-line string."""
|
||||||
|
rp_state = {
|
||||||
|
"player_name": "Edwin Diaz",
|
||||||
|
"track": {"card_type": "rp"},
|
||||||
|
"current_tier": 1,
|
||||||
|
"current_value": 45,
|
||||||
|
"next_threshold": 60,
|
||||||
|
}
|
||||||
|
result = format_refractor_entry(rp_state)
|
||||||
|
assert "Edwin Diaz" in result
|
||||||
|
assert "45/60" in result
|
||||||
|
|
||||||
|
def test_unknown_card_type_does_not_crash(self):
|
||||||
|
"""Unknown card_type produces a valid two-line string."""
|
||||||
|
state = {
|
||||||
|
"player_name": "Test Player",
|
||||||
|
"track": {"card_type": "dh"},
|
||||||
|
"current_tier": 1,
|
||||||
|
"current_value": 30,
|
||||||
|
"next_threshold": 50,
|
||||||
|
}
|
||||||
|
result = format_refractor_entry(state)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert len(result.split("\n")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Slash command: empty roster / no-team scenarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refractor_status_no_team(mock_bot, mock_interaction):
|
||||||
|
"""
|
||||||
|
When the user has no team, the command replies with a signup prompt
|
||||||
|
and does not call db_get.
|
||||||
|
|
||||||
|
Why: get_team_by_owner returning None means the user is unregistered;
|
||||||
|
the command must short-circuit before hitting the API.
|
||||||
|
"""
|
||||||
|
from cogs.refractor import Refractor
|
||||||
|
|
||||||
|
cog = Refractor(mock_bot)
|
||||||
|
|
||||||
|
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=None)):
|
||||||
|
with patch("cogs.refractor.db_get", new=AsyncMock()) as mock_db:
|
||||||
|
await cog.refractor_status.callback(cog, mock_interaction)
|
||||||
|
mock_db.assert_not_called()
|
||||||
|
|
||||||
|
call_kwargs = mock_interaction.edit_original_response.call_args
|
||||||
|
content = call_kwargs.kwargs.get("content", "")
|
||||||
|
assert "newteam" in content.lower() or "team" in content.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
|
||||||
|
"""
|
||||||
|
When the API returns an empty card list, the command sends an
|
||||||
|
informative 'no data' message rather than an empty embed.
|
||||||
|
|
||||||
|
Why: An empty list is valid (team has no refractor cards yet);
|
||||||
|
the command should not crash or send a blank embed.
|
||||||
|
"""
|
||||||
|
from cogs.refractor import Refractor
|
||||||
|
|
||||||
|
cog = Refractor(mock_bot)
|
||||||
|
team = {"id": 1, "sname": "Test"}
|
||||||
|
|
||||||
|
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
|
||||||
|
with patch(
|
||||||
|
"cogs.refractor.db_get",
|
||||||
|
new=AsyncMock(return_value={"items": [], "count": 0}),
|
||||||
|
):
|
||||||
|
await cog.refractor_status.callback(cog, mock_interaction)
|
||||||
|
|
||||||
|
call_kwargs = mock_interaction.edit_original_response.call_args
|
||||||
|
content = call_kwargs.kwargs.get("content", "")
|
||||||
|
assert "no refractor data" in content.lower()
|
||||||
328
tests/test_refractor_notifs.py
Normal file
328
tests/test_refractor_notifs.py
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
Tests for Refractor Tier Completion Notification embeds.
|
||||||
|
|
||||||
|
These tests verify that:
|
||||||
|
1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color).
|
||||||
|
2. Tier 4 (Superfractor) embeds include the special title, description, and note field.
|
||||||
|
3. Multiple tier-up events each produce a separate embed.
|
||||||
|
4. An empty tier-up list results in no channel sends.
|
||||||
|
|
||||||
|
The channel interaction is mocked because we are testing the embed content, not Discord
|
||||||
|
network I/O. Notification failure must never affect game flow, so the non-fatal path
|
||||||
|
is also exercised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from helpers.refractor_notifs import build_tier_up_embed, notify_tier_completion
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def make_tier_up(
|
||||||
|
player_name="Mike Trout",
|
||||||
|
old_tier=1,
|
||||||
|
new_tier=2,
|
||||||
|
track_name="Batter",
|
||||||
|
current_value=150,
|
||||||
|
):
|
||||||
|
"""Return a minimal tier_up dict matching the expected shape."""
|
||||||
|
return {
|
||||||
|
"player_name": player_name,
|
||||||
|
"old_tier": old_tier,
|
||||||
|
"new_tier": new_tier,
|
||||||
|
"track_name": track_name,
|
||||||
|
"current_value": current_value,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit: build_tier_up_embed — tiers 1-3 (standard tier-up)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildTierUpEmbed:
|
||||||
|
"""Verify that build_tier_up_embed produces correctly structured embeds."""
|
||||||
|
|
||||||
|
def test_title_is_refractor_tier_up(self):
|
||||||
|
"""Title must read 'Refractor Tier Up!' for any non-max tier."""
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.title == "Refractor Tier Up!"
|
||||||
|
|
||||||
|
def test_description_contains_player_name(self):
|
||||||
|
"""Description must contain the player's name."""
|
||||||
|
tier_up = make_tier_up(player_name="Mike Trout", new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert "Mike Trout" in embed.description
|
||||||
|
|
||||||
|
def test_description_contains_new_tier_name(self):
|
||||||
|
"""Description must include the human-readable tier name for the new tier."""
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
# Tier 2 display name is "Refractor"
|
||||||
|
assert "Refractor" in embed.description
|
||||||
|
|
||||||
|
def test_description_contains_track_name(self):
|
||||||
|
"""Description must mention the refractor track (e.g., 'Batter')."""
|
||||||
|
tier_up = make_tier_up(track_name="Batter", new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert "Batter" in embed.description
|
||||||
|
|
||||||
|
def test_tier1_color_is_green(self):
|
||||||
|
"""Tier 1 uses green (0x2ecc71)."""
|
||||||
|
tier_up = make_tier_up(old_tier=0, new_tier=1)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.color.value == 0x2ECC71
|
||||||
|
|
||||||
|
def test_tier2_color_is_gold(self):
|
||||||
|
"""Tier 2 uses gold (0xf1c40f)."""
|
||||||
|
tier_up = make_tier_up(old_tier=1, new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.color.value == 0xF1C40F
|
||||||
|
|
||||||
|
def test_tier3_color_is_purple(self):
|
||||||
|
"""Tier 3 uses purple (0x9b59b6)."""
|
||||||
|
tier_up = make_tier_up(old_tier=2, new_tier=3)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.color.value == 0x9B59B6
|
||||||
|
|
||||||
|
def test_footer_text_is_paper_dynasty_refractor(self):
|
||||||
|
"""Footer text must be 'Paper Dynasty Refractor' for brand consistency."""
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.footer.text == "Paper Dynasty Refractor"
|
||||||
|
|
||||||
|
def test_returns_discord_embed_instance(self):
|
||||||
|
"""Return type must be discord.Embed so it can be sent directly."""
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert isinstance(embed, discord.Embed)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit: build_tier_up_embed — tier 4 (superfractor)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildTierUpEmbedSuperfractor:
|
||||||
|
"""Verify that tier 4 (Superfractor) embeds use special formatting."""
|
||||||
|
|
||||||
|
def test_title_is_superfractor(self):
|
||||||
|
"""Tier 4 title must be 'SUPERFRACTOR!' to emphasise max achievement."""
|
||||||
|
tier_up = make_tier_up(old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.title == "SUPERFRACTOR!"
|
||||||
|
|
||||||
|
def test_description_mentions_maximum_refractor_tier(self):
|
||||||
|
"""Tier 4 description must mention 'maximum refractor tier' per the spec."""
|
||||||
|
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert "maximum refractor tier" in embed.description.lower()
|
||||||
|
|
||||||
|
def test_description_contains_player_name(self):
|
||||||
|
"""Player name must appear in the tier 4 description."""
|
||||||
|
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert "Mike Trout" in embed.description
|
||||||
|
|
||||||
|
def test_description_contains_track_name(self):
|
||||||
|
"""Track name must appear in the tier 4 description."""
|
||||||
|
tier_up = make_tier_up(track_name="Batter", old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert "Batter" in embed.description
|
||||||
|
|
||||||
|
def test_tier4_color_is_teal(self):
|
||||||
|
"""Tier 4 uses teal (0x1abc9c) to visually distinguish superfractor."""
|
||||||
|
tier_up = make_tier_up(old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.color.value == 0x1ABC9C
|
||||||
|
|
||||||
|
def test_note_field_present(self):
|
||||||
|
"""Tier 4 must include a note field about future rating boosts."""
|
||||||
|
tier_up = make_tier_up(old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
field_names = [f.name for f in embed.fields]
|
||||||
|
assert any(
|
||||||
|
"rating" in name.lower()
|
||||||
|
or "boost" in name.lower()
|
||||||
|
or "note" in name.lower()
|
||||||
|
for name in field_names
|
||||||
|
), "Expected a field mentioning rating boosts for tier 4 embed"
|
||||||
|
|
||||||
|
def test_note_field_value_mentions_future_update(self):
|
||||||
|
"""The note field value must reference the future rating boost update."""
|
||||||
|
tier_up = make_tier_up(old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
note_field = next(
|
||||||
|
(
|
||||||
|
f
|
||||||
|
for f in embed.fields
|
||||||
|
if "rating" in f.name.lower()
|
||||||
|
or "boost" in f.name.lower()
|
||||||
|
or "note" in f.name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert note_field is not None
|
||||||
|
assert (
|
||||||
|
"future" in note_field.value.lower() or "update" in note_field.value.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_footer_text_is_paper_dynasty_refractor(self):
|
||||||
|
"""Footer must remain 'Paper Dynasty Refractor' for tier 4 as well."""
|
||||||
|
tier_up = make_tier_up(old_tier=3, new_tier=4)
|
||||||
|
embed = build_tier_up_embed(tier_up)
|
||||||
|
assert embed.footer.text == "Paper Dynasty Refractor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unit: notify_tier_completion — multiple and empty cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifyTierCompletion:
|
||||||
|
"""Verify that notify_tier_completion sends the right number of messages."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_single_tier_up_sends_one_message(self):
|
||||||
|
"""A single tier-up event sends exactly one embed to the channel."""
|
||||||
|
channel = AsyncMock()
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
channel.send.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_embed_not_plain_text(self):
|
||||||
|
"""The channel.send call must use the embed= keyword, not content=."""
|
||||||
|
channel = AsyncMock()
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
_, kwargs = channel.send.call_args
|
||||||
|
assert "embed" in kwargs, (
|
||||||
|
"notify_tier_completion must send an embed, not plain text"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_embed_type_is_discord_embed(self):
|
||||||
|
"""The embed passed to channel.send must be a discord.Embed instance."""
|
||||||
|
channel = AsyncMock()
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
_, kwargs = channel.send.call_args
|
||||||
|
assert isinstance(kwargs["embed"], discord.Embed)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notification_failure_does_not_raise(self):
|
||||||
|
"""If channel.send raises, notify_tier_completion must swallow it so game flow is unaffected."""
|
||||||
|
channel = AsyncMock()
|
||||||
|
channel.send.side_effect = Exception("Discord API unavailable")
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
# Should not raise
|
||||||
|
await notify_tier_completion(channel, tier_up)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_tier_ups_caller_sends_multiple_embeds(self):
|
||||||
|
"""
|
||||||
|
Callers are responsible for iterating tier-up events; each call to
|
||||||
|
notify_tier_completion sends a separate embed. This test simulates
|
||||||
|
three consecutive calls (3 events) and asserts 3 sends occurred.
|
||||||
|
"""
|
||||||
|
channel = AsyncMock()
|
||||||
|
events = [
|
||||||
|
make_tier_up(player_name="Mike Trout", new_tier=2),
|
||||||
|
make_tier_up(player_name="Aaron Judge", new_tier=1),
|
||||||
|
make_tier_up(player_name="Shohei Ohtani", new_tier=3),
|
||||||
|
]
|
||||||
|
for event in events:
|
||||||
|
await notify_tier_completion(channel, event)
|
||||||
|
assert channel.send.call_count == 3, (
|
||||||
|
"Each tier-up event must produce its own embed (no batching)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_tier_ups_means_no_sends(self):
|
||||||
|
"""
|
||||||
|
When the caller has an empty list of tier-up events and simply
|
||||||
|
does not call notify_tier_completion, zero sends happen.
|
||||||
|
This explicitly guards against any accidental unconditional send.
|
||||||
|
"""
|
||||||
|
channel = AsyncMock()
|
||||||
|
tier_up_events = []
|
||||||
|
for event in tier_up_events:
|
||||||
|
await notify_tier_completion(channel, event)
|
||||||
|
channel.send.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T1-5: tier_up dict shape validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTierUpDictShapeValidation:
|
||||||
|
"""
|
||||||
|
T1-5: Verify build_tier_up_embed handles valid API shapes correctly and
|
||||||
|
rejects malformed input.
|
||||||
|
|
||||||
|
The evaluate-game API endpoint returns the full shape (player_name,
|
||||||
|
old_tier, new_tier, track_name, current_value). These tests guard the
|
||||||
|
contract between the API response and the embed builder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_empty_dict_raises_key_error(self):
|
||||||
|
"""
|
||||||
|
An empty dict must raise KeyError — guards against callers passing
|
||||||
|
unrelated or completely malformed data.
|
||||||
|
"""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
build_tier_up_embed({})
|
||||||
|
|
||||||
|
def test_full_api_shape_builds_embed(self):
|
||||||
|
"""
|
||||||
|
The full shape returned by the evaluate-game endpoint builds a valid
|
||||||
|
embed without error.
|
||||||
|
"""
|
||||||
|
full_shape = make_tier_up(
|
||||||
|
player_name="Mike Trout",
|
||||||
|
old_tier=1,
|
||||||
|
new_tier=2,
|
||||||
|
track_name="Batter Track",
|
||||||
|
current_value=150,
|
||||||
|
)
|
||||||
|
embed = build_tier_up_embed(full_shape)
|
||||||
|
assert embed is not None
|
||||||
|
assert "Mike Trout" in embed.description
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# T2-7: notify_tier_completion with None channel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotifyTierCompletionNoneChannel:
|
||||||
|
"""
|
||||||
|
T2-7: notify_tier_completion must not propagate exceptions when the channel
|
||||||
|
is None.
|
||||||
|
|
||||||
|
Why: the post-game hook may call notify_tier_completion before a valid
|
||||||
|
channel is resolved (e.g. in tests, or if the scoreboard channel lookup
|
||||||
|
fails). The try/except in notify_tier_completion should catch the
|
||||||
|
AttributeError from None.send() so game flow is never interrupted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_none_channel_does_not_raise(self):
|
||||||
|
"""
|
||||||
|
Passing None as the channel argument must not raise.
|
||||||
|
|
||||||
|
None.send() raises AttributeError; the try/except in
|
||||||
|
notify_tier_completion is expected to absorb it silently.
|
||||||
|
"""
|
||||||
|
tier_up = make_tier_up(new_tier=2)
|
||||||
|
# Should not raise regardless of channel being None
|
||||||
|
await notify_tier_completion(None, tier_up)
|
||||||
Loading…
Reference in New Issue
Block a user