Compare commits

..

18 Commits

Author SHA1 Message Date
cal
44d83b321f Merge pull request 'perf: parallelize roll_for_cards and bump pack limit to 20' (#102) from performance/97-parallelize-roll-for-cards into next-release
All checks were successful
Build Docker Image / build (push) Successful in 1m25s
Build Docker Image / build (pull_request) Successful in 54s
Reviewed-on: #102
2026-03-20 15:34:06 +00:00
Cal Corum
56007aaeec perf: parallelize roll_for_cards DB calls and increase pack limit to 20 (#97)
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m54s
Restructure roll_for_cards into three phases: dice rolling (CPU-only),
batched player fetches (one per rarity tier via asyncio.gather), and
gathered writes (cards + pack patches concurrent). Reduces 20-30
sequential API calls to ~6 gathered calls for 5 packs.

Also fixes leaked `x` variable bug in dupe branch, removes dead
`all_players` accumulation, and bumps open-packs limit from 5 to 20.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:17:08 -05:00
cal
f497b59c0f Merge pull request 'Card Evolution Phase 1c: Bot Integration → next-release' (#95) from card-evolution-phase1c into next-release
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
Build Docker Image / build (pull_request) Successful in 1m37s
Reviewed-on: #95
2026-03-18 21:28:57 +00:00
Cal Corum
0854d52336 Merge branch 'feature/wp14-tier-notifications' into card-evolution-phase1c 2026-03-18 16:27:32 -05:00
Cal Corum
d1035041c0 Merge branch 'feature/wp13-postgame-hook' into card-evolution-phase1c 2026-03-18 16:27:32 -05:00
Cal Corum
15bcdb7b9c Merge branch 'feature/wp12-tier-badge' into card-evolution-phase1c 2026-03-18 16:27:32 -05:00
Cal Corum
746ffa2263 fix: remove unused Optional import
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m19s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:02:54 -05:00
Cal Corum
596a3ec414 fix: remove dead real_notify import in test
All checks were successful
Build Docker Image / build (pull_request) Successful in 52s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:02:31 -05:00
Cal Corum
303b7670d7 fix: remove WP-14 files from WP-13 PR
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m20s
evolution_notifs.py and test_evolution_notifications.py belong in
PR #94 (WP-14). They were accidentally captured as untracked files
by the WP-13 agent. complete_game() correctly uses the local stub.

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

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

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

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

Closes #78 on cal/paper-dynasty-database

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:41:06 -05:00
cal
7c76d1e2c6 Merge pull request 'feat: implement run-decision algorithm in gb_decide_run (#18)' (#72) from ai/paper-dynasty-discord-18 into next-release
All checks were successful
Build Docker Image / build (push) Successful in 3m15s
Reviewed-on: #72
2026-03-10 14:44:40 +00:00
cal
24410fa65b Merge pull request 'fix: explicitly exclude cogs/gameplay_legacy.py from Docker image (#42)' (#52) from ai/paper-dynasty-discord-42 into next-release
All checks were successful
Build Docker Image / build (push) Successful in 2m44s
Reviewed-on: #52
2026-03-10 14:11:51 +00:00
Cal Corum
6c4ff3bd27 feat: implement run-decision algorithm in gb_decide_run (#18)
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m19s
Replace placeholder formula with tier-based algorithm modeled after
tag_from_second and tag_from_third. Uses self.running + aggression_mod
(abs deviation from neutral) for adjusted_running, then brackets into
three min_safe tiers (4/6/8), with a ±2 adjustment for 2-out and 0-out
situations respectively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 17:34:53 -06:00
Cal Corum
3fa28d9df2 fix: explicitly exclude cogs/gameplay_legacy.py from Docker image (#42)
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m16s
The wildcard *_legacy.py pattern already covered this file, but adding
an explicit entry makes the exclusion unambiguous and self-documenting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 00:04:25 -06:00
44 changed files with 7976 additions and 9039 deletions

View File

@ -34,6 +34,7 @@ README.md
**/storage **/storage
**/htmlcov **/htmlcov
*_legacy.py *_legacy.py
cogs/gameplay_legacy.py
pytest.ini pytest.ini
CLAUDE.md CLAUDE.md
**.db **.db

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@ -31,8 +31,8 @@ pip install -r requirements.txt # Install dependencies
- **Path**: `/home/cal/container-data/paper-dynasty` - **Path**: `/home/cal/container-data/paper-dynasty`
- **Container**: `paper-dynasty_discord-app_1` - **Container**: `paper-dynasty_discord-app_1`
- **Image**: `manticorum67/paper-dynasty-discordapp` - **Image**: `manticorum67/paper-dynasty-discordapp`
- **Health**: `GET http://localhost:8081/health` (HTTP server in `health_server.py`) - **Health**: `GET http://localhost:8080/health` (HTTP server in `health_server.py`)
- **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release - **Versioning**: CalVer (`YYYY.MM.BUILD`) — auto-generated on merge to `main`
### Logs ### Logs
- **Container logs**: `ssh sba-bots "docker logs --since 1h paper-dynasty_discord-app_1"` - **Container logs**: `ssh sba-bots "docker logs --since 1h paper-dynasty_discord-app_1"`
@ -46,12 +46,11 @@ pip install -r requirements.txt # Install dependencies
- Bot not responding → check `docker logs`, verify `BOT_TOKEN` and `GUILD_ID` - Bot not responding → check `docker logs`, verify `BOT_TOKEN` and `GUILD_ID`
- API errors → verify `DATABASE` is set to `Prod` or `Dev`, check `API_TOKEN` matches the database API - API errors → verify `DATABASE` is set to `Prod` or `Dev`, check `API_TOKEN` matches the database API
- Game engine errors → check `/usr/src/app/logs/discord.log` for detailed tracebacks - Game engine errors → check `/usr/src/app/logs/discord.log` for detailed tracebacks
- Health endpoint not responding → `health_server.py` runs on port 8081 inside the container - Health endpoint not responding → `health_server.py` runs on port 8080 inside the container
### CI/CD ### CI/CD
Ruff lint on PRs. Docker image built on CalVer tag push only. Gitea Actions on PR to `main` — builds Docker image, auto-generates CalVer version on merge.
```bash ```bash
# Release: git tag YYYY.M.BUILD && git push origin YYYY.M.BUILD
tea pulls create --repo cal/paper-dynasty --head <branch> --base main --title "title" --description "description" tea pulls create --repo cal/paper-dynasty --head <branch> --base main --title "title" --description "description"
``` ```

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -1,419 +0,0 @@
"""
Refractor cog /refractor status slash command.
Displays a team's refractor progress: formula value vs next threshold
with a progress bar, paginated 10 cards per page.
Tier names: Base Card (T0) / Base Chrome (T1) / Refractor (T2) /
Gold Refractor (T3) / Superfractor (T4).
Depends on WP-07 (refractor/cards API endpoint).
"""
import logging
from typing import Optional
import discord
from discord import app_commands
from discord.app_commands import Choice
from discord.ext import commands
from api_calls import db_get
from helpers.discord_utils import get_team_embed
from helpers.main import get_team_by_owner
from helpers.refractor_constants import TIER_NAMES, STATUS_TIER_COLORS as TIER_COLORS
logger = logging.getLogger("discord_app")
PAGE_SIZE = 10
# 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
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)
)
# If the requested page is beyond the last page, clamp and re-fetch.
if not items and total_count > 0:
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = total_pages
clamped_params = [(k, v) for k, v in params if k != "offset"]
clamped_params.append(("offset", (page - 1) * PAGE_SIZE))
data = await db_get("refractor/cards", params=clamped_params)
if isinstance(data, dict):
items = data.get("items", [])
total_count = data.get("count", total_count)
logger.debug(
"Refractor status for team %s: %d items returned, %d total (page %d)",
team["id"],
len(items),
total_count,
page,
)
if not items:
has_filters = card_type or tier is not None or progress
if has_filters:
parts = []
if card_type:
parts.append(f"**{card_type.name}**")
if tier is not None:
parts.append(f"**{tier.name}**")
if progress:
parts.append(f"progress: **{progress.name}**")
filter_str = ", ".join(parts)
await interaction.edit_original_response(
content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
)
else:
await interaction.edit_original_response(
content="No refractor data found for your team."
)
return
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages)
embed = build_status_embed(
team, items, page, total_pages, total_count, tier_filter=tier_filter
)
if total_pages > 1:
view = RefractorPaginationView(
team=team,
page=page,
total_pages=total_pages,
total_count=total_count,
params=params,
owner_id=interaction.user.id,
tier_filter=tier_filter,
)
await interaction.edit_original_response(embed=embed, view=view)
else:
await interaction.edit_original_response(embed=embed)
async def setup(bot):
await bot.add_cog(Refractor(bot))

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -104,9 +104,9 @@ class SelectChoicePackTeam(discord.ui.Select):
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [ options = [
discord.SelectOption( discord.SelectOption(
label="St. Louis Cardinals" label=(
if team == "St Louis Cardinals" "St. Louis Cardinals" if team == "St Louis Cardinals" else team
else team )
) )
for team in NL_TEAMS for team in NL_TEAMS
] ]
@ -167,7 +167,7 @@ class SelectOpenPack(discord.ui.Select):
params = [ params = [
("team_id", self.owner_team["id"]), ("team_id", self.owner_team["id"]),
("opened", False), ("opened", False),
("limit", 5), ("limit", 20),
("exact_match", True), ("exact_match", True),
] ]
@ -196,15 +196,7 @@ class SelectOpenPack(discord.ui.Select):
open_type = "choice" open_type = "choice"
params.append(("pack_type_id", 8)) params.append(("pack_type_id", 8))
else: else:
logger.error( raise KeyError(f"Cannot identify pack details: {pack_vals}")
f"Unrecognized pack type in selector: {self.values[0]} (split: {pack_vals})"
)
await interaction.response.edit_message(view=None)
await interaction.followup.send(
content="This pack type cannot be opened manually. Please contact Cal.",
ephemeral=True,
)
return
# If team isn't already set on team choice pack, make team pack selection now # If team isn't already set on team choice pack, make team pack selection now
await interaction.response.edit_message(view=None) await interaction.response.edit_message(view=None)
@ -218,7 +210,7 @@ class SelectOpenPack(discord.ui.Select):
): ):
await interaction.followup.send( await interaction.followup.send(
content="This Team Choice pack needs to be assigned a team and cardset. " content="This Team Choice pack needs to be assigned a team and cardset. "
"Please contact Cal to configure this pack.", "Please contact an admin to configure this pack.",
ephemeral=True, ephemeral=True,
) )
return return
@ -252,7 +244,7 @@ class SelectOpenPack(discord.ui.Select):
if p_query["count"] == 0: if p_query["count"] == 0:
logger.error(f"open-packs - no packs found with params: {params}") logger.error(f"open-packs - no packs found with params: {params}")
await interaction.followup.send( await interaction.followup.send(
content="Unable to find the selected pack. Please contact Cal.", content="Unable to find the selected pack. Please contact an admin.",
ephemeral=True, ephemeral=True,
) )
return return
@ -268,7 +260,7 @@ class SelectOpenPack(discord.ui.Select):
except Exception as e: except Exception as e:
logger.error(f"Failed to open pack: {e}") logger.error(f"Failed to open pack: {e}")
await interaction.followup.send( await interaction.followup.send(
content=f"Failed to open pack. Please contact Cal. Error: {str(e)}", content=f"Failed to open pack. Please contact an admin. Error: {str(e)}",
ephemeral=True, ephemeral=True,
) )
return return
@ -326,9 +318,9 @@ class SelectPaperdexTeam(discord.ui.Select):
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [ options = [
discord.SelectOption( discord.SelectOption(
label="St. Louis Cardinals" label=(
if team == "St Louis Cardinals" "St. Louis Cardinals" if team == "St Louis Cardinals" else team
else team )
) )
for team in NL_TEAMS for team in NL_TEAMS
] ]
@ -459,9 +451,9 @@ class SelectBuyPacksTeam(discord.ui.Select):
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [ options = [
discord.SelectOption( discord.SelectOption(
label="St. Louis Cardinals" label=(
if team == "St Louis Cardinals" "St. Louis Cardinals" if team == "St Louis Cardinals" else team
else team )
) )
for team in NL_TEAMS for team in NL_TEAMS
] ]
@ -528,9 +520,9 @@ class SelectUpdatePlayerTeam(discord.ui.Select):
# Handle St. Louis Cardinals display name # Handle St. Louis Cardinals display name
options = [ options = [
discord.SelectOption( discord.SelectOption(
label="St. Louis Cardinals" label=(
if team == "St Louis Cardinals" "St. Louis Cardinals" if team == "St Louis Cardinals" else team
else team )
) )
for team in NL_TEAMS for team in NL_TEAMS
] ]

252
discord_utils.py Normal file
View File

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

View File

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

View File

@ -17,14 +17,14 @@ logger = logging.getLogger("discord_app.health")
class HealthServer: class HealthServer:
"""HTTP server for health checks and metrics.""" """HTTP server for health checks and metrics."""
def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8081): def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
""" """
Initialize health server. Initialize health server.
Args: Args:
bot: Discord bot instance to monitor bot: Discord bot instance to monitor
host: Host to bind to (default: 0.0.0.0 for container access) host: Host to bind to (default: 0.0.0.0 for container access)
port: Port to listen on (default: 8081) port: Port to listen on (default: 8080)
""" """
self.bot = bot self.bot = bot
self.host = host self.host = host
@ -148,7 +148,7 @@ class HealthServer:
logger.info("Health check server stopped") logger.info("Health check server stopped")
async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8081): async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
""" """
Run health server as a background task. Run health server as a background task.

2153
helpers.py Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,7 +1,7 @@
""" """
Refractor Tier Completion Notifications Evolution Tier Completion Notifications
Builds and sends Discord embeds when a player completes a refractor tier Builds and sends Discord embeds when a player completes an evolution tier
during post-game evaluation. Each tier-up event gets its own embed. during post-game evaluation. Each tier-up event gets its own embed.
Notification failures are non-fatal: the send is wrapped in try/except so Notification failures are non-fatal: the send is wrapped in try/except so
@ -12,23 +12,35 @@ import logging
import discord import discord
from helpers.refractor_constants import TIER_NAMES, NOTIF_TIER_COLORS as TIER_COLORS
logger = logging.getLogger("discord_app") logger = logging.getLogger("discord_app")
FOOTER_TEXT = "Paper Dynasty Refractor" # Human-readable display names for each tier number.
TIER_NAMES = {
0: "Unranked",
1: "Initiate",
2: "Rising",
3: "Ascendant",
4: "Evolved",
}
# Tier-specific embed colors.
TIER_COLORS = {
1: 0x2ECC71, # green
2: 0xF1C40F, # gold
3: 0x9B59B6, # purple
4: 0x1ABC9C, # teal (fully evolved)
}
FOOTER_TEXT = "Paper Dynasty Evolution"
def build_tier_up_embed(tier_up: dict, image_url: str | None = None) -> discord.Embed: def build_tier_up_embed(tier_up: dict) -> discord.Embed:
"""Build a Discord embed for a tier-up event. """Build a Discord embed for a tier-up event.
Parameters Parameters
---------- ----------
tier_up: tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name. Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
image_url:
Optional S3 URL for the newly rendered refractor card image. When
provided, the card art is shown as the embed image.
Returns Returns
------- -------
@ -43,32 +55,33 @@ def build_tier_up_embed(tier_up: dict, image_url: str | None = None) -> discord.
color = TIER_COLORS.get(new_tier, 0x2ECC71) color = TIER_COLORS.get(new_tier, 0x2ECC71)
if new_tier >= 4: if new_tier >= 4:
# Superfractor — special title and description. # Fully evolved — special title and description.
embed = discord.Embed( embed = discord.Embed(
title="SUPERFRACTOR!", title="FULLY EVOLVED!",
description=( description=(
f"**{player_name}** has reached maximum refractor tier on the **{track_name}** track" f"**{player_name}** has reached maximum evolution on the **{track_name}** track"
), ),
color=color, color=color,
) )
embed.add_field(
name="Rating Boosts",
value="Rating boosts coming in a future update!",
inline=False,
)
else: else:
embed = discord.Embed( embed = discord.Embed(
title="Refractor Tier Up!", title="Evolution Tier Up!",
description=( description=(
f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track" f"**{player_name}** reached **Tier {new_tier} ({tier_name})** on the **{track_name}** track"
), ),
color=color, color=color,
) )
if image_url:
embed.set_image(url=image_url)
embed.set_footer(text=FOOTER_TEXT) embed.set_footer(text=FOOTER_TEXT)
return embed return embed
async def notify_tier_completion( async def notify_tier_completion(channel, tier_up: dict) -> None:
channel: discord.abc.Messageable, tier_up: dict, image_url: str | None = None
) -> None:
"""Send a tier-up notification embed to the given channel. """Send a tier-up notification embed to the given channel.
Non-fatal: any exception during send is caught and logged so that a Non-fatal: any exception during send is caught and logged so that a
@ -77,15 +90,12 @@ async def notify_tier_completion(
Parameters Parameters
---------- ----------
channel: channel:
A discord.abc.Messageable (e.g. discord.TextChannel). A discord.TextChannel (or any object with an async ``send`` method).
tier_up: tier_up:
Dict with keys: player_name, old_tier, new_tier, current_value, track_name. Dict with keys: player_name, old_tier, new_tier, current_value, track_name.
image_url:
Optional S3 URL for the refractor card image. Passed through to
build_tier_up_embed so the card art appears in the notification.
""" """
try: try:
embed = build_tier_up_embed(tier_up, image_url=image_url) embed = build_tier_up_embed(tier_up)
await channel.send(embed=embed) await channel.send(embed=embed)
except Exception as exc: except Exception as exc:
logger.error( logger.error(

View File

@ -21,10 +21,7 @@ from utils import (
get_cal_user, get_cal_user,
) )
from search_utils import * from search_utils import *
from .discord_utils import * from discord_utils import *
# Refractor tier badge prefixes for card embeds (T0 = no badge)
TIER_BADGES = {1: "BC", 2: "R", 3: "GR", 4: "SF"}
async def get_player_photo(player): async def get_player_photo(player):
@ -113,16 +110,20 @@ async def share_channel(channel, user, read_only=False):
async def get_card_embeds(card, include_stats=False) -> list: async def get_card_embeds(card, include_stats=False) -> list:
# WP-12: fetch evolution state and build tier badge prefix.
# Non-blocking — any failure falls back to no badge so card display is
# never broken by an unavailable or slow evolution API.
tier_badge = "" tier_badge = ""
try: try:
evo_state = await db_get(f"refractor/cards/{card['id']}") evo_state = await db_get(f"evolution/cards/{card['id']}", none_okay=True)
if evo_state and evo_state.get("current_tier", 0) > 0: if evo_state and evo_state.get("current_tier", 0) > 0:
tier = evo_state["current_tier"] tier = evo_state["current_tier"]
badge = TIER_BADGES.get(tier) tier_badge = f"[{'EVO' if tier >= 4 else f'T{tier}'}] "
tier_badge = f"[{badge}] " if badge else "" except Exception:
except Exception as e: logging.warning(
logging.debug( f"Could not fetch evolution state for card {card.get('id')}; "
f"badge lookup failed for card {card.get('id')}: {e}", exc_info=True "displaying without tier badge.",
exc_info=True,
) )
embed = discord.Embed( embed = discord.Embed(
@ -184,19 +185,11 @@ async def get_card_embeds(card, include_stats=False) -> list:
name="Collected By", value=f"{count} team{'s' if count != 1 else ''}" name="Collected By", value=f"{count} team{'s' if count != 1 else ''}"
) )
if card["team"]["lname"] != "Paper Dynasty": # TODO: check for dupes with the included paperdex data
team_dex = await db_get( # if card['team']['lname'] != 'Paper Dynasty':
"cards", # team_dex = await db_get('cards', params=[("player_id", card["player"]["player_id"]), ('team_id', card['team']['id'])])
params=[ # count = 1 if not team_dex['count'] else team_dex['count']
("player_id", card["player"]["player_id"]), # embed.add_field(name='# Dupes', value=f'{count - 1} dupe{"s" if count - 1 != 1 else ""}')
("team_id", card["team"]["id"]),
],
)
if team_dex is not None:
dupe_count = max(0, team_dex["count"] - 1)
embed.add_field(
name="Dupes", value=f"{dupe_count} dupe{'s' if dupe_count != 1 else ''}"
)
# embed.add_field(name='Team', value=f'{card["player"]["mlbclub"]}') # embed.add_field(name='Team', value=f'{card["player"]["mlbclub"]}')
if card["player"]["franchise"] != "Pokemon": if card["player"]["franchise"] != "Pokemon":
@ -224,9 +217,9 @@ async def get_card_embeds(card, include_stats=False) -> list:
) )
if evo_mon is not None: if evo_mon is not None:
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:
logging.error( logging.error(
f"could not pull evolution: {e}", exc_info=True, stack_info=True "could not pull evolution: {e}", exc_info=True, stack_info=True
) )
if "420420" not in card["player"]["strat_code"]: if "420420" not in card["player"]["strat_code"]:
try: try:
@ -235,9 +228,9 @@ async def get_card_embeds(card, include_stats=False) -> list:
) )
if evo_mon is not None: if evo_mon is not None:
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:
logging.error( logging.error(
f"could not pull evolution: {e}", exc_info=True, stack_info=True "could not pull evolution: {e}", exc_info=True, stack_info=True
) )
if include_stats: if include_stats:
@ -339,7 +332,7 @@ async def display_cards(
cards.sort(key=lambda x: x["player"]["rarity"]["value"]) cards.sort(key=lambda x: x["player"]["rarity"]["value"])
logger.debug("Cards sorted successfully") logger.debug("Cards sorted successfully")
card_embeds = list(await asyncio.gather(*[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")
page_num = 0 if pack_cover is None else -1 page_num = 0 if pack_cover is None else -1
@ -656,21 +649,15 @@ async def get_test_pack(ctx, team):
async def roll_for_cards(all_packs: list, extra_val=None) -> list: async def roll_for_cards(all_packs: list, extra_val=None) -> list:
"""Open packs by rolling dice, fetching random players, and creating cards.
Parallelizes DB calls: one fetch per rarity tier across all packs,
then gathers all card creates and pack patches concurrently.
""" """
Pack odds are calculated based on the pack type
Parameters
----------
extra_val
all_packs
Returns
-------
"""
all_players = []
team = all_packs[0]["team"] team = all_packs[0]["team"]
pack_ids = []
# --- Phase A: Roll dice for every pack (CPU-only, no I/O) ---
pack_counts = []
for pack in all_packs: for pack in all_packs:
counts = { counts = {
"Rep": {"count": 0, "rarity": 0}, "Rep": {"count": 0, "rarity": 0},
@ -680,10 +667,9 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
"MVP": {"count": 0, "rarity": 5}, "MVP": {"count": 0, "rarity": 5},
"HoF": {"count": 0, "rarity": 8}, "HoF": {"count": 0, "rarity": 8},
} }
this_pack_players = []
if pack["pack_type"]["name"] == "Standard": if pack["pack_type"]["name"] == "Standard":
# Cards 1 - 2 # Cards 1 - 2
for x in range(2): for _ in range(2):
d_1000 = random.randint(1, 1000) d_1000 = random.randint(1, 1000)
if d_1000 <= 450: if d_1000 <= 450:
counts["Rep"]["count"] += 1 counts["Rep"]["count"] += 1
@ -803,7 +789,6 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
logger.info( logger.info(
f"Building Check-In Pack // extra_val (type): {extra_val} {type(extra_val)}" f"Building Check-In Pack // extra_val (type): {extra_val} {type(extra_val)}"
) )
# Single Card
mod = 0 mod = 0
if isinstance(extra_val, int): if isinstance(extra_val, int):
mod = extra_val mod = extra_val
@ -821,106 +806,195 @@ async def roll_for_cards(all_packs: list, extra_val=None) -> list:
else: else:
raise TypeError(f"Pack type not recognized: {pack['pack_type']['name']}") raise TypeError(f"Pack type not recognized: {pack['pack_type']['name']}")
pull_notifs = [] pack_counts.append(counts)
for key in counts:
mvp_flag = None
if counts[key]["count"] > 0: # --- Phase B: Fetch players — one call per rarity tier, all gathered ---
params = [ # Sum counts across all packs per rarity tier
("min_rarity", counts[key]["rarity"]), rarity_keys = ["Rep", "Res", "Sta", "All", "MVP", "HoF"]
("max_rarity", counts[key]["rarity"]), summed = {key: 0 for key in rarity_keys}
("limit", counts[key]["count"]), for counts in pack_counts:
] for key in rarity_keys:
if all_packs[0]["pack_team"] is not None: summed[key] += counts[key]["count"]
params.extend(
[
("franchise", all_packs[0]["pack_team"]["sname"]),
("in_packs", True),
]
)
elif all_packs[0]["pack_cardset"] is not None:
params.append(("cardset_id", all_packs[0]["pack_cardset"]["id"]))
else:
params.append(("in_packs", True))
pl = await db_get("players/random", params=params) # Build shared filter params
base_params = []
if pl["count"] != counts[key]["count"]: if all_packs[0]["pack_team"] is not None:
mvp_flag = counts[key]["count"] - pl["count"] base_params.extend(
logging.info( [
f"Set mvp flag to {mvp_flag} / cardset_id: {all_packs[0]['pack_cardset']['id']}" ("franchise", all_packs[0]["pack_team"]["sname"]),
) ("in_packs", True),
]
for x in pl["players"]:
this_pack_players.append(x)
all_players.append(x)
if x["rarity"]["value"] >= 3:
pull_notifs.append(x)
if mvp_flag and all_packs[0]["pack_cardset"]["id"] not in [23]:
logging.info(f"Adding {mvp_flag} MVPs for missing cards")
pl = await db_get(
"players/random", params=[("min_rarity", 5), ("limit", mvp_flag)]
)
for x in pl["players"]:
this_pack_players.append(x)
all_players.append(x)
# Add dupes of Replacement/Reserve cards
elif mvp_flag:
logging.info(f"Adding {mvp_flag} duplicate pokemon cards")
for count in range(mvp_flag):
logging.info(f"Adding {pl['players'][0]['p_name']} to the pack")
this_pack_players.append(x)
all_players.append(pl["players"][0])
success = await db_post(
"cards",
payload={
"cards": [
{
"player_id": x["player_id"],
"team_id": pack["team"]["id"],
"pack_id": pack["id"],
}
for x in this_pack_players
]
},
timeout=10,
) )
if not success: elif all_packs[0]["pack_cardset"] is not None:
raise ConnectionError("Failed to create this pack of cards.") base_params.append(("cardset_id", all_packs[0]["pack_cardset"]["id"]))
else:
base_params.append(("in_packs", True))
await db_patch( # Fire one request per non-zero rarity tier concurrently
"packs", rarity_values = {
object_id=pack["id"], "Rep": 0,
params=[ "Res": 1,
( "Sta": 2,
"open_time", "All": 3,
int(datetime.datetime.timestamp(datetime.datetime.now()) * 1000), "MVP": 5,
) "HoF": 8,
], }
) fetch_keys = [key for key in rarity_keys if summed[key] > 0]
pack_ids.append(pack["id"]) fetch_coros = []
for key in fetch_keys:
params = [
("min_rarity", rarity_values[key]),
("max_rarity", rarity_values[key]),
("limit", summed[key]),
] + base_params
fetch_coros.append(db_get("players/random", params=params))
for pull in pull_notifs: fetch_results = await asyncio.gather(*fetch_coros)
logger.info(f"good pull: {pull}")
await db_post( # Map results back: rarity key -> list of players
"notifs", fetched_players = {}
payload={ for key, result in zip(fetch_keys, fetch_results):
"created": int( fetched_players[key] = result.get("players", [])
datetime.datetime.timestamp(datetime.datetime.now()) * 1000
), # Handle shortfalls — collect total MVP backfill needed
"title": "Rare Pull", total_mvp_shortfall = 0
"field_name": f"{player_desc(pull)} ({pull['rarity']['name']})", # Track per-tier shortfall for dupe-branch (cardset 23 exclusion)
"message": f"Pulled by {team['abbrev']}", tier_shortfalls = {}
"about": f"Player-{pull['player_id']}", for key in fetch_keys:
}, returned = len(fetched_players[key])
requested = summed[key]
if returned < requested:
shortfall = requested - returned
tier_shortfalls[key] = shortfall
total_mvp_shortfall += shortfall
logging.info(
f"Shortfall in {key}: requested {requested}, got {returned} "
f"(cardset_id: {all_packs[0]['pack_cardset']['id'] if all_packs[0]['pack_cardset'] else 'N/A'})"
) )
return pack_ids # Fetch MVP backfill or duplicate existing players
backfill_players = []
is_dupe_cardset = all_packs[0]["pack_cardset"] is not None and all_packs[0][
"pack_cardset"
]["id"] in [23]
if total_mvp_shortfall > 0 and not is_dupe_cardset:
logging.info(f"Adding {total_mvp_shortfall} MVPs for missing cards")
mvp_result = await db_get(
"players/random",
params=[("min_rarity", 5), ("limit", total_mvp_shortfall)],
)
backfill_players = mvp_result.get("players", [])
elif total_mvp_shortfall > 0 and is_dupe_cardset:
logging.info(
f"Adding {total_mvp_shortfall} duplicate cards for excluded cardset"
)
# Duplicate from first available player in the fetched results
for key in fetch_keys:
if fetched_players[key]:
for _ in range(total_mvp_shortfall):
backfill_players.append(fetched_players[key][0])
break
# Slice fetched players back into per-pack groups
# Track consumption offset per rarity tier
tier_offsets = {key: 0 for key in rarity_keys}
backfill_offset = 0
per_pack_players = []
all_pull_notifs = []
for pack_idx, counts in enumerate(pack_counts):
this_pack_players = []
pack_shortfall = 0
for key in rarity_keys:
needed = counts[key]["count"]
if needed == 0:
continue
available = fetched_players.get(key, [])
start = tier_offsets[key]
end = start + needed
got = available[start:end]
this_pack_players.extend(got)
tier_offsets[key] = end
# Track shortfall for this pack
if len(got) < needed:
pack_shortfall += needed - len(got)
# Distribute backfill players to this pack
if pack_shortfall > 0 and backfill_offset < len(backfill_players):
bf_slice = backfill_players[
backfill_offset : backfill_offset + pack_shortfall
]
this_pack_players.extend(bf_slice)
backfill_offset += len(bf_slice)
# Collect rare pull notifications
for player in this_pack_players:
if player["rarity"]["value"] >= 3:
all_pull_notifs.append(player)
per_pack_players.append(this_pack_players)
# --- Phase C: Write cards + mark packs opened, all gathered ---
open_time = int(datetime.datetime.timestamp(datetime.datetime.now()) * 1000)
write_coros = []
for pack, this_pack_players in zip(all_packs, per_pack_players):
write_coros.append(
db_post(
"cards",
payload={
"cards": [
{
"player_id": p["player_id"],
"team_id": pack["team"]["id"],
"pack_id": pack["id"],
}
for p in this_pack_players
]
},
timeout=10,
)
)
write_coros.append(
db_patch(
"packs",
object_id=pack["id"],
params=[("open_time", open_time)],
)
)
write_results = await asyncio.gather(*write_coros)
# Check card creation results (every other result starting at index 0)
for i in range(0, len(write_results), 2):
if not write_results[i]:
raise ConnectionError("Failed to create this pack of cards.")
# --- Gather notification posts ---
if all_pull_notifs:
notif_coros = []
for pull in all_pull_notifs:
logger.info(f"good pull: {pull}")
notif_coros.append(
db_post(
"notifs",
payload={
"created": int(
datetime.datetime.timestamp(datetime.datetime.now()) * 1000
),
"title": "Rare Pull",
"field_name": f"{player_desc(pull)} ({pull['rarity']['name']})",
"message": f"Pulled by {team['abbrev']}",
"about": f"Player-{pull['player_id']}",
},
)
)
await asyncio.gather(*notif_coros)
return [pack["id"] for pack in all_packs]
async def give_packs(team: dict, num_packs: int, pack_type: dict = None) -> dict: async def give_packs(team: dict, num_packs: int, pack_type: dict = None) -> dict:
@ -1786,18 +1860,14 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
pack_type_name = all_packs[0].get("pack_type", {}).get("name") pack_type_name = all_packs[0].get("pack_type", {}).get("name")
if pack_type_name in SCOUTABLE_PACK_TYPES: if pack_type_name in SCOUTABLE_PACK_TYPES:
await asyncio.gather( for p_id in pack_ids:
*[ pack_cards = [c for c in all_cards if c.get("pack_id") == p_id]
create_scout_opportunity( if pack_cards:
[c for c in all_cards if c.get("pack_id") == p_id], await create_scout_opportunity(
team, pack_cards, team, pack_channel, author, context
pack_channel,
author,
context,
) )
for p_id in pack_ids if len(pack_ids) > 1:
] await asyncio.sleep(2)
)
async def get_choice_from_cards( async def get_choice_from_cards(

View File

@ -1,36 +0,0 @@
"""
Shared Refractor Constants
Single source of truth for tier names and colors used across the refractor
system. All consumers (status view, notifications, player view) import from
here to prevent silent divergence.
"""
# Human-readable display names for each tier number.
TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
# Embed accent colors for the /refractor status view.
# These use muted/metallic tones suited to a card-list display.
STATUS_TIER_COLORS = {
0: 0x95A5A6, # slate grey
1: 0xBDC3C7, # silver/chrome
2: 0x3498DB, # refractor blue
3: 0xF1C40F, # gold
4: 0x1ABC9C, # teal superfractor
}
# Embed accent colors for tier-up notification embeds.
# These use brighter/more celebratory tones to signal a milestone event.
# T2 is gold (not blue) to feel like an achievement unlock, not a status indicator.
NOTIF_TIER_COLORS = {
1: 0x2ECC71, # green
2: 0xF1C40F, # gold
3: 0x9B59B6, # purple
4: 0x1ABC9C, # teal (superfractor)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -7,42 +7,60 @@ from in_game.managerai_responses import JumpResponse
def test_create_ai(session: Session): def test_create_ai(session: Session):
all_ai = session.exec(select(ManagerAi)).all() all_ai = session.exec(select(ManagerAi)).all()
assert len(all_ai) == 3 assert len(all_ai) == 3
assert ManagerAi.create_ai(session) == True assert ManagerAi.create_ai(session) == True
all_ai = session.exec(select(ManagerAi)).all() all_ai = session.exec(select(ManagerAi)).all()
assert len(all_ai) == 3 assert len(all_ai) == 3
def test_check_jump(session: Session): def test_check_jump(session: Session):
balanced_ai = session.exec(select(ManagerAi).where(ManagerAi.name == 'Balanced')).one() balanced_ai = session.exec(
aggressive_ai = session.exec(select(ManagerAi).where(ManagerAi.name == 'Yolo')).one() select(ManagerAi).where(ManagerAi.name == "Balanced")
).one()
aggressive_ai = session.exec(
select(ManagerAi).where(ManagerAi.name == "Yolo")
).one()
this_game = session.get(Game, 1) this_game = session.get(Game, 1)
runner = session.get(Lineup, 5) runner = session.get(Lineup, 5)
this_play = session.get(Play, 2) this_play = session.get(Play, 2)
this_play.on_first = runner this_play.on_first = runner
assert this_play.starting_outs == 1 assert this_play.starting_outs == 1
assert balanced_ai.check_jump(session, this_game, to_base=2) == JumpResponse(ai_note='- SEND **Player 4** to second if they get the jump', min_safe=16) assert balanced_ai.check_jump(session, this_game, to_base=2) == JumpResponse(
assert aggressive_ai.check_jump(session, this_game, to_base=2) == JumpResponse(ai_note='- SEND **Player 4** to second if they get the jump', min_safe=13, run_if_auto_jump=True) ai_note="- SEND **Player 4** to second if they get the jump", min_safe=16
)
assert aggressive_ai.check_jump(session, this_game, to_base=2) == JumpResponse(
ai_note="- SEND **Player 4** to second if they get the jump",
min_safe=13,
run_if_auto_jump=True,
)
this_play.on_third = runner this_play.on_third = runner
assert balanced_ai.check_jump(session, this_game, to_base=4) == JumpResponse(min_safe=8) assert balanced_ai.check_jump(session, this_game, to_base=4) == JumpResponse(
assert aggressive_ai.check_jump(session, this_game, to_base=4) == JumpResponse(min_safe=5) min_safe=8
)
assert aggressive_ai.check_jump(session, this_game, to_base=4) == JumpResponse(
min_safe=5
)
def test_tag_from_second(session: Session): def test_tag_from_second(session: Session):
balanced_ai = session.exec(select(ManagerAi).where(ManagerAi.name == 'Balanced')).one() balanced_ai = session.exec(
aggressive_ai = session.exec(select(ManagerAi).where(ManagerAi.name == 'Yolo')).one() select(ManagerAi).where(ManagerAi.name == "Balanced")
).one()
aggressive_ai = session.exec(
select(ManagerAi).where(ManagerAi.name == "Yolo")
).one()
this_game = session.get(Game, 1) this_game = session.get(Game, 1)
runner = session.get(Lineup, 5) runner = session.get(Lineup, 5)
this_play = session.get(Play, 2) this_play = session.get(Play, 2)
this_play.on_second = runner this_play.on_second = runner
assert this_play.starting_outs == 1 assert this_play.starting_outs == 1
@ -53,4 +71,30 @@ def test_tag_from_second(session: Session):
assert balanced_resp.min_safe == 5 assert balanced_resp.min_safe == 5
assert aggressive_resp.min_safe == 2 assert aggressive_resp.min_safe == 2
def test_gb_decide_run(session: Session):
"""
Verifies that gb_decide_run returns a min_safe threshold based on self.running
plus an aggression modifier, with outs adjustment applied.
With 1 out (no outs adjustment):
- Balanced (running=5, behind_aggression=5): adjusted_running=5 tier 5 min_safe=6
- Yolo (running=10, behind_aggression=10): adjusted_running=15 tier 8 min_safe=4
"""
balanced_ai = session.exec(
select(ManagerAi).where(ManagerAi.name == "Balanced")
).one()
aggressive_ai = session.exec(
select(ManagerAi).where(ManagerAi.name == "Yolo")
).one()
this_game = session.get(Game, 1)
this_play = session.get(Play, 2)
assert this_play.starting_outs == 1
balanced_resp = balanced_ai.gb_decide_run(session, this_game)
aggressive_resp = aggressive_ai.gb_decide_run(session, this_game)
assert balanced_resp.min_safe == 6
assert aggressive_resp.min_safe == 4

View File

@ -1,709 +0,0 @@
# Refractor System -- In-App Integration Test Plan
**Target environment**: Dev Discord server (Guild ID: `613880856032968834`)
**Dev API**: `pddev.manticorum.com`
**Bot container**: `paper-dynasty_discord-app_1` on `sba-bots`
**Date created**: 2026-03-25
This test plan is designed for browser automation (Playwright against the Discord
web client) or manual execution. Each test case specifies an exact slash command,
the expected bot response, and pass/fail criteria.
---
## Table of Contents
1. [Prerequisites](#prerequisites)
2. [API Health Checks](#1-api-health-checks)
3. [/refractor status -- Basic Functionality](#2-refractor-status----basic-functionality)
4. [/refractor status -- Filters](#3-refractor-status----filters)
5. [/refractor status -- Pagination](#4-refractor-status----pagination)
6. [/refractor status -- Edge Cases and Errors](#5-refractor-status----edge-cases-and-errors)
7. [Tier Badges on Card Embeds](#6-tier-badges-on-card-embeds)
8. [Post-Game Hook -- Stat Accumulation and Evaluation](#7-post-game-hook----stat-accumulation-and-evaluation)
9. [Tier-Up Notifications](#8-tier-up-notifications)
10. [Cross-Command Badge Propagation](#9-cross-command-badge-propagation)
11. [Known Gaps and Risks](#known-gaps-and-risks)
---
## Prerequisites
Before running these tests, ensure the following state exists:
### Bot State
- [ ] Bot is online and healthy: `GET http://sba-bots:8081/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:8081/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 /player command (player lookup)
| Field | Value |
|---|---|
| **Description** | Look up a card that has a refractor tier > 0 |
| **Discord command** | `/player {player_name}` (use a player known to have refractor state) |
| **Expected result** | Embed title is `[BC] Player Name` (or appropriate badge for their tier) |
| **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** | `/player {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** | `/player {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 card-by-name {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** | `/open-packs` |
| **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 /player and /refractor status
| Field | Value |
|---|---|
| **Description** | Compare the badge shown for the same player in both views |
| **Discord command** | Run both `/player {player}` and `/refractor status` for the same player |
| **Expected result** | The badge in the `/player` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` |
| **Pass criteria** | Tier badge letter matches: T1=[BC], T2=[R], T3=[GR], T4=[SF] |
---
## 7. Post-Game Hook -- Stat Accumulation and Evaluation
These tests verify the end-to-end flow: play a game -> stats update -> refractor
evaluation -> optional tier-up notification.
### Prerequisites for Game Tests
- Two teams exist on the dev server (the test user's team + an AI opponent)
- The test user's team has a valid lineup and starting pitcher set
- Record the game ID from the game channel name after starting
**Note:** Cal will perform the test game manually in Discord. Sections 7 and 8
(REF-50 through REF-64) are not automated via Playwright — game simulation
requires interactive play that is impractical to automate through the Discord
web client. After the game completes, the verification checks (REF-52 through
REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
### REF-50: Start a game against AI
| Field | Value |
|---|---|
| **Description** | Start a new game to create a game context |
| **Discord command** | `/new-game mlb-campaign league:Minor League away_team_abbrev:{user_team} home_team_abbrev:{ai_team}` |
| **Expected result** | Game channel is created; game starts successfully |
| **Pass criteria** | A new channel appears; scorebug embed is posted |
| **Notes** | This is setup for REF-51 through REF-54 |
### REF-51: Complete a game (manual or auto-roll)
| Field | Value |
|---|---|
| **Description** | Play the game through to completion |
| **Discord command** | Use `/log ab` repeatedly or auto-roll to finish the game |
| **Expected result** | Game ends; final score is posted; game summary embed appears |
| **Pass criteria** | 1. Game over message appears |
| | 2. No errors in bot logs during the post-game hook |
### REF-52: Verify season stats updated post-game
| Field | Value |
|---|---|
| **Description** | After game completion, check that season stats were updated |
| **Verification** | Check bot logs for successful POST to `season-stats/update-game/{game_id}` |
| **Pass criteria** | 1. Bot logs show the season-stats POST was made |
| | 2. No error logged for that call |
| **API check** | `curl "https://pddev.manticorum.com/api/v2/season-stats?team_id={team_id}" -H "Authorization: Bearer $TOKEN"` returns updated stats |
### REF-53: Verify refractor evaluation triggered post-game
| Field | Value |
|---|---|
| **Description** | After game completion, check that refractor evaluation was called |
| **Verification** | Check bot logs for successful POST to `refractor/evaluate-game/{game_id}` |
| **Pass criteria** | 1. Bot logs show the refractor evaluate-game POST was made |
| | 2. The call happened AFTER the season-stats call (ordering matters) |
| | 3. Log does not show "Post-game refractor processing failed" |
### REF-54: Verify refractor values changed after game
| Field | Value |
|---|---|
| **Description** | After a completed game, check that formula values increased for participating players |
| **Discord command** | `/refractor status` (compare before/after values for a participating player) |
| **Expected result** | `formula_value` for batters who had PAs and pitchers who recorded outs should be higher than before the game |
| **Pass criteria** | At least one card's formula_value has increased |
| **API check** | `curl "https://pddev.manticorum.com/api/v2/refractor/cards/{CARD_BATTER}" -H "Authorization: Bearer $TOKEN"` -- compare `current_value` before and after |
### REF-55: Post-game hook is non-fatal
| Field | Value |
|---|---|
| **Description** | Even if the refractor API fails, the game completion should succeed |
| **Verification** | This is tested via unit tests (test_complete_game_hook.py). For integration: verify that if the API has a momentary error, the game result is still saved and the channel reflects the final score. |
| **Pass criteria** | Game results persist even if refractor evaluation errors appear in logs |
---
## 8. Tier-Up Notifications
### REF-60: Tier-up embed format (T0 -> T1)
| Field | Value |
|---|---|
| **Description** | When a card tiers up from T0 to T1 (Base Chrome), a notification embed is sent |
| **Trigger** | Complete a game where a player's formula_value crosses the T1 threshold |
| **Expected result** | An embed appears in the game channel with: |
| | - Title: "Refractor Tier Up!" |
| | - Description: `**{Player Name}** reached **Tier 1 (Base Chrome)** on the **{Track Name}** track` |
| | - Color: green (`0x2ECC71`) |
| | - Footer: "Paper Dynasty Refractor" |
| **Pass criteria** | 1. Embed title is exactly "Refractor Tier Up!" |
| | 2. Player name appears bold in description |
| | 3. Tier number and name are correct |
| | 4. Track name is one of: Batter Track, Starting Pitcher Track, Relief Pitcher Track |
| | 5. Footer text is "Paper Dynasty Refractor" |
### REF-61: Tier-up embed colors per tier
| Field | Value |
|---|---|
| **Description** | Each tier has a distinct embed color |
| **Expected colors** | T1: green (`0x2ECC71`), T2: gold (`0xF1C40F`), T3: purple (`0x9B59B6`), T4: teal (`0x1ABC9C`) |
| **Pass criteria** | Embed color matches the target tier |
| **Notes** | May require manual API manipulation to trigger specific tier transitions |
### REF-62: Superfractor notification (T3 -> T4)
| Field | Value |
|---|---|
| **Description** | The Superfractor tier-up has special formatting |
| **Trigger** | A player crosses the T4 threshold |
| **Expected result** | Embed with: |
| | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") |
| | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` |
| | - Color: teal (`0x1ABC9C`) |
| **Pass criteria** | 1. Title is "SUPERFRACTOR!" |
| | 2. Description mentions "maximum refractor tier" |
### REF-63: Multiple tier-ups in one game
| Field | Value |
|---|---|
| **Description** | When multiple players tier up in the same game, each gets a separate notification |
| **Trigger** | Complete a game where 2+ players cross thresholds |
| **Expected result** | One embed per tier-up, posted sequentially in the game channel |
| **Pass criteria** | Each tier-up gets its own embed; no tier-ups are lost |
### REF-64: No notification when no tier-ups occur
| Field | Value |
|---|---|
| **Description** | Most games will not produce any tier-ups; verify no spurious notifications |
| **Trigger** | Complete a game where no thresholds are crossed |
| **Expected result** | No tier-up embeds appear in the channel |
| **Pass criteria** | The only game-end messages are the standard game summary and rewards |
---
## 9. Cross-Command Badge Propagation
These tests verify that tier badges appear (or correctly do not appear) in all
commands that display card information.
### REF-70: /team command -- cards show tier badges
| Field | Value |
|---|---|
| **Discord command** | `/team` |
| **Expected result** | If team/roster display uses `get_card_embeds()`, cards with refractor state show tier badges |
| **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
### 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: /scout-tokens -- no badge expected
| Field | Value |
|---|---|
| **Discord command** | `/scout-tokens` |
| **Expected result** | Scout tokens display does not show card embeds, so no badges are expected |
| **Pass criteria** | Command responds with token count; no card embeds or badges displayed |
| **Notes** | `/scout-tokens` shows remaining daily tokens, not card embeds. Badge propagation is not applicable here. |
---
## 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. [x] 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 ✓)
- Tested 2026-04-07: REF-API-02 (tracks ✓), REF-API-04 (404 nonexistent ✓), REF-API-05 (evolution removed ✓), REF-API-08 (tier filter ✓), REF-API-09 (progress=close ✓)
- REF-API-01 (bot health) not tested via API (port conflict with adminer on localhost:8080), but bot confirmed healthy via logs
2. [x] 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] ✓)
- Tested 2026-04-07: REF-03 (SP format ✓), REF-04 (RP format ✓)
- Bugs found and fixed (2026-03-25): wrong response key ("cards" vs "items"), wrong field names (formula_value vs current_value, card_type nesting), limit=500 exceeding API max, floating point display
- Note: formula labels (IP+K, PA+TB x 2) from test spec are not rendered; format is value/threshold (pct%) only
- REF-06 (fully evolved) not testable — no T4 cards exist in test data
3. [x] Execute REF-10 through REF-19 (filters)
- Tested 2026-03-25: REF-10 (card_type=batter ✓ after fix)
- Tested 2026-04-07: REF-11 (sp ✓), REF-12 (rp ✓), REF-13 (tier=0 ✓), REF-14 (tier=1 ✓), REF-15 (tier=4 empty ✓), REF-16 (progress=close ✓), REF-17 (batter+T1 combined ✓), REF-18 (T4+close empty ✓)
- Choice dropdown menus added for all filter params (PR #126)
- REF-19 (season filter): N/A — season param not implemented in the slash command
4. [x] Execute REF-20 through REF-23 (pagination)
- Tested 2026-03-25: REF-20 (page 1 footer ✓), pagination buttons added (PR #127)
- Tested 2026-04-07: REF-21 (page 2 ✓), REF-22 (page=999 clamps to last page ✓ — fixed in discord#141/#142), REF-23 (page 0 clamps to 1 ✓), Prev/Next buttons (✓)
5. [x] Execute REF-30 through REF-34 (edge cases)
- Tested 2026-04-07: REF-34 (page=-5 clamps to 1 ✓)
- REF-30 (no team), REF-31 (no refractor data), REF-32 (invalid card_type), REF-33 (negative tier): not tested — require alt account or manual API state manipulation
6. [N/A] Execute REF-40 through REF-45 (tier badges on card embeds)
- **Design gap**: `get_card_embeds()` looks up refractor state via `card['id']`, but all user-facing commands (`/player`, `/buy`) use `get_blank_team_card()` which has no `id` field. The `except Exception: pass` silently swallows the KeyError. Badges never appear outside `/refractor status`. `/open-packs` uses real card objects but results are random. No command currently surfaces badges on card embeds in practice.
7. [ ] Execute REF-50 through REF-55 (post-game hook -- requires live game)
8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
9. [N/A] Execute REF-70 through REF-72 (cross-command badge propagation)
- REF-70: `/team` shows team overview, not card embeds — badges not applicable
- REF-71: `/show-card defense` only works during active games — expected no badge (by design)
- REF-72: `/scout-tokens` shows token count, not card embeds — badges not applicable
10. [x] Execute REF-80 through REF-82 (force-evaluate API)
- Tested 2026-03-25: REF-80 (force evaluate ✓ — used to seed 100 cards for team 31)
- Tested 2026-04-07: REF-81 (no stats → 404 ✓), REF-82 (nonexistent card → 404 ✓)
### Approximate Time Estimates
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes
- /refractor status tests (REF-01 through REF-34): 10-15 minutes
- Tier badge tests (REF-40 through REF-45): 5-10 minutes
- Game simulation tests (REF-50 through REF-55): 15-30 minutes (depends on game length)
- Tier-up notification tests (REF-60 through REF-64): Requires setup; 10-20 minutes
- Cross-command tests (REF-70 through REF-72): 5 minutes
- Force-evaluate API tests (REF-80 through REF-82): 2-3 minutes
**Total estimated time**: 50-90 minutes for full suite (87 test cases)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,9 @@
""" """
Tests for Refractor Tier Completion Notification embeds. Tests for Evolution Tier Completion Notification embeds.
These tests verify that: These tests verify that:
1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color). 1. Tier-up embeds are correctly formatted for tiers 1-3 (title, description, color).
2. Tier 4 (Superfractor) embeds include the special title, description, and color. 2. Tier 4 (Fully Evolved) embeds include the special title, description, and note field.
3. Multiple tier-up events each produce a separate embed. 3. Multiple tier-up events each produce a separate embed.
4. An empty tier-up list results in no channel sends. 4. An empty tier-up list results in no channel sends.
@ -17,7 +17,7 @@ from unittest.mock import AsyncMock
import discord import discord
from helpers.refractor_notifs import build_tier_up_embed, notify_tier_completion from helpers.evolution_notifs import build_tier_up_embed, notify_tier_completion
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Fixtures # Fixtures
@ -49,11 +49,11 @@ def make_tier_up(
class TestBuildTierUpEmbed: class TestBuildTierUpEmbed:
"""Verify that build_tier_up_embed produces correctly structured embeds.""" """Verify that build_tier_up_embed produces correctly structured embeds."""
def test_title_is_refractor_tier_up(self): def test_title_is_evolution_tier_up(self):
"""Title must read 'Refractor Tier Up!' for any non-max tier.""" """Title must read 'Evolution Tier Up!' for any non-max tier."""
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.title == "Refractor Tier Up!" assert embed.title == "Evolution Tier Up!"
def test_description_contains_player_name(self): def test_description_contains_player_name(self):
"""Description must contain the player's name.""" """Description must contain the player's name."""
@ -65,11 +65,11 @@ class TestBuildTierUpEmbed:
"""Description must include the human-readable tier name for the new tier.""" """Description must include the human-readable tier name for the new tier."""
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
# Tier 2 display name is "Refractor" # Tier 2 display name is "Rising"
assert "Refractor" in embed.description assert "Rising" in embed.description
def test_description_contains_track_name(self): def test_description_contains_track_name(self):
"""Description must mention the refractor track (e.g., 'Batter').""" """Description must mention the evolution track (e.g., 'Batter')."""
tier_up = make_tier_up(track_name="Batter", new_tier=2) tier_up = make_tier_up(track_name="Batter", new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert "Batter" in embed.description assert "Batter" in embed.description
@ -92,11 +92,11 @@ class TestBuildTierUpEmbed:
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x9B59B6 assert embed.color.value == 0x9B59B6
def test_footer_text_is_paper_dynasty_refractor(self): def test_footer_text_is_paper_dynasty_evolution(self):
"""Footer text must be 'Paper Dynasty Refractor' for brand consistency.""" """Footer text must be 'Paper Dynasty Evolution' for brand consistency."""
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Refractor" assert embed.footer.text == "Paper Dynasty Evolution"
def test_returns_discord_embed_instance(self): def test_returns_discord_embed_instance(self):
"""Return type must be discord.Embed so it can be sent directly.""" """Return type must be discord.Embed so it can be sent directly."""
@ -106,24 +106,24 @@ class TestBuildTierUpEmbed:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Unit: build_tier_up_embed — tier 4 (superfractor) # Unit: build_tier_up_embed — tier 4 (fully evolved)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestBuildTierUpEmbedSuperfractor: class TestBuildTierUpEmbedFullyEvolved:
"""Verify that tier 4 (Superfractor) embeds use special formatting.""" """Verify that tier 4 (Fully Evolved) embeds use special formatting."""
def test_title_is_superfractor(self): def test_title_is_fully_evolved(self):
"""Tier 4 title must be 'SUPERFRACTOR!' to emphasise max achievement.""" """Tier 4 title must be 'FULLY EVOLVED!' to emphasise max achievement."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.title == "SUPERFRACTOR!" assert embed.title == "FULLY EVOLVED!"
def test_description_mentions_maximum_refractor_tier(self): def test_description_mentions_maximum_evolution(self):
"""Tier 4 description must mention 'maximum refractor tier' per the spec.""" """Tier 4 description must mention 'maximum evolution' per the spec."""
tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4) tier_up = make_tier_up(player_name="Mike Trout", old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert "maximum refractor tier" in embed.description.lower() assert "maximum evolution" in embed.description.lower()
def test_description_contains_player_name(self): def test_description_contains_player_name(self):
"""Player name must appear in the tier 4 description.""" """Player name must appear in the tier 4 description."""
@ -138,22 +138,47 @@ class TestBuildTierUpEmbedSuperfractor:
assert "Batter" in embed.description assert "Batter" in embed.description
def test_tier4_color_is_teal(self): def test_tier4_color_is_teal(self):
"""Tier 4 uses teal (0x1abc9c) to visually distinguish superfractor.""" """Tier 4 uses teal (0x1abc9c) to visually distinguish max evolution."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.color.value == 0x1ABC9C assert embed.color.value == 0x1ABC9C
def test_no_extra_fields(self): def test_note_field_present(self):
"""Tier 4 embed should have no extra fields — boosts are live, no teaser needed.""" """Tier 4 must include a note field about future rating boosts."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert len(embed.fields) == 0 field_names = [f.name for f in embed.fields]
assert any(
"rating" in name.lower()
or "boost" in name.lower()
or "note" in name.lower()
for name in field_names
), "Expected a field mentioning rating boosts for tier 4 embed"
def test_footer_text_is_paper_dynasty_refractor(self): def test_note_field_value_mentions_future_update(self):
"""Footer must remain 'Paper Dynasty Refractor' for tier 4 as well.""" """The note field value must reference the future rating boost update."""
tier_up = make_tier_up(old_tier=3, new_tier=4) tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up) embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Refractor" note_field = next(
(
f
for f in embed.fields
if "rating" in f.name.lower()
or "boost" in f.name.lower()
or "note" in f.name.lower()
),
None,
)
assert note_field is not None
assert (
"future" in note_field.value.lower() or "update" in note_field.value.lower()
)
def test_footer_text_is_paper_dynasty_evolution(self):
"""Footer must remain 'Paper Dynasty Evolution' for tier 4 as well."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
assert embed.footer.text == "Paper Dynasty Evolution"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -179,9 +204,9 @@ class TestNotifyTierCompletion:
tier_up = make_tier_up(new_tier=2) tier_up = make_tier_up(new_tier=2)
await notify_tier_completion(channel, tier_up) await notify_tier_completion(channel, tier_up)
_, kwargs = channel.send.call_args _, kwargs = channel.send.call_args
assert "embed" in kwargs, ( assert (
"notify_tier_completion must send an embed, not plain text" "embed" in kwargs
) ), "notify_tier_completion must send an embed, not plain text"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_embed_type_is_discord_embed(self): async def test_embed_type_is_discord_embed(self):
@ -216,9 +241,9 @@ class TestNotifyTierCompletion:
] ]
for event in events: for event in events:
await notify_tier_completion(channel, event) await notify_tier_completion(channel, event)
assert channel.send.call_count == 3, ( assert (
"Each tier-up event must produce its own embed (no batching)" channel.send.call_count == 3
) ), "Each tier-up event must produce its own embed (no batching)"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_tier_ups_means_no_sends(self): async def test_no_tier_ups_means_no_sends(self):
@ -232,72 +257,3 @@ class TestNotifyTierCompletion:
for event in tier_up_events: for event in tier_up_events:
await notify_tier_completion(channel, event) await notify_tier_completion(channel, event)
channel.send.assert_not_called() channel.send.assert_not_called()
# ---------------------------------------------------------------------------
# T1-5: tier_up dict shape validation
# ---------------------------------------------------------------------------
class TestTierUpDictShapeValidation:
"""
T1-5: Verify build_tier_up_embed handles valid API shapes correctly and
rejects malformed input.
The evaluate-game API endpoint returns the full shape (player_name,
old_tier, new_tier, track_name, current_value). These tests guard the
contract between the API response and the embed builder.
"""
def test_empty_dict_raises_key_error(self):
"""
An empty dict must raise KeyError guards against callers passing
unrelated or completely malformed data.
"""
with pytest.raises(KeyError):
build_tier_up_embed({})
def test_full_api_shape_builds_embed(self):
"""
The full shape returned by the evaluate-game endpoint builds a valid
embed without error.
"""
full_shape = make_tier_up(
player_name="Mike Trout",
old_tier=1,
new_tier=2,
track_name="Batter Track",
current_value=150,
)
embed = build_tier_up_embed(full_shape)
assert embed is not None
assert "Mike Trout" in embed.description
# ---------------------------------------------------------------------------
# T2-7: notify_tier_completion with None channel
# ---------------------------------------------------------------------------
class TestNotifyTierCompletionNoneChannel:
"""
T2-7: notify_tier_completion must not propagate exceptions when the channel
is None.
Why: the post-game hook may call notify_tier_completion before a valid
channel is resolved (e.g. in tests, or if the scoreboard channel lookup
fails). The try/except in notify_tier_completion should catch the
AttributeError from None.send() so game flow is never interrupted.
"""
@pytest.mark.asyncio
async def test_none_channel_does_not_raise(self):
"""
Passing None as the channel argument must not raise.
None.send() raises AttributeError; the try/except in
notify_tier_completion is expected to absorb it silently.
"""
tier_up = make_tier_up(new_tier=2)
# Should not raise regardless of channel being None
await notify_tier_completion(None, tier_up)

View File

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

View File

@ -1,387 +0,0 @@
"""
Mock-based integration tests for the post-game refractor hook.
Tests _run_post_game_refractor_hook() which orchestrates:
1. POST season-stats/update-game/{game_id} update player season stats
2. POST refractor/evaluate-game/{game_id} evaluate refractor milestones
3. _trigger_variant_renders() with the full tier_ups list (returns image_url map)
4. notify_tier_completion() once per tier-up, with image_url from render
The hook is wrapped in try/except so failures are non-fatal the game
result is already persisted before this block runs. These tests cover the
orchestration logic (REF-50+ scenarios) without requiring a live game.
"""
from unittest.mock import AsyncMock, MagicMock, call, patch
from command_logic.logic_gameplay import _run_post_game_refractor_hook
def _make_channel(channel_id: int = 999) -> MagicMock:
ch = MagicMock()
ch.id = channel_id
return ch
# ---------------------------------------------------------------------------
# Endpoint ordering
# ---------------------------------------------------------------------------
class TestEndpointOrder:
"""Season-stats must be POSTed before refractor evaluate."""
async def test_calls_both_endpoints(self):
"""Both POST endpoints are called for every game completion."""
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = {}
await _run_post_game_refractor_hook(42, _make_channel())
assert mock_post.call_count == 2
async def test_season_stats_before_evaluate(self):
"""Season stats must be updated before refractor evaluate runs.
player_season_stats must exist before the refractor engine reads them
for milestone evaluation wrong order yields stale data.
"""
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = {}
await _run_post_game_refractor_hook(42, _make_channel())
calls = mock_post.call_args_list
assert calls[0] == call("season-stats/update-game/42")
assert calls[1] == call("refractor/evaluate-game/42")
async def test_game_id_interpolated_correctly(self):
"""The game ID is interpolated into both endpoint URLs."""
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = {}
await _run_post_game_refractor_hook(99, _make_channel())
urls = [c.args[0] for c in mock_post.call_args_list]
assert "season-stats/update-game/99" in urls
assert "refractor/evaluate-game/99" in urls
# ---------------------------------------------------------------------------
# Tier-up notifications
# ---------------------------------------------------------------------------
class TestTierUpNotifications:
"""notify_tier_completion is called once per tier-up in the API response."""
async def test_notifies_for_each_tier_up(self):
"""Each tier_up dict is forwarded to notify_tier_completion."""
tier_ups = [
{
"player_id": 101,
"player_name": "Mike Trout",
"old_tier": 0,
"new_tier": 1,
"current_value": 30.0,
"track_name": "Batter Track",
},
{
"player_id": 202,
"player_name": "Shohei Ohtani",
"old_tier": 1,
"new_tier": 2,
"current_value": 60.0,
"track_name": "Pitcher Track",
},
]
async def fake_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": tier_ups}
return {}
channel = _make_channel()
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
):
await _run_post_game_refractor_hook(99, channel)
assert mock_notify.call_count == 2
forwarded = [c.args[1] for c in mock_notify.call_args_list]
assert tier_ups[0] in forwarded
assert tier_ups[1] in forwarded
async def test_channel_passed_to_notify(self):
"""notify_tier_completion receives the channel from complete_game."""
tier_up = {
"player_id": 1,
"player_name": "Mike Trout",
"old_tier": 0,
"new_tier": 1,
"current_value": 30.0,
"track_name": "Batter Track",
}
async def fake_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": [tier_up]}
return {}
channel = _make_channel()
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
):
await _run_post_game_refractor_hook(1, channel)
mock_notify.assert_called_once_with(channel, tier_up, image_url=None)
async def test_no_notify_when_empty_tier_ups(self):
"""No notifications sent when evaluate returns an empty tier_ups list."""
async def fake_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": []}
return {}
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
),
):
await _run_post_game_refractor_hook(55, _make_channel())
mock_notify.assert_not_called()
async def test_no_notify_when_tier_ups_key_absent(self):
"""No notifications when evaluate response has no tier_ups key."""
async def fake_post(endpoint):
return {}
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
),
):
await _run_post_game_refractor_hook(55, _make_channel())
mock_notify.assert_not_called()
# ---------------------------------------------------------------------------
# Variant render triggers
# ---------------------------------------------------------------------------
class TestVariantRenderTriggers:
"""_trigger_variant_renders receives the full tier_ups list."""
async def test_trigger_renders_called_with_all_tier_ups(self):
"""_trigger_variant_renders is called once with the complete tier_ups list."""
tier_ups = [
{"player_id": 101, "variant_created": 7, "track_name": "Batter"},
{"player_id": 202, "variant_created": 3, "track_name": "Pitcher"},
]
async def fake_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": tier_ups}
return {}
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
),
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
) as mock_render,
):
await _run_post_game_refractor_hook(42, _make_channel())
mock_render.assert_called_once_with(tier_ups)
async def test_no_trigger_when_no_tier_ups(self):
"""_trigger_variant_renders is not called when tier_ups is empty."""
async def fake_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": []}
return {}
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
),
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
) as mock_render,
):
await _run_post_game_refractor_hook(42, _make_channel())
mock_render.assert_not_called()
async def test_render_before_notification(self):
"""_trigger_variant_renders is called before notify_tier_completion.
Renders run first so that image URLs are available to include in the
tier-up notification embed. The player sees the card art immediately
rather than receiving a text-only notification.
"""
call_order = []
tier_up = {"player_id": 1, "variant_created": 5, "track_name": "Batter"}
async def fake_post(endpoint):
if "refractor" in endpoint:
return {"tier_ups": [tier_up]}
return {}
async def fake_notify(ch, tu, image_url=None):
call_order.append("notify")
async def fake_render(tier_ups):
call_order.append("render")
return {}
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
side_effect=fake_notify,
),
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
side_effect=fake_render,
),
):
await _run_post_game_refractor_hook(1, _make_channel())
assert call_order == ["render", "notify"]
# ---------------------------------------------------------------------------
# Non-fatal error handling
# ---------------------------------------------------------------------------
class TestNonFatalErrors:
"""Hook failures must never propagate to the caller."""
async def test_nonfatal_when_season_stats_raises(self):
"""Exception from season-stats update does not propagate.
The game is already saved refractor failure must not interrupt
the completion flow or show an error to the user.
"""
with patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=Exception("stats API down"),
):
await _run_post_game_refractor_hook(7, _make_channel())
async def test_nonfatal_when_evaluate_game_raises(self):
"""Exception from refractor evaluate does not propagate."""
async def fake_post(endpoint):
if "refractor" in endpoint:
raise Exception("refractor API unavailable")
return {}
with patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
):
await _run_post_game_refractor_hook(7, _make_channel())
async def test_nonfatal_when_evaluate_returns_none(self):
"""None response from evaluate-game does not raise or notify."""
async def fake_post(endpoint):
if "refractor" in endpoint:
return None
return {}
with (
patch(
"command_logic.logic_gameplay.db_post",
new_callable=AsyncMock,
side_effect=fake_post,
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
) as mock_notify,
):
await _run_post_game_refractor_hook(7, _make_channel())
mock_notify.assert_not_called()

View File

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

View File

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

View File

@ -0,0 +1,530 @@
"""Tests for roll_for_cards parallelized implementation.
Validates dice rolling, batched player fetches, card creation,
pack marking, MVP backfill, cardset-23 dupe fallback, and notifications.
"""
import os
from unittest.mock import AsyncMock, patch
import pytest
def _make_player(
player_id, rarity_value=0, rarity_name="Replacement", p_name="Test Player"
):
"""Factory for player dicts matching API shape."""
return {
"player_id": player_id,
"rarity": {"value": rarity_value, "name": rarity_name},
"p_name": p_name,
"description": f"2024 {p_name}",
}
_UNSET = object()
def _make_pack(
pack_id, team_id=1, pack_type="Standard", pack_team=None, pack_cardset=_UNSET
):
"""Factory for pack dicts matching API shape."""
return {
"id": pack_id,
"team": {"id": team_id, "abbrev": "TST"},
"pack_type": {"name": pack_type},
"pack_team": pack_team,
"pack_cardset": {"id": 10} if pack_cardset is _UNSET else pack_cardset,
}
def _random_response(players):
"""Wrap a list of player dicts in the API response shape."""
return {"count": len(players), "players": players}
@pytest.fixture
def mock_db():
"""Patch db_get, db_post, db_patch in helpers.main."""
with (
patch("helpers.main.db_get", new_callable=AsyncMock) as mock_get,
patch("helpers.main.db_post", new_callable=AsyncMock) as mock_post,
patch("helpers.main.db_patch", new_callable=AsyncMock) as mock_patch,
):
mock_post.return_value = True
yield mock_get, mock_post, mock_patch
class TestSinglePack:
"""Single pack opening — verifies basic flow."""
async def test_single_standard_pack_creates_cards_and_marks_opened(self, mock_db):
"""A single standard pack should fetch players, create cards, and patch open_time.
Why: Validates the core happy path dice roll fetch create mark opened.
"""
mock_get, mock_post, mock_patch = mock_db
# Return enough players for any rarity tier requested
mock_get.return_value = _random_response([_make_player(i) for i in range(10)])
pack = _make_pack(100)
from helpers.main import roll_for_cards
result = await roll_for_cards([pack])
assert result == [100]
# At least one db_get for player fetches
assert mock_get.call_count >= 1
# Exactly one db_post for cards (may have notif posts too)
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 1
# Exactly one db_patch for marking pack opened
assert mock_patch.call_count == 1
assert mock_patch.call_args.kwargs["object_id"] == 100
async def test_checkin_pack_uses_extra_val(self, mock_db):
"""Check-In Player packs should apply extra_val modifier to dice range.
Why: extra_val shifts the d1000 ceiling, affecting rarity odds for check-in rewards.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(1)])
pack = _make_pack(200, pack_type="Check-In Player")
from helpers.main import roll_for_cards
result = await roll_for_cards([pack], extra_val=500)
assert result == [200]
assert mock_get.call_count >= 1
async def test_unknown_pack_type_raises(self, mock_db):
"""Unrecognized pack types must raise TypeError.
Why: Guards against silent failures if a new pack type is added without dice logic.
"""
mock_get, mock_post, mock_patch = mock_db
pack = _make_pack(300, pack_type="Unknown")
from helpers.main import roll_for_cards
with pytest.raises(TypeError, match="Pack type not recognized"):
await roll_for_cards([pack])
class TestMultiplePacks:
"""Multiple packs — verifies batching and distribution."""
async def test_multiple_packs_return_all_ids(self, mock_db):
"""Opening multiple packs should return all pack IDs.
Why: Callers use the returned IDs to know which packs were successfully opened.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(i) for i in range(50)])
packs = [_make_pack(i) for i in range(5)]
from helpers.main import roll_for_cards
result = await roll_for_cards(packs)
assert result == [0, 1, 2, 3, 4]
async def test_multiple_packs_batch_fetches(self, mock_db):
"""Multiple packs should batch fetches — one db_get per rarity tier, not per pack.
Why: This is the core performance optimization. 5 packs should NOT make 20-30 calls.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(i) for i in range(50)])
packs = [_make_pack(i) for i in range(5)]
from helpers.main import roll_for_cards
await roll_for_cards(packs)
# Standard packs have up to 6 rarity tiers, but typically fewer are non-zero.
# The key assertion: far fewer fetches than 5 packs * ~4 tiers = 20.
player_fetches = [
c for c in mock_get.call_args_list if c.args[0] == "players/random"
]
# At most 6 tier fetches + possible 1 MVP backfill = 7
assert len(player_fetches) <= 7
async def test_multiple_packs_create_cards_per_pack(self, mock_db):
"""Each pack should get its own db_post('cards') call with correct pack_id.
Why: Cards must be associated with the correct pack for display and tracking.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(i) for i in range(50)])
packs = [_make_pack(i) for i in range(3)]
from helpers.main import roll_for_cards
await roll_for_cards(packs)
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 3
# Each card post should reference the correct pack_id
for i, post_call in enumerate(card_posts):
payload = post_call.kwargs["payload"]
pack_ids_in_cards = {card["pack_id"] for card in payload["cards"]}
assert pack_ids_in_cards == {i}
class TestMVPBackfill:
"""MVP fallback when a rarity tier returns fewer players than requested."""
async def test_shortfall_triggers_mvp_backfill(self, mock_db):
"""When a tier returns fewer players than needed, MVP backfill should fire.
Why: Packs must always contain the expected number of cards. Shortfalls are
filled with MVP-tier players as a fallback.
"""
mock_get, mock_post, mock_patch = mock_db
call_count = 0
async def side_effect(endpoint, params=None):
nonlocal call_count
call_count += 1
if params and any(
p[0] == "min_rarity"
and p[1] == 5
and any(q[0] == "max_rarity" for q in params) is False
for p in params
):
# MVP backfill call (no max_rarity)
return _random_response([_make_player(900, 5, "MVP")])
# For tier-specific calls, check if this is the MVP backfill
if params:
param_dict = dict(params)
if "max_rarity" not in param_dict:
return _random_response([_make_player(900, 5, "MVP")])
# Return fewer than requested to trigger shortfall
requested = 5
if params:
for key, val in params:
if key == "limit":
requested = val
break
return _random_response(
[_make_player(i) for i in range(max(0, requested - 1))]
)
mock_get.side_effect = side_effect
pack = _make_pack(100)
from helpers.main import roll_for_cards
result = await roll_for_cards([pack])
assert result == [100]
# Should have at least the tier fetch + backfill call
assert mock_get.call_count >= 2
class TestCardsetExclusion:
"""Cardset 23 should duplicate existing players instead of MVP backfill."""
async def test_cardset_23_duplicates_instead_of_mvp(self, mock_db):
"""For cardset 23, shortfalls should duplicate existing players, not fetch MVPs.
Why: Cardset 23 (special/limited cardset) shouldn't pull from the MVP pool —
it should fill gaps by duplicating from what's already available.
"""
mock_get, mock_post, mock_patch = mock_db
async def side_effect(endpoint, params=None):
if params:
param_dict = dict(params)
# If this is a backfill call (no max_rarity), it shouldn't happen
if "max_rarity" not in param_dict:
pytest.fail("Should not make MVP backfill call for cardset 23")
# Return fewer than requested
return _random_response([_make_player(1)])
mock_get.side_effect = side_effect
pack = _make_pack(100, pack_cardset={"id": 23})
from helpers.main import roll_for_cards
# Force specific dice rolls to ensure a shortfall
with patch("helpers.main.random.randint", return_value=1):
# d1000=1 for Standard: Rep, Rep, Rep, Rep, Rep → 5 Reps needed
result = await roll_for_cards([pack])
assert result == [100]
class TestNotifications:
"""Rare pull notifications should be gathered and sent."""
async def test_rare_pulls_generate_notifications(self, mock_db):
"""Players with rarity >= 3 should trigger notification posts.
Why: Rare pulls are announced to the community all notifs should be sent.
"""
mock_get, mock_post, mock_patch = mock_db
rare_player = _make_player(
42, rarity_value=3, rarity_name="All-Star", p_name="Mike Trout"
)
mock_get.return_value = _random_response([rare_player])
pack = _make_pack(100)
# Force all dice to land on All-Star tier (d1000=951 for card 3)
from helpers.main import roll_for_cards
with patch("helpers.main.random.randint", return_value=960):
await roll_for_cards([pack])
notif_posts = [c for c in mock_post.call_args_list if c.args[0] == "notifs"]
assert len(notif_posts) >= 1
payload = notif_posts[0].kwargs["payload"]
assert payload["title"] == "Rare Pull"
assert "Mike Trout" in payload["field_name"]
async def test_no_notifications_for_common_pulls(self, mock_db):
"""Players with rarity < 3 should NOT trigger notifications.
Why: Only rare pulls are noteworthy common cards would spam the notif feed.
"""
mock_get, mock_post, mock_patch = mock_db
common_player = _make_player(1, rarity_value=0, rarity_name="Replacement")
mock_get.return_value = _random_response([common_player])
pack = _make_pack(100)
from helpers.main import roll_for_cards
# Force low dice rolls (all Replacement)
with patch("helpers.main.random.randint", return_value=1):
await roll_for_cards([pack])
notif_posts = [c for c in mock_post.call_args_list if c.args[0] == "notifs"]
assert len(notif_posts) == 0
class TestErrorHandling:
"""Error propagation from gathered writes."""
async def test_card_creation_failure_raises(self, mock_db):
"""If db_post('cards') returns falsy, ConnectionError must propagate.
Why: Card creation failure means the pack wasn't properly opened — caller
needs to know so it can report the error to the user.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(1)])
mock_post.return_value = False # Simulate failure
pack = _make_pack(100)
from helpers.main import roll_for_cards
with pytest.raises(ConnectionError, match="Failed to create"):
await roll_for_cards([pack])
class TestPackTeamFiltering:
"""Verify correct filter params are passed to player fetch."""
async def test_pack_team_adds_franchise_filter(self, mock_db):
"""When pack has a pack_team, franchise filter should be applied.
Why: Team-specific packs should only contain players from that franchise.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(1)])
pack = _make_pack(
100,
pack_team={"sname": "NYY"},
pack_cardset=None,
)
from helpers.main import roll_for_cards
with patch("helpers.main.random.randint", return_value=1):
await roll_for_cards([pack])
# Check that tier-fetch calls (those with max_rarity) include franchise filter
tier_calls = [
c
for c in mock_get.call_args_list
if any(p[0] == "max_rarity" for p in (c.kwargs.get("params") or []))
]
assert len(tier_calls) >= 1
for c in tier_calls:
param_dict = dict(c.kwargs.get("params") or [])
assert param_dict.get("franchise") == "NYY"
assert param_dict.get("in_packs") is True
async def test_no_team_no_cardset_adds_in_packs(self, mock_db):
"""When pack has no team or cardset, in_packs filter should be applied.
Why: Generic packs still need the in_packs filter to exclude non-packable players.
"""
mock_get, mock_post, mock_patch = mock_db
mock_get.return_value = _random_response([_make_player(1)])
pack = _make_pack(100, pack_team=None, pack_cardset=None)
from helpers.main import roll_for_cards
with patch("helpers.main.random.randint", return_value=1):
await roll_for_cards([pack])
# Check that tier-fetch calls (those with max_rarity) include in_packs filter
tier_calls = [
c
for c in mock_get.call_args_list
if any(p[0] == "max_rarity" for p in (c.kwargs.get("params") or []))
]
assert len(tier_calls) >= 1
for c in tier_calls:
param_dict = dict(c.kwargs.get("params") or [])
assert param_dict.get("in_packs") is True
# ---------------------------------------------------------------------------
# Integration tests — hit real dev API for reads, mock all writes
# ---------------------------------------------------------------------------
requires_api = pytest.mark.skipif(
not os.environ.get("API_TOKEN"),
reason="API_TOKEN not set — skipping integration tests",
)
@requires_api
class TestIntegrationRealFetches:
"""Integration tests that hit the real dev API for player fetches.
Only db_get is real db_post and db_patch are mocked to prevent writes.
Run with: API_TOKEN=<token> python -m pytest tests/test_roll_for_cards.py -k integration -v
"""
@pytest.fixture
def mock_writes(self):
"""Mock only write operations, let reads hit the real API."""
with (
patch("helpers.main.db_post", new_callable=AsyncMock) as mock_post,
patch("helpers.main.db_patch", new_callable=AsyncMock) as mock_patch,
):
mock_post.return_value = True
yield mock_post, mock_patch
async def test_integration_single_pack_fetches_real_players(self, mock_writes):
"""A single standard pack should fetch real players from the dev API.
Why: Validates that the batched fetch params (min_rarity, max_rarity, limit,
in_packs) produce valid responses from the real API and that the returned
players have the expected structure.
"""
mock_post, mock_patch = mock_writes
pack = _make_pack(9999)
from helpers.main import roll_for_cards
result = await roll_for_cards([pack])
assert result == [9999]
# Cards were "created" (mocked)
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 1
payload = card_posts[0].kwargs["payload"]
# Standard pack produces 5 cards
assert len(payload["cards"]) == 5
# Each card has the expected structure
for card in payload["cards"]:
assert "player_id" in card
assert card["team_id"] == 1
assert card["pack_id"] == 9999
async def test_integration_multiple_packs_batch_correctly(self, mock_writes):
"""Multiple packs should batch fetches and distribute players correctly.
Why: Validates the core optimization summing counts across packs, making
fewer API calls, and slicing players back into per-pack groups with real data.
"""
mock_post, mock_patch = mock_writes
packs = [_make_pack(i + 9000) for i in range(3)]
from helpers.main import roll_for_cards
result = await roll_for_cards(packs)
assert result == [9000, 9001, 9002]
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 3
# Each pack should have exactly 5 cards (Standard packs)
total_cards = 0
for post_call in card_posts:
cards = post_call.kwargs["payload"]["cards"]
assert len(cards) == 5
total_cards += len(cards)
assert total_cards == 15
async def test_integration_players_have_valid_rarity(self, mock_writes):
"""Fetched players should have rarity values matching their requested tier.
Why: Confirms the API respects min_rarity/max_rarity filters and that
the player distribution logic assigns correct-tier players to each pack.
"""
mock_post, mock_patch = mock_writes
pack = _make_pack(9999)
from helpers.main import roll_for_cards
# Use fixed dice to get known rarity distribution
# d1000=500 for Standard: Rep, Res, Sta, Res, Sta (mix of low tiers)
with patch("helpers.main.random.randint", return_value=500):
await roll_for_cards([pack])
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 1
cards = card_posts[0].kwargs["payload"]["cards"]
# All cards should have valid player_ids (positive integers from real API)
for card in cards:
assert isinstance(card["player_id"], int)
assert card["player_id"] > 0
async def test_integration_cardset_filter(self, mock_writes):
"""Packs with a specific cardset should only fetch players from that cardset.
Why: Validates that the cardset_id parameter is correctly passed through
the batched fetch and the API filters accordingly.
"""
mock_post, mock_patch = mock_writes
pack = _make_pack(9999, pack_cardset={"id": 24})
from helpers.main import roll_for_cards
with patch("helpers.main.random.randint", return_value=500):
result = await roll_for_cards([pack])
assert result == [9999]
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 1
assert len(card_posts[0].kwargs["payload"]["cards"]) == 5
async def test_integration_checkin_pack(self, mock_writes):
"""Check-In Player pack should fetch exactly 1 player from the real API.
Why: Check-in packs produce a single card validates the simplest
path through the batched fetch logic with real data.
"""
mock_post, mock_patch = mock_writes
pack = _make_pack(9999, pack_type="Check-In Player")
from helpers.main import roll_for_cards
result = await roll_for_cards([pack])
assert result == [9999]
card_posts = [c for c in mock_post.call_args_list if c.args[0] == "cards"]
assert len(card_posts) == 1
# Check-in packs produce exactly 1 card
assert len(card_posts[0].kwargs["payload"]["cards"]) == 1