Compare commits

..

57 Commits

Author SHA1 Message Date
cal
5d86641fda Merge pull request 'feat: show refractor progress in post-game summary embed (#147)' (#160) from issue/147-feat-show-refractor-progress-in-post-game-summary into main
Reviewed-on: #160
2026-04-08 23:21:57 +00:00
Cal Corum
39424f7157 feat: show refractor progress in post-game summary embed (#147)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 24s
Closes #147

Adds a "Refractor Progress" field to the game summary embed showing:
- Cards that tiered up during this game (⬆ Name → Tier Name)
- Cards currently ≥80% toward their next tier on either team (◈ Name (pct%))

The field is omitted entirely when there is nothing to show.

Implementation:
- _run_post_game_refractor_hook() now returns evo_result (or None on failure)
- New _build_refractor_progress_text() fetches close-to-tier cards from both
  teams via refractor/cards?progress=close and formats the combined output
- complete_game() adds the field between Rewards and Highlights sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:36:32 -05:00
cal
8842d80f26 Merge pull request 'feat: include refractor card image in tier-up notification embed (#144)' (#159) from issue/144-feat-tier-up-notification-should-include-refractor into main 2026-04-08 15:25:50 +00:00
Cal Corum
25b63b407f feat: include refractor card image in tier-up notification embed (#144)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 21s
Closes #144

- build_tier_up_embed() accepts optional image_url and calls set_image() when provided
- notify_tier_completion() accepts optional image_url and passes it through
- _trigger_variant_renders() now captures the render response image_url per player and returns a player_id->image_url dict
- _run_post_game_refractor_hook() triggers renders first (to obtain image URLs), then sends notifications with card art included
- Updated test_post_game_refractor_hook.py to reflect new render-before-notify ordering and image_url kwarg in notify calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 09:38:43 -05:00
cal
bd6c387902 Merge pull request 'test: mock-based integration tests for post-game refractor hook (#148)' (#158) from issue/148-test-mock-based-integration-tests-for-post-game-re into main 2026-04-08 14:26:05 +00:00
Cal Corum
eb022c3d66 test: mock-based integration tests for post-game refractor hook (#148)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 28s
Extract _run_post_game_refractor_hook() from complete_game() so the hook
logic can be tested directly without a live game session. Add 13 tests
covering endpoint ordering, tier-up notification dispatch, variant render
triggers, and non-fatal error handling.

Closes #148

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:34:50 -05:00
cal
63a25ea0ba Merge pull request 'perf: parallelize scout opportunity creation and remove sleep(2) (#101)' (#156) from issue/101-perf-parallelize-scout-opportunity-creation-and-re into main 2026-04-08 10:26:17 +00:00
cal
e5ec88f794 Merge branch 'main' into issue/101-perf-parallelize-scout-opportunity-creation-and-re
All checks were successful
Ruff Lint / lint (pull_request) Successful in 15s
2026-04-08 10:26:10 +00:00
cal
ff57b8fea3 Merge pull request 'perf: parallelize get_card_embeds calls in display_cards (#98)' (#157) from issue/98-perf-parallelize-get-card-embeds-calls-in-display into main 2026-04-08 10:26:04 +00:00
Cal Corum
2f22a11e17 perf: parallelize get_card_embeds calls in display_cards (#98)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 17s
Closes #98

Replace sequential await-in-list-comprehension with asyncio.gather() so
all card embed fetches run concurrently. Cuts 50 sequential DB round-trips
(5 packs × 5 cards × 2 calls each) down to ~2 concurrent batches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 02:32:56 -05:00
Cal Corum
8ddd58101c perf: parallelize scout opportunity creation and remove sleep(2) (#101)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 12s
Closes #101

Replace sequential for-loop with asyncio.gather() so all scout
opportunities are created concurrently. Remove asyncio.sleep(2) that
added ~8s of post-display delay for multi-pack opens. create_scout_opportunity()
already guards against empty pack_cards with an early return.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 01:02:10 -05:00
cal
24420268cf Merge pull request 'refactor: extract TIER_NAMES/TIER_COLORS to shared constants module (#146)' (#155) from issue/146-refactor-extract-tier-names-tier-colors-to-shared into main 2026-04-08 05:25:33 +00:00
Cal Corum
21bad7af51 refactor: extract TIER_NAMES/TIER_COLORS to shared constants module (#146)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 12s
Closes #146

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 00:03:46 -05:00
cal
224250b03d Merge pull request 'fix: add logging to silent error swallowing in badge lookup (#150)' (#152) from issue/150-fix-add-logging-to-silent-error-swallowing-in-badg into main 2026-04-08 03:25:41 +00:00
Cal Corum
1a3f8994a9 fix: add debug logging to silent badge lookup exception in get_card_embeds
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
Replaces bare `except Exception: pass` with `logging.debug(..., exc_info=True)`
so badge lookup failures are traceable in logs without affecting card display.

Closes #150

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:02:00 -05:00
cal
f67c1c41a7 Merge pull request 'feat: add team param to _build_refractor_response for collection view (#138)' (#140) from issue/138-feat-collection-view-refractor-card-images-in-web into main 2026-04-08 01:45:24 +00:00
cal
435bfd376f Merge branch 'main' into issue/138-feat-collection-view-refractor-card-images-in-web
All checks were successful
Ruff Lint / lint (pull_request) Successful in 27s
2026-04-08 01:45:19 +00:00
cal
c01167b097 Merge pull request 'docs: update refractor test plan with 2026-04-07 results' (#143) from docs/update-refractor-test-plan into main 2026-04-08 01:45:16 +00:00
Cal Corum
59a41e0c39 docs: update refractor integration test plan with 2026-04-07 results
All checks were successful
Ruff Lint / lint (pull_request) Successful in 25s
Fix incorrect command names (/card→/player, /roster→/team, /buy→/buy card-by-name,
/openpack→/open-packs, /scout→/scout-tokens). Update execution checklist with full
Playwright test session results — API tests, filter tests, pagination, edge cases
all passing. Note badge propagation design gap and REF-22 fix (discord#141).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 16:08:37 -05:00
cal
3210d5d6a4 Merge pull request 'fix: move health server from port 8080 to 8081 (#130)' (#131) from issue/130-move-bot-health-server-off-port-8080-to-avoid-admi into main
All checks were successful
Build Docker Image / build (push) Successful in 3m2s
Reviewed-on: #131
2026-04-07 15:54:03 +00:00
Cal Corum
8e5242a6b7 fix: move health server from port 8080 to 8081 (#130)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 18s
Adminer is exposed on host port 8080, shadowing the bot health endpoint.
Change health server default to 8081 to avoid the conflict.

Closes #130

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:53:33 +00:00
cal
8f9242bed8 Merge pull request 'fix: clamp page overflow to last page in /refractor status (#141)' (#142) from issue/141-bug-refractor-status-page-overflow-shows-no-data-i into main
Reviewed-on: #142
2026-04-07 15:52:06 +00:00
Cal Corum
cb17b99220 fix: clamp page overflow to last page in /refractor status (#141)
All checks were successful
Ruff Lint / lint (pull_request) Successful in 30s
When page exceeds total pages, API returns empty items but non-zero
count. Now detects this case and re-fetches the last valid page.

Closes #141

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 10:31:55 -05:00
Cal Corum
ddc9a28023 fix: use Optional[dict] for team param type annotation
All checks were successful
Ruff Lint / lint (pull_request) Successful in 12s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:36:04 -05:00
Cal Corum
f488cb66e0 feat: add team param to _build_refractor_response for collection view (#138)
Closes #138

The test suite passes `team` to _build_refractor_response but the
function signature did not accept it. Adds `team: dict = None` so
tests for the refractor card image collection view pass without
changing any existing behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:35:46 -05:00
cal
5cfddaa89a Merge pull request 'feat: refractor card art pipeline — render trigger + /player view' (#139) from feat/refractor-card-art-pipeline into main 2026-04-06 22:34:01 +00:00
Cal Corum
78f313663e fix: review feedback — variant 0 guard, remove dead team param
All checks were successful
Ruff Lint / lint (pull_request) Successful in 11s
- Use `variant is None` instead of `not variant` to avoid skipping
  variant 0 tier-ups (0 is falsy in Python)
- Remove unused `team` parameter from _build_refractor_response
- Update tests to match

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:33:36 -05:00
Cal Corum
46744d139c feat: add /player refractor_tier parameter for viewing evolved cards
All checks were successful
Ruff Lint / lint (pull_request) Successful in 13s
Adds optional refractor_tier parameter to the /player slash command.
When provided: looks up the user's team refractor data, shows the
evolved card image if available, triggers on-demand render if image
not yet generated, or shows top 5 refractor cards as fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:21:28 -05:00
Cal Corum
730d4b4f60 feat: trigger variant card renders after post-game tier-ups
After refractor tier-ups, the bot hits the card render URL for each
variant card to trigger Playwright render + S3 upload as a side
effect. Fire-and-forget — failures are logged but never raised.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:10:13 -05:00
cal
9ee4a76cd6 Merge pull request 'fix: remove stale Superfractor "Rating Boosts" teaser' (#137) from fix/remove-stale-superfractor-text into main 2026-04-06 20:13:36 +00:00
Cal Corum
80e99b075f chore: fix stale docstring referencing removed note field
All checks were successful
Ruff Lint / lint (pull_request) Successful in 15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 15:09:21 -05:00
Cal Corum
ef270ec1ab fix: remove stale "Rating Boosts coming soon" from Superfractor notification
All checks were successful
Ruff Lint / lint (pull_request) Successful in 52s
Tier boosts shipped in Phase 2 — the teaser field in the T4 tier-up
embed was outdated. Remove it and update tests + test plan to match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:59:56 -05:00
cal
b65d91a65b Merge pull request 'fix: remove docker-compose.yml from tracking, add example template' (#136) from fix/docker-compose-secrets-untrack into main 2026-04-01 18:02:35 +00:00
cal
4bda3bf0de Merge branch 'main' into fix/docker-compose-secrets-untrack
All checks were successful
Ruff Lint / lint (pull_request) Successful in 15s
2026-04-01 18:02:29 +00:00
cal
ff768c95f5 Merge pull request 'chore: add .env.example with placeholder values' (#135) from chore/add-env-example into main 2026-04-01 18:02:17 +00:00
Cal Corum
fb545ef34a fix: remove docker-compose.yml from tracking, add example template
All checks were successful
Ruff Lint / lint (pull_request) Successful in 21s
docker-compose.yml contains hardcoded credentials (BOT_TOKEN, API_TOKEN,
RESTART_WEBHOOK_URL) and should never be committed. The *compose.yml gitignore
rule already excluded it but docker-compose.example.yml was not provided as a
reference for contributors.

- Add !docker-compose.example.yml negation to .gitignore so example is tracked
- Add docker-compose.example.yml with placeholder values for all secrets

Closes paper-dynasty-database#9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:59:40 -05:00
Cal Corum
f704b09933 chore: add .env.example with placeholder values
All checks were successful
Ruff Lint / lint (pull_request) Successful in 29s
Document all required environment variables for running the Discord bot,
including bot token, API credentials, database config, and webhook URL.

References paper-dynasty-database#9
2026-04-01 11:59:44 -05:00
Cal Corum
94f3b1dc97 fix: apply open-packs hotfix to cogs/economy.py
Port the Check-In Player pack fix from the hotfix branch to the legacy
economy.py cog (which production currently loads instead of economy_new).

- Filter auto-open pack types from the manual open-packs menu
- Add pretty_name fallback for hyphenated pack type names
- Add ruff noqa for pre-existing star import warnings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:04:19 -05:00
cal
fca85d583f Merge pull request 'fix: prevent crash when Check-In Player packs in open-packs' (#134) from hotfix/open-packs-checkin into main 2026-03-26 13:50:09 +00:00
cal
b6592b8a70 Merge branch 'main' into hotfix/open-packs-checkin
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
2026-03-26 13:50:01 +00:00
Cal Corum
01f6fb50d5 fix: prevent crash when Check-In Player packs appear in open-packs menu
All checks were successful
Build Docker Image / build (pull_request) Successful in 3m17s
Check-In Player packs (auto-opened by daily check-in) could end up orphaned
in inventory if roll_for_cards failed. The open-packs command crashed because:
1. The hyphenated pack type name bypassed the pretty_name logic, producing an
   empty select menu that Discord rejected (400 Bad Request)
2. Even if displayed, selecting it would raise KeyError in the callback since
   "Check-In Player".split("-") doesn't match any known pack type token

Fixes:
- Filter auto-open pack types out of the manual open-packs menu
- Add fallback for hyphenated pack type names in pretty_name logic
- Replace KeyError with graceful user-facing message for unknown pack types
- Change "contact an admin" to "contact Cal" in all user-facing messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:45:51 -05:00
cal
f843b45099 Merge pull request 'fix: clean up refractor status — suffix tags, compact layout' (#133) from fix/refractor-status-cleanup into main
All checks were successful
Build Docker Image / build (push) Successful in 2m47s
2026-03-26 06:00:32 +00:00
Cal Corum
bbad1daba2 fix: clean up refractor status display — suffix tags, compact layout, dead code removal
All checks were successful
Ruff Lint / lint (pull_request) Successful in 20s
- Tier labels as suffix tags: **Name** — Base Chrome [T1] (T0 gets no suffix)
- Compact progress line: bar value/threshold (pct) — removed formula and tier arrow
- Fully evolved shows `MAX` instead of FULLY EVOLVED
- Deleted unused FORMULA_LABELS dict
- Added _FULL_BAR constant, moved T0-branch lookups into else
- Fixed mock API shape in test (cards → items)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:22:35 -05:00
cal
2d7c19814e Merge pull request 'fix: replace abstract tier symbols with readable labels' (#132) from fix/refractor-tier-labels into main
All checks were successful
Build Docker Image / build (push) Successful in 2m46s
2026-03-26 04:49:19 +00:00
Cal Corum
c3ff85fd2d fix: replace abstract tier symbols with readable labels in /refractor status
All checks were successful
Ruff Lint / lint (pull_request) Successful in 11s
Unicode symbols (○ ◈ ◆ ✦ ★) were too similar to distinguish at a glance.
Now uses T1/T2/T3/T4★ prefixes with no prefix for base cards (T0).
Summary header reads "Base: 1  T1: 9 — 64 total" instead of cryptic symbols.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:47:03 -05:00
cal
64c656ce91 Merge pull request 'feat: redesign /refractor status with rich Unicode display' (#129) from feat/refractor-status-redesign into main
All checks were successful
Build Docker Image / build (push) Successful in 2m42s
2026-03-26 03:50:41 +00:00
Cal Corum
cd822857bf feat: redesign /refractor status with rich Unicode display and team branding
All checks were successful
Ruff Lint / lint (pull_request) Successful in 21s
Replace plain ASCII progress bars and text badges with a polished embed:
- Unicode block progress bars (▰▱) replacing ASCII [===---]
- Tier-specific symbols (○ ◈ ◆ ✦ ★) instead of [BC]/[R]/[GR]/[SF] badges
- Team-branded embeds via get_team_embed (color, logo, season footer)
- Tier distribution summary header in code block
- Percentage display and backtick-wrapped values
- Tier-specific accent colors for single-tier filtered views
- Sparkle treatment for fully evolved cards (✧ FULLY EVOLVED ✧)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:46:38 -05:00
cal
34774290b8 Merge pull request 'fix: context-aware empty state messages for /refractor status' (#128) from fix/refractor-empty-filter-message into main
All checks were successful
Build Docker Image / build (push) Successful in 3m29s
2026-03-25 23:57:53 +00:00
Cal Corum
6239f1177c fix: context-aware empty state messages for /refractor status
All checks were successful
Ruff Lint / lint (pull_request) Successful in 23s
When filters are active and return 0 results, show which filters were
applied and suggest removing them, instead of the misleading
"No refractor data found for your team."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:57:32 -05:00
cal
dea6316201 Merge pull request 'feat: add Prev/Next navigation buttons to /refractor status' (#127) from feat/refractor-pagination-buttons into main
All checks were successful
Build Docker Image / build (push) Successful in 3m8s
2026-03-25 22:43:29 +00:00
Cal Corum
b9deb14b62 feat: add Prev/Next navigation buttons to /refractor status
All checks were successful
Ruff Lint / lint (pull_request) Successful in 24s
- RefractorPaginationView with ◀ Prev / Next ▶ buttons
- Buttons re-fetch from API on each page change
- Prev disabled on page 1, Next disabled on last page
- Only the command invoker can use the buttons
- Buttons auto-disable after 2 min timeout
- Single-page results show no buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:43:06 -05:00
cal
48392a9bbe Merge pull request 'feat: use Discord Choice menus for /refractor status parameters' (#126) from enhancement/refractor-choice-params into main
All checks were successful
Build Docker Image / build (push) Successful in 2m40s
2026-03-25 22:16:53 +00:00
Cal Corum
a53cc5cac3 feat: use Discord Choice menus for /refractor status parameters
All checks were successful
Ruff Lint / lint (pull_request) Successful in 22s
Replace freeform text inputs with dropdown selections:
- card_type: Batter, Starting Pitcher, Relief Pitcher
- tier: T0-T4 with names (Base Card through Superfractor)
- progress: "Close to next tier" option
- Removed season param (not useful for current UX)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 17:16:26 -05:00
cal
a8b4d6cdbb Merge pull request 'fix: round refractor values to integers in display' (#125) from fix/refractor-round-values into main
All checks were successful
Build Docker Image / build (push) Successful in 2m39s
2026-03-25 21:55:24 +00:00
Cal Corum
8d2cdc81fe fix: round refractor values to integers in display
All checks were successful
Ruff Lint / lint (pull_request) Successful in 19s
Cast current_value and next_threshold to int to avoid ugly floating
point numbers like 53.0/149.0 in the progress display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:55:03 -05:00
cal
27ce8b3617 Merge pull request 'fix: add debug logging for successful refractor API responses' (#124) from fix/refractor-debug-logging into main
All checks were successful
Build Docker Image / build (push) Successful in 2m47s
2026-03-25 21:47:41 +00:00
Cal Corum
17d124feb4 fix: add debug logging for successful refractor API responses
All checks were successful
Ruff Lint / lint (pull_request) Successful in 9s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:46:01 -05:00
23 changed files with 2472 additions and 951 deletions

54
.env.example Normal file
View File

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

1
.gitignore vendored
View File

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

View File

@ -31,7 +31,7 @@ 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:8080/health` (HTTP server in `health_server.py`) - **Health**: `GET http://localhost:8081/health` (HTTP server in `health_server.py`)
- **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release - **Versioning**: CalVer (`YYYY.M.BUILD`) — manually tagged when ready to release
### Logs ### Logs
@ -46,7 +46,7 @@ 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 8080 inside the container - Health endpoint not responding → `health_server.py` runs on port 8081 inside the container
### CI/CD ### CI/CD
Ruff lint on PRs. Docker image built on CalVer tag push only. Ruff lint on PRs. Docker image built on CalVer tag push only.

View File

@ -1,3 +1,4 @@
# ruff: noqa: F403, F405
import copy import copy
import helpers import helpers
@ -695,6 +696,9 @@ class Economy(commands.Cog):
) )
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 = {
@ -711,6 +715,11 @@ class Economy(commands.Cog):
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:
logger.debug(
f"Skipping auto-open pack type: {pack['pack_type']['name']}"
)
continue
if pack["pack_team"] is None and pack["pack_cardset"] is None: if pack["pack_team"] is None and pack["pack_cardset"] is None:
p_group = pack["pack_type"]["name"] 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
@ -773,6 +782,9 @@ class Economy(commands.Cog):
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}" pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
elif "Cardset" in key: elif "Cardset" in key:
pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}" pretty_name = f"{key.split('-')[0]} - {key.split('-')[3]}"
else:
# Pack type name contains a hyphen (e.g. "Check-In Player")
pretty_name = key
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])}")

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

View File

@ -28,7 +28,7 @@ import helpers
from in_game.gameplay_queries import get_team_or_none from in_game.gameplay_queries import get_team_or_none
from in_game.simulations import get_pos_embeds, get_result from in_game.simulations import get_pos_embeds, get_result
from in_game.gameplay_models import Lineup, Play, Session, engine from in_game.gameplay_models import Lineup, Play, Session, engine
from api_calls import db_get, db_post, db_patch, get_team_by_abbrev from api_calls import db_get, db_post, db_patch, get_team_by_abbrev, DB_URL
from helpers import ( from helpers import (
ACTIVE_EVENT_LITERAL, ACTIVE_EVENT_LITERAL,
PD_PLAYERS_ROLE_NAME, PD_PLAYERS_ROLE_NAME,
@ -60,6 +60,7 @@ from helpers import (
) )
from utilities.buttons import ask_with_buttons from utilities.buttons import ask_with_buttons
from utilities.autocomplete import cardset_autocomplete, player_autocomplete from utilities.autocomplete import cardset_autocomplete, player_autocomplete
from helpers.refractor_constants import TIER_NAMES as REFRACTOR_TIER_NAMES
logger = logging.getLogger("discord_app") logger = logging.getLogger("discord_app")
@ -293,29 +294,29 @@ def get_ai_records(short_games, long_games):
if line["away_team"]["is_ai"]: if line["away_team"]["is_ai"]:
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"w" "w"
] += (1 if home_win else 0) ] += 1 if home_win else 0
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"l" "l"
] += (1 if not home_win else 0) ] += 1 if not home_win else 0
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"points" "points"
] += (2 if home_win else 1) ] += 2 if home_win else 1
all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["away_team"]["abbrev"]][league[line["game_type"]]][
"rd" "rd"
] += (line["home_score"] - line["away_score"]) ] += line["home_score"] - line["away_score"]
elif line["home_team"]["is_ai"]: elif line["home_team"]["is_ai"]:
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"w" "w"
] += (1 if not home_win else 0) ] += 1 if not home_win else 0
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"l" "l"
] += (1 if home_win else 0) ] += 1 if home_win else 0
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"points" "points"
] += (2 if not home_win else 1) ] += 2 if not home_win else 1
all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][ all_results[line["home_team"]["abbrev"]][league[line["game_type"]]][
"rd" "rd"
] += (line["away_score"] - line["home_score"]) ] += line["away_score"] - line["home_score"]
logger.debug(f"done league games") logger.debug(f"done league games")
return all_results return all_results
@ -367,51 +368,51 @@ def get_record_embed_legacy(embed: discord.Embed, results: dict, league: str):
embed.add_field( embed.add_field(
name=f"AL East ({ale_points} pts)", name=f"AL East ({ale_points} pts)",
value=f'BAL: {results["BAL"][league]["w"]} - {results["BAL"][league]["l"]} ({results["BAL"][league]["rd"]} RD)\n' value=f"BAL: {results['BAL'][league]['w']} - {results['BAL'][league]['l']} ({results['BAL'][league]['rd']} RD)\n"
f'BOS: {results["BOS"][league]["w"]} - {results["BOS"][league]["l"]} ({results["BOS"][league]["rd"]} RD)\n' f"BOS: {results['BOS'][league]['w']} - {results['BOS'][league]['l']} ({results['BOS'][league]['rd']} RD)\n"
f'NYY: {results["NYY"][league]["w"]} - {results["NYY"][league]["l"]} ({results["NYY"][league]["rd"]} RD)\n' f"NYY: {results['NYY'][league]['w']} - {results['NYY'][league]['l']} ({results['NYY'][league]['rd']} RD)\n"
f'TBR: {results["TBR"][league]["w"]} - {results["TBR"][league]["l"]} ({results["TBR"][league]["rd"]} RD)\n' f"TBR: {results['TBR'][league]['w']} - {results['TBR'][league]['l']} ({results['TBR'][league]['rd']} RD)\n"
f'TOR: {results["TOR"][league]["w"]} - {results["TOR"][league]["l"]} ({results["TOR"][league]["rd"]} RD)\n', f"TOR: {results['TOR'][league]['w']} - {results['TOR'][league]['l']} ({results['TOR'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL Central ({alc_points} pts)", name=f"AL Central ({alc_points} pts)",
value=f'CLE: {results["CLE"][league]["w"]} - {results["CLE"][league]["l"]} ({results["CLE"][league]["rd"]} RD)\n' value=f"CLE: {results['CLE'][league]['w']} - {results['CLE'][league]['l']} ({results['CLE'][league]['rd']} RD)\n"
f'CHW: {results["CHW"][league]["w"]} - {results["CHW"][league]["l"]} ({results["CHW"][league]["rd"]} RD)\n' f"CHW: {results['CHW'][league]['w']} - {results['CHW'][league]['l']} ({results['CHW'][league]['rd']} RD)\n"
f'DET: {results["DET"][league]["w"]} - {results["DET"][league]["l"]} ({results["DET"][league]["rd"]} RD)\n' f"DET: {results['DET'][league]['w']} - {results['DET'][league]['l']} ({results['DET'][league]['rd']} RD)\n"
f'KCR: {results["KCR"][league]["w"]} - {results["KCR"][league]["l"]} ({results["KCR"][league]["rd"]} RD)\n' f"KCR: {results['KCR'][league]['w']} - {results['KCR'][league]['l']} ({results['KCR'][league]['rd']} RD)\n"
f'MIN: {results["MIN"][league]["w"]} - {results["MIN"][league]["l"]} ({results["MIN"][league]["rd"]} RD)\n', f"MIN: {results['MIN'][league]['w']} - {results['MIN'][league]['l']} ({results['MIN'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL West ({alw_points} pts)", name=f"AL West ({alw_points} pts)",
value=f'HOU: {results["HOU"][league]["w"]} - {results["HOU"][league]["l"]} ({results["HOU"][league]["rd"]} RD)\n' value=f"HOU: {results['HOU'][league]['w']} - {results['HOU'][league]['l']} ({results['HOU'][league]['rd']} RD)\n"
f'LAA: {results["LAA"][league]["w"]} - {results["LAA"][league]["l"]} ({results["LAA"][league]["rd"]} RD)\n' f"LAA: {results['LAA'][league]['w']} - {results['LAA'][league]['l']} ({results['LAA'][league]['rd']} RD)\n"
f'OAK: {results["OAK"][league]["w"]} - {results["OAK"][league]["l"]} ({results["OAK"][league]["rd"]} RD)\n' f"OAK: {results['OAK'][league]['w']} - {results['OAK'][league]['l']} ({results['OAK'][league]['rd']} RD)\n"
f'SEA: {results["SEA"][league]["w"]} - {results["SEA"][league]["l"]} ({results["SEA"][league]["rd"]} RD)\n' f"SEA: {results['SEA'][league]['w']} - {results['SEA'][league]['l']} ({results['SEA'][league]['rd']} RD)\n"
f'TEX: {results["TEX"][league]["w"]} - {results["TEX"][league]["l"]} ({results["TEX"][league]["rd"]} RD)\n', f"TEX: {results['TEX'][league]['w']} - {results['TEX'][league]['l']} ({results['TEX'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL East ({nle_points} pts)", name=f"NL East ({nle_points} pts)",
value=f'ATL: {results["ATL"][league]["w"]} - {results["ATL"][league]["l"]} ({results["ATL"][league]["rd"]} RD)\n' value=f"ATL: {results['ATL'][league]['w']} - {results['ATL'][league]['l']} ({results['ATL'][league]['rd']} RD)\n"
f'MIA: {results["MIA"][league]["w"]} - {results["MIA"][league]["l"]} ({results["MIA"][league]["rd"]} RD)\n' f"MIA: {results['MIA'][league]['w']} - {results['MIA'][league]['l']} ({results['MIA'][league]['rd']} RD)\n"
f'NYM: {results["NYM"][league]["w"]} - {results["NYM"][league]["l"]} ({results["NYM"][league]["rd"]} RD)\n' f"NYM: {results['NYM'][league]['w']} - {results['NYM'][league]['l']} ({results['NYM'][league]['rd']} RD)\n"
f'PHI: {results["PHI"][league]["w"]} - {results["PHI"][league]["l"]} ({results["PHI"][league]["rd"]} RD)\n' f"PHI: {results['PHI'][league]['w']} - {results['PHI'][league]['l']} ({results['PHI'][league]['rd']} RD)\n"
f'WSN: {results["WSN"][league]["w"]} - {results["WSN"][league]["l"]} ({results["WSN"][league]["rd"]} RD)\n', f"WSN: {results['WSN'][league]['w']} - {results['WSN'][league]['l']} ({results['WSN'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL Central ({nlc_points} pts)", name=f"NL Central ({nlc_points} pts)",
value=f'CHC: {results["CHC"][league]["w"]} - {results["CHC"][league]["l"]} ({results["CHC"][league]["rd"]} RD)\n' value=f"CHC: {results['CHC'][league]['w']} - {results['CHC'][league]['l']} ({results['CHC'][league]['rd']} RD)\n"
f'CHW: {results["CIN"][league]["w"]} - {results["CIN"][league]["l"]} ({results["CIN"][league]["rd"]} RD)\n' f"CHW: {results['CIN'][league]['w']} - {results['CIN'][league]['l']} ({results['CIN'][league]['rd']} RD)\n"
f'MIL: {results["MIL"][league]["w"]} - {results["MIL"][league]["l"]} ({results["MIL"][league]["rd"]} RD)\n' f"MIL: {results['MIL'][league]['w']} - {results['MIL'][league]['l']} ({results['MIL'][league]['rd']} RD)\n"
f'PIT: {results["PIT"][league]["w"]} - {results["PIT"][league]["l"]} ({results["PIT"][league]["rd"]} RD)\n' f"PIT: {results['PIT'][league]['w']} - {results['PIT'][league]['l']} ({results['PIT'][league]['rd']} RD)\n"
f'STL: {results["STL"][league]["w"]} - {results["STL"][league]["l"]} ({results["STL"][league]["rd"]} RD)\n', f"STL: {results['STL'][league]['w']} - {results['STL'][league]['l']} ({results['STL'][league]['rd']} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL West ({nlw_points} pts)", name=f"NL West ({nlw_points} pts)",
value=f'ARI: {results["ARI"][league]["w"]} - {results["ARI"][league]["l"]} ({results["ARI"][league]["rd"]} RD)\n' value=f"ARI: {results['ARI'][league]['w']} - {results['ARI'][league]['l']} ({results['ARI'][league]['rd']} RD)\n"
f'COL: {results["COL"][league]["w"]} - {results["COL"][league]["l"]} ({results["COL"][league]["rd"]} RD)\n' f"COL: {results['COL'][league]['w']} - {results['COL'][league]['l']} ({results['COL'][league]['rd']} RD)\n"
f'LAD: {results["LAD"][league]["w"]} - {results["LAD"][league]["l"]} ({results["LAD"][league]["rd"]} RD)\n' f"LAD: {results['LAD'][league]['w']} - {results['LAD'][league]['l']} ({results['LAD'][league]['rd']} RD)\n"
f'SDP: {results["SDP"][league]["w"]} - {results["SDP"][league]["l"]} ({results["SDP"][league]["rd"]} RD)\n' f"SDP: {results['SDP'][league]['w']} - {results['SDP'][league]['l']} ({results['SDP'][league]['rd']} RD)\n"
f'SFG: {results["SFG"][league]["w"]} - {results["SFG"][league]["l"]} ({results["SFG"][league]["rd"]} RD)\n', f"SFG: {results['SFG'][league]['w']} - {results['SFG'][league]['l']} ({results['SFG'][league]['rd']} RD)\n",
) )
return embed return embed
@ -421,56 +422,126 @@ def get_record_embed(team: dict, results: dict, league: str):
embed = get_team_embed(league, team) embed = get_team_embed(league, team)
embed.add_field( embed.add_field(
name=f"AL East", name=f"AL East",
value=f'BAL: {results["BAL"][0]} - {results["BAL"][1]} ({results["BAL"][2]} RD)\n' value=f"BAL: {results['BAL'][0]} - {results['BAL'][1]} ({results['BAL'][2]} RD)\n"
f'BOS: {results["BOS"][0]} - {results["BOS"][1]} ({results["BOS"][2]} RD)\n' f"BOS: {results['BOS'][0]} - {results['BOS'][1]} ({results['BOS'][2]} RD)\n"
f'NYY: {results["NYY"][0]} - {results["NYY"][1]} ({results["NYY"][2]} RD)\n' f"NYY: {results['NYY'][0]} - {results['NYY'][1]} ({results['NYY'][2]} RD)\n"
f'TBR: {results["TBR"][0]} - {results["TBR"][1]} ({results["TBR"][2]} RD)\n' f"TBR: {results['TBR'][0]} - {results['TBR'][1]} ({results['TBR'][2]} RD)\n"
f'TOR: {results["TOR"][0]} - {results["TOR"][1]} ({results["TOR"][2]} RD)\n', f"TOR: {results['TOR'][0]} - {results['TOR'][1]} ({results['TOR'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL Central", name=f"AL Central",
value=f'CLE: {results["CLE"][0]} - {results["CLE"][1]} ({results["CLE"][2]} RD)\n' value=f"CLE: {results['CLE'][0]} - {results['CLE'][1]} ({results['CLE'][2]} RD)\n"
f'CHW: {results["CHW"][0]} - {results["CHW"][1]} ({results["CHW"][2]} RD)\n' f"CHW: {results['CHW'][0]} - {results['CHW'][1]} ({results['CHW'][2]} RD)\n"
f'DET: {results["DET"][0]} - {results["DET"][1]} ({results["DET"][2]} RD)\n' f"DET: {results['DET'][0]} - {results['DET'][1]} ({results['DET'][2]} RD)\n"
f'KCR: {results["KCR"][0]} - {results["KCR"][1]} ({results["KCR"][2]} RD)\n' f"KCR: {results['KCR'][0]} - {results['KCR'][1]} ({results['KCR'][2]} RD)\n"
f'MIN: {results["MIN"][0]} - {results["MIN"][1]} ({results["MIN"][2]} RD)\n', f"MIN: {results['MIN'][0]} - {results['MIN'][1]} ({results['MIN'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"AL West", name=f"AL West",
value=f'HOU: {results["HOU"][0]} - {results["HOU"][1]} ({results["HOU"][2]} RD)\n' value=f"HOU: {results['HOU'][0]} - {results['HOU'][1]} ({results['HOU'][2]} RD)\n"
f'LAA: {results["LAA"][0]} - {results["LAA"][1]} ({results["LAA"][2]} RD)\n' f"LAA: {results['LAA'][0]} - {results['LAA'][1]} ({results['LAA'][2]} RD)\n"
f'OAK: {results["OAK"][0]} - {results["OAK"][1]} ({results["OAK"][2]} RD)\n' f"OAK: {results['OAK'][0]} - {results['OAK'][1]} ({results['OAK'][2]} RD)\n"
f'SEA: {results["SEA"][0]} - {results["SEA"][1]} ({results["SEA"][2]} RD)\n' f"SEA: {results['SEA'][0]} - {results['SEA'][1]} ({results['SEA'][2]} RD)\n"
f'TEX: {results["TEX"][0]} - {results["TEX"][1]} ({results["TEX"][2]} RD)\n', f"TEX: {results['TEX'][0]} - {results['TEX'][1]} ({results['TEX'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL East", name=f"NL East",
value=f'ATL: {results["ATL"][0]} - {results["ATL"][1]} ({results["ATL"][2]} RD)\n' value=f"ATL: {results['ATL'][0]} - {results['ATL'][1]} ({results['ATL'][2]} RD)\n"
f'MIA: {results["MIA"][0]} - {results["MIA"][1]} ({results["MIA"][2]} RD)\n' f"MIA: {results['MIA'][0]} - {results['MIA'][1]} ({results['MIA'][2]} RD)\n"
f'NYM: {results["NYM"][0]} - {results["NYM"][1]} ({results["NYM"][2]} RD)\n' f"NYM: {results['NYM'][0]} - {results['NYM'][1]} ({results['NYM'][2]} RD)\n"
f'PHI: {results["PHI"][0]} - {results["PHI"][1]} ({results["PHI"][2]} RD)\n' f"PHI: {results['PHI'][0]} - {results['PHI'][1]} ({results['PHI'][2]} RD)\n"
f'WSN: {results["WSN"][0]} - {results["WSN"][1]} ({results["WSN"][2]} RD)\n', f"WSN: {results['WSN'][0]} - {results['WSN'][1]} ({results['WSN'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL Central", name=f"NL Central",
value=f'CHC: {results["CHC"][0]} - {results["CHC"][1]} ({results["CHC"][2]} RD)\n' value=f"CHC: {results['CHC'][0]} - {results['CHC'][1]} ({results['CHC'][2]} RD)\n"
f'CIN: {results["CIN"][0]} - {results["CIN"][1]} ({results["CIN"][2]} RD)\n' f"CIN: {results['CIN'][0]} - {results['CIN'][1]} ({results['CIN'][2]} RD)\n"
f'MIL: {results["MIL"][0]} - {results["MIL"][1]} ({results["MIL"][2]} RD)\n' f"MIL: {results['MIL'][0]} - {results['MIL'][1]} ({results['MIL'][2]} RD)\n"
f'PIT: {results["PIT"][0]} - {results["PIT"][1]} ({results["PIT"][2]} RD)\n' f"PIT: {results['PIT'][0]} - {results['PIT'][1]} ({results['PIT'][2]} RD)\n"
f'STL: {results["STL"][0]} - {results["STL"][1]} ({results["STL"][2]} RD)\n', f"STL: {results['STL'][0]} - {results['STL'][1]} ({results['STL'][2]} RD)\n",
) )
embed.add_field( embed.add_field(
name=f"NL West", name=f"NL West",
value=f'ARI: {results["ARI"][0]} - {results["ARI"][1]} ({results["ARI"][2]} RD)\n' value=f"ARI: {results['ARI'][0]} - {results['ARI'][1]} ({results['ARI'][2]} RD)\n"
f'COL: {results["COL"][0]} - {results["COL"][1]} ({results["COL"][2]} RD)\n' f"COL: {results['COL'][0]} - {results['COL'][1]} ({results['COL'][2]} RD)\n"
f'LAD: {results["LAD"][0]} - {results["LAD"][1]} ({results["LAD"][2]} RD)\n' f"LAD: {results['LAD'][0]} - {results['LAD'][1]} ({results['LAD'][2]} RD)\n"
f'SDP: {results["SDP"][0]} - {results["SDP"][1]} ({results["SDP"][2]} RD)\n' f"SDP: {results['SDP'][0]} - {results['SDP'][1]} ({results['SDP'][2]} RD)\n"
f'SFG: {results["SFG"][0]} - {results["SFG"][1]} ({results["SFG"][2]} RD)\n', f"SFG: {results['SFG'][0]} - {results['SFG'][1]} ({results['SFG'][2]} RD)\n",
) )
return embed return embed
async def _build_refractor_response(
player_name: str,
player_id: int,
refractor_tier: int,
refractor_data: dict,
team: Optional[dict] = None,
) -> dict:
"""Build response data for a /player refractor_tier request.
Returns a dict with:
- found: bool
- image_url: str or None
- needs_render: bool
- variant: int
- card_type: str
- player_name: str
- tier_name: str
- current_tier: int (when found)
- top_cards: list (when not found)
"""
items = refractor_data.get("items", [])
match = None
for item in items:
if item["player_id"] == player_id and item["current_tier"] >= refractor_tier:
match = item
break
if match:
# Map track card_type to the URL card_type format
track_type = match.get("track", {}).get("card_type", "batter")
card_type = "pitching" if track_type in ("sp", "rp") else "batting"
return {
"found": True,
"image_url": match.get("image_url"),
"needs_render": match.get("image_url") is None,
"variant": match.get("variant", 0),
"card_type": card_type,
"player_name": player_name,
"tier_name": REFRACTOR_TIER_NAMES.get(refractor_tier, f"T{refractor_tier}"),
"current_tier": match["current_tier"],
"top_cards": [],
}
sorted_cards = sorted(
items, key=lambda x: (-x["current_tier"], -x.get("current_value", 0))
)
top_cards = []
for card in sorted_cards[:5]:
tier = card["current_tier"]
top_cards.append(
{
"player_name": card.get("player_name", "Unknown"),
"tier": tier,
"tier_name": REFRACTOR_TIER_NAMES.get(tier, f"T{tier}"),
"image_url": card.get("image_url"),
}
)
return {
"found": False,
"image_url": None,
"needs_render": False,
"variant": 0,
"player_name": player_name,
"tier_name": REFRACTOR_TIER_NAMES.get(refractor_tier, f"T{refractor_tier}"),
"top_cards": top_cards,
}
class Players(commands.Cog): class Players(commands.Cog):
def __init__(self, bot): def __init__(self, bot):
self.bot = bot self.bot = bot
@ -650,12 +721,83 @@ class Players(commands.Cog):
name="player", description="Display one or more of the player's cards" name="player", description="Display one or more of the player's cards"
) )
@app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME) @app_commands.checks.has_any_role(PD_PLAYERS_ROLE_NAME)
@app_commands.describe(
refractor_tier="View a refractor tier of this card (1-4)",
)
@app_commands.autocomplete( @app_commands.autocomplete(
player_name=player_autocomplete, cardset=cardset_autocomplete player_name=player_autocomplete, cardset=cardset_autocomplete
) )
async def player_slash_command( async def player_slash_command(
self, interaction: discord.Interaction, player_name: str, cardset: str = "All" self,
interaction: discord.Interaction,
player_name: str,
cardset: str = "All",
refractor_tier: Optional[int] = None,
): ):
if refractor_tier is not None:
await interaction.response.defer()
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.edit_original_response(
content="You don't have a team yet. Join a team first!"
)
return
this_player = fuzzy_search(player_name, self.player_list)
player_data = await db_get("players", params=[("name", this_player)])
if not player_data or not player_data.get("players"):
await interaction.edit_original_response(
content=f"Player '{player_name}' not found."
)
return
pid = player_data["players"][0]["id"]
refractor_data = await db_get(
"refractor/cards", params=[("team_id", team["id"]), ("limit", 100)]
)
if not refractor_data:
refractor_data = {"count": 0, "items": []}
result = await _build_refractor_response(
player_name=this_player,
player_id=pid,
refractor_tier=refractor_tier,
refractor_data=refractor_data,
)
if result["found"]:
embed = discord.Embed(
title=f"{result['player_name']}{result['tier_name']}",
color=discord.Color.gold(),
)
if result["needs_render"]:
today = datetime.date.today().isoformat()
card_type = result.get("card_type", "batting")
render_url = f"{DB_URL}/v2/players/{pid}/{card_type}card/{today}/{result['variant']}"
embed.set_image(url=render_url)
embed.set_footer(text="First render — image generating...")
else:
embed.set_image(url=result["image_url"])
await interaction.edit_original_response(embed=embed)
else:
msg = f"You don't have a T{refractor_tier} refractor of **{this_player}**."
if result["top_cards"]:
msg += "\n\nYour top refractor cards:"
for card in result["top_cards"]:
tier_label = f"T{card['tier']} {card['tier_name']}"
if card["image_url"]:
msg += f"\n> [{card['player_name']}{tier_label}]({card['image_url']})"
else:
msg += f"\n> {card['player_name']}{tier_label}"
else:
msg += "\n\nYou don't have any refractor cards yet. Play games to earn them!"
await interaction.edit_original_response(content=msg)
return
ephemeral = False ephemeral = False
if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]: if interaction.channel.name in ["paper-dynasty-chat", "pd-news-ticker"]:
ephemeral = True ephemeral = True
@ -694,7 +836,7 @@ class Players(commands.Cog):
if len(all_embeds) > 1: if len(all_embeds) > 1:
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'# {all_players["players"][0]["p_name"]}' content=f"# {all_players['players'][0]['p_name']}"
) )
await embed_pagination( await embed_pagination(
all_embeds, all_embeds,
@ -788,11 +930,11 @@ class Players(commands.Cog):
current = await db_get("current") current = await db_get("current")
await interaction.response.send_message( await interaction.response.send_message(
f'I\'m tallying the {team["lname"]} results now...', ephemeral=ephemeral f"I'm tallying the {team['lname']} results now...", ephemeral=ephemeral
) )
st_query = await db_get( st_query = await db_get(
f'teams/{team["id"]}/season-record', object_id=current["season"] f"teams/{team['id']}/season-record", object_id=current["season"]
) )
minor_embed = get_record_embed(team, st_query["minor-league"], "Minor League") minor_embed = get_record_embed(team, st_query["minor-league"], "Minor League")
@ -812,7 +954,7 @@ class Players(commands.Cog):
start_page = 3 start_page = 3
await interaction.edit_original_response( await interaction.edit_original_response(
content=f'Here are the {team["lname"]} campaign records' content=f"Here are the {team['lname']} campaign records"
) )
await embed_pagination( await embed_pagination(
[minor_embed, major_embed, flashback_embed, hof_embed], [minor_embed, major_embed, flashback_embed, hof_embed],
@ -856,18 +998,18 @@ class Players(commands.Cog):
c_query = await db_get("cards", object_id=card_id) c_query = await db_get("cards", object_id=card_id)
if c_query: if c_query:
c_string = ( c_string = (
f'Card ID {card_id} is a {helpers.player_desc(c_query["player"])}' f"Card ID {card_id} is a {helpers.player_desc(c_query['player'])}"
) )
if c_query["team"] is not None: if c_query["team"] is not None:
c_string += f' owned by the {c_query["team"]["sname"]}' c_string += f" owned by the {c_query['team']['sname']}"
if c_query["pack"] is not None: if c_query["pack"] is not None:
c_string += ( c_string += (
f' pulled from a {c_query["pack"]["pack_type"]["name"]} pack.' f" pulled from a {c_query['pack']['pack_type']['name']} pack."
) )
else: else:
c_query["team"] = c_query["pack"]["team"] c_query["team"] = c_query["pack"]["team"]
c_string += ( c_string += (
f' used by the {c_query["pack"]["team"]["sname"]} in a gauntlet' f" used by the {c_query['pack']['team']['sname']} in a gauntlet"
) )
await interaction.edit_original_response( await interaction.edit_original_response(
@ -947,7 +1089,7 @@ class Players(commands.Cog):
await ctx.send(f"Who?") await ctx.send(f"Who?")
return return
await ctx.send(f'{t_query["teams"][0]["sname"]} are a bunch of cuties!') await ctx.send(f"{t_query['teams'][0]['sname']} are a bunch of cuties!")
@commands.hybrid_command(name="random", help="Check out a random card") @commands.hybrid_command(name="random", help="Check out a random card")
@commands.has_any_role(PD_PLAYERS_ROLE_NAME) @commands.has_any_role(PD_PLAYERS_ROLE_NAME)
@ -1074,7 +1216,7 @@ class Players(commands.Cog):
r_query = await db_get("results", params=params) r_query = await db_get("results", params=params)
if not r_query["count"]: if not r_query["count"]:
await ctx.send( await ctx.send(
f'There are no Ranked games on record this {"week" if which == "week" else "season"}.' f"There are no Ranked games on record this {'week' if which == 'week' else 'season'}."
) )
return return
@ -1116,8 +1258,8 @@ class Players(commands.Cog):
# await ctx.send(f'sorted: {sorted_records}') # await ctx.send(f'sorted: {sorted_records}')
embed = get_team_embed( embed = get_team_embed(
title=f'{"Season" if which == "season" else "Week"} ' title=f"{'Season' if which == 'season' else 'Week'} "
f'{current["season"] if which == "season" else current["week"]} Standings' f"{current['season'] if which == 'season' else current['week']} Standings"
) )
chunk_string = "" chunk_string = ""
@ -1126,8 +1268,8 @@ class Players(commands.Cog):
team = await db_get("teams", object_id=record[0]) team = await db_get("teams", object_id=record[0])
if team: if team:
chunk_string += ( chunk_string += (
f'{record[1]["points"]} pt{"s" if record[1]["points"] != 1 else ""} ' f"{record[1]['points']} pt{'s' if record[1]['points'] != 1 else ''} "
f'({record[1]["wins"]}-{record[1]["losses"]}) - {team["sname"]} [{team["ranking"]}]\n' f"({record[1]['wins']}-{record[1]['losses']}) - {team['sname']} [{team['ranking']}]\n"
) )
else: else:
@ -1237,7 +1379,7 @@ class Players(commands.Cog):
this_run = r_query["runs"][0] this_run = r_query["runs"][0]
else: else:
await interaction.channel.send( await interaction.channel.send(
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']}."
) )
else: else:
await interaction.channel.send( await interaction.channel.send(
@ -1311,7 +1453,7 @@ class Players(commands.Cog):
if r_query["count"] != 0: if r_query["count"] != 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
@ -1322,7 +1464,7 @@ class Players(commands.Cog):
) )
except ZeroDivisionError as e: except ZeroDivisionError as e:
logger.error( logger.error(
f'ZeroDivisionError in {this_event["name"]} draft for the {main_team.sname}: {e}' f"ZeroDivisionError in {this_event['name']} draft for the {main_team.sname}: {e}"
) )
await gauntlets.wipe_team(draft_team, interaction) await gauntlets.wipe_team(draft_team, interaction)
await interaction.channel.send( await interaction.channel.send(
@ -1333,7 +1475,7 @@ class Players(commands.Cog):
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}: {e}' f"Failed to run {this_event['name']} draft for the {main_team.sname}: {e}"
) )
await gauntlets.wipe_team(draft_team, interaction) await gauntlets.wipe_team(draft_team, interaction)
await interaction.channel.send( await interaction.channel.send(
@ -1348,7 +1490,7 @@ class Players(commands.Cog):
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.channel.send( await interaction.channel.send(
@ -1360,7 +1502,7 @@ class Players(commands.Cog):
await helpers.send_to_channel( await helpers.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} have entered the {this_event["name"]} Gauntlet!', content=f"The {main_team.lname} have entered the {this_event['name']} Gauntlet!",
embed=draft_embed, embed=draft_embed,
) )
@ -1373,7 +1515,7 @@ class Players(commands.Cog):
): # type: ignore ): # type: ignore
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?"
@ -1404,7 +1546,7 @@ class Players(commands.Cog):
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']}."
) )
return return

View File

@ -15,89 +15,148 @@ from typing import Optional
import discord import discord
from discord import app_commands from discord import app_commands
from discord.app_commands import Choice
from discord.ext import commands from discord.ext import commands
from api_calls import db_get from api_calls import db_get
from helpers.discord_utils import get_team_embed
from helpers.main import get_team_by_owner from helpers.main import get_team_by_owner
from helpers.refractor_constants import TIER_NAMES, STATUS_TIER_COLORS as TIER_COLORS
logger = logging.getLogger("discord_app") logger = logging.getLogger("discord_app")
PAGE_SIZE = 10 PAGE_SIZE = 10
TIER_NAMES = { # Tier-specific labels for the status display.
0: "Base Card", TIER_SYMBOLS = {
1: "Base Chrome", 0: "Base", # Base Card — used in summary only, not in per-card display
2: "Refractor", 1: "T1", # Base Chrome
3: "Gold Refractor", 2: "T2", # Refractor
4: "Superfractor", 3: "T3", # Gold Refractor
4: "T4★", # Superfractor
} }
FORMULA_LABELS = { _FULL_BAR = "" * 12
"batter": "PA+TB×2",
"sp": "IP+K",
"rp": "IP+K",
}
TIER_BADGES = {1: "[BC]", 2: "[R]", 3: "[GR]", 4: "[SF]"}
def render_progress_bar(current: int, threshold: int, width: int = 10) -> str: def render_progress_bar(current: int, threshold: int, width: int = 12) -> str:
""" """
Render a fixed-width ASCII progress bar. Render a Unicode block progress bar.
Examples: Examples:
render_progress_bar(120, 149) -> '[========--]' render_progress_bar(120, 149) -> '▰▰▰▰▰▰▰▰▰▰▱▱'
render_progress_bar(0, 100) -> '[----------]' render_progress_bar(0, 100) -> '▱▱▱▱▱▱▱▱▱▱▱▱'
render_progress_bar(100, 100) -> '[==========]' render_progress_bar(100, 100) -> '▰▰▰▰▰▰▰▰▰▰▰▰'
""" """
if threshold <= 0: if threshold <= 0:
filled = width filled = width
else: else:
ratio = min(current / threshold, 1.0) ratio = max(0.0, min(current / threshold, 1.0))
filled = round(ratio * width) filled = round(ratio * width)
empty = width - filled empty = width - filled
return f"[{'=' * filled}{'-' * empty}]" 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: def format_refractor_entry(card_state: dict) -> str:
""" """
Format a single card state dict as a display string. Format a single card state dict as a compact two-line display string.
Expected keys: player_name, card_type, current_tier, formula_value, Output example (base card no suffix):
next_threshold (None if fully evolved). **Mike Trout**
120/149 (80%)
A tier badge prefix (e.g. [BC], [R], [GR], [SF]) is prepended to the Output example (evolved suffix tag):
player name for tiers 1-4. T0 cards have no badge. **Mike Trout** Base Chrome [T1]
120/149 (80%)
Output example: Output example (fully evolved):
**[BC] Mike Trout** (Base Chrome) **Barry Bonds** Superfractor [T4]
[========--] 120/149 (PA+TB×2) T1 T2 `MAX`
""" """
player_name = card_state.get("player_name", "Unknown") player_name = card_state.get("player_name", "Unknown")
track = card_state.get("track", {})
card_type = track.get("card_type", "batter")
current_tier = card_state.get("current_tier", 0) current_tier = card_state.get("current_tier", 0)
formula_value = card_state.get("current_value", 0) formula_value = int(card_state.get("current_value", 0))
next_threshold = card_state.get("next_threshold") next_threshold = int(card_state.get("next_threshold") or 0) or None
tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}") if current_tier == 0:
formula_label = FORMULA_LABELS.get(card_type, card_type) first_line = f"**{player_name}**"
else:
badge = TIER_BADGES.get(current_tier, "") tier_label = TIER_NAMES.get(current_tier, f"T{current_tier}")
display_name = f"{badge} {player_name}" if badge else player_name symbol = TIER_SYMBOLS.get(current_tier, "")
first_line = f"**{player_name}** — {tier_label} [{symbol}]"
if current_tier >= 4 or next_threshold is None: if current_tier >= 4 or next_threshold is None:
bar = "[==========]" second_line = f"{_FULL_BAR} `MAX`"
detail = "FULLY EVOLVED ★"
else: else:
bar = render_progress_bar(formula_value, next_threshold) bar = render_progress_bar(formula_value, next_threshold)
detail = f"{formula_value}/{next_threshold} ({formula_label}) — T{current_tier} → T{current_tier + 1}" pct = _pct_label(formula_value, next_threshold)
second_line = f"{bar} {formula_value}/{next_threshold} ({pct})"
first_line = f"**{display_name}** ({tier_label})"
second_line = f"{bar} {detail}"
return f"{first_line}\n{second_line}" 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: def apply_close_filter(card_states: list) -> list:
""" """
Return only cards within 80% of their next tier threshold. Return only cards within 80% of their next tier threshold.
@ -107,11 +166,11 @@ def apply_close_filter(card_states: list) -> list:
result = [] result = []
for state in card_states: for state in card_states:
current_tier = state.get("current_tier", 0) current_tier = state.get("current_tier", 0)
formula_value = state.get("current_value", 0) formula_value = int(state.get("current_value", 0))
next_threshold = state.get("next_threshold") next_threshold = state.get("next_threshold")
if current_tier >= 4 or not next_threshold: if current_tier >= 4 or not next_threshold:
continue continue
if formula_value >= 0.8 * next_threshold: if formula_value >= 0.8 * int(next_threshold):
result.append(state) result.append(state)
return result return result
@ -128,6 +187,83 @@ def paginate(items: list, page: int, page_size: int = PAGE_SIZE) -> tuple:
return items[start : start + page_size], total_pages 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): class Refractor(commands.Cog):
"""Refractor progress tracking slash commands.""" """Refractor progress tracking slash commands."""
@ -142,19 +278,34 @@ class Refractor(commands.Cog):
name="status", description="Show your team's refractor progress" name="status", description="Show your team's refractor progress"
) )
@app_commands.describe( @app_commands.describe(
card_type="Card type filter (batter, sp, rp)", card_type="Filter by card type",
season="Season number (default: current)", tier="Filter by current tier",
tier="Filter by current tier (0-4)", progress="Filter by advancement progress",
progress='Use "close" to show cards within 80% of their next tier',
page="Page number (default: 1, 10 cards per page)", 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( async def refractor_status(
self, self,
interaction: discord.Interaction, interaction: discord.Interaction,
card_type: Optional[str] = None, card_type: Optional[Choice[str]] = None,
season: Optional[int] = None, tier: Optional[Choice[str]] = None,
tier: Optional[int] = None, progress: Optional[Choice[str]] = None,
progress: Optional[str] = None,
page: int = 1, page: int = 1,
): ):
"""Show a paginated view of the invoking user's team refractor progress.""" """Show a paginated view of the invoking user's team refractor progress."""
@ -171,13 +322,13 @@ class Refractor(commands.Cog):
offset = (page - 1) * PAGE_SIZE offset = (page - 1) * PAGE_SIZE
params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)] params = [("team_id", team["id"]), ("limit", PAGE_SIZE), ("offset", offset)]
if card_type: if card_type:
params.append(("card_type", card_type)) params.append(("card_type", card_type.value))
if season is not None:
params.append(("season", season))
if tier is not None: if tier is not None:
params.append(("tier", tier)) params.append(("tier", tier.value))
if progress: if progress:
params.append(("progress", 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) data = await db_get("refractor/cards", params=params)
if not data: if not data:
@ -203,6 +354,18 @@ class Refractor(commands.Cog):
total_count = ( total_count = (
data.get("count", len(items)) if isinstance(data, dict) else len(items) 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( logger.debug(
"Refractor status for team %s: %d items returned, %d total (page %d)", "Refractor status for team %s: %d items returned, %d total (page %d)",
team["id"], team["id"],
@ -211,9 +374,18 @@ class Refractor(commands.Cog):
page, page,
) )
if not items: if not items:
if progress == "close": 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( await interaction.edit_original_response(
content="No cards are currently close to a tier advancement." content=f"No cards match your filters ({filter_str}). Try `/refractor status` with no filters to see all cards."
) )
else: else:
await interaction.edit_original_response( await interaction.edit_original_response(
@ -223,19 +395,24 @@ class Refractor(commands.Cog):
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE) total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
page = min(page, total_pages) page = min(page, total_pages)
page_items = items
lines = [format_refractor_entry(state) for state in page_items]
embed = discord.Embed( embed = build_status_embed(
title=f"{team['sname']} Refractor Status", team, items, page, total_pages, total_count, tier_filter=tier_filter
description="\n\n".join(lines),
color=0x6F42C1,
)
embed.set_footer(
text=f"Page {page}/{total_pages} · {total_count} card(s) total"
) )
await interaction.edit_original_response(embed=embed) 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): async def setup(bot):

View File

@ -1,5 +1,6 @@
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
@ -23,6 +24,7 @@ from helpers import (
position_name_to_abbrev, position_name_to_abbrev,
team_role, team_role,
) )
from helpers.refractor_constants import TIER_NAMES
from helpers.refractor_notifs import notify_tier_completion 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
@ -4243,6 +4245,118 @@ async def get_game_summary_embed(
return game_embed return game_embed
async def _run_post_game_refractor_hook(db_game_id: int, channel) -> dict | None:
"""Post-game refractor processing — non-fatal.
Updates season stats then evaluates refractor milestones for all
participating players. Triggers variant card renders first to obtain
image URLs, then fires tier-up notifications with card art included.
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.
Returns the evaluate-game API response dict, or None on failure.
"""
try:
await db_post(f"season-stats/update-game/{db_game_id}")
evo_result = await db_post(f"refractor/evaluate-game/{db_game_id}")
if evo_result and evo_result.get("tier_ups"):
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)
return evo_result
except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
return None
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 _build_refractor_progress_text(
evo_result: dict | None,
winning_team_id: int,
losing_team_id: int,
) -> str | None:
"""Build the Refractor Progress embed field value for the post-game summary.
Shows tier-ups that occurred this game (from the evaluate-game response)
and any cards currently close (80%) to their next tier on either team.
Returns None when there is nothing to show so the caller can skip the field.
"""
lines = []
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
name = tier_up.get("player_name", "Unknown")
new_tier = tier_up.get("new_tier", 0)
tier_name = TIER_NAMES.get(new_tier, f"T{new_tier}")
lines.append(f"⬆ **{name}** → {tier_name}")
try:
close_lines = []
for team_id in (winning_team_id, losing_team_id):
data = await db_get(
"refractor/cards",
params=[("team_id", team_id), ("progress", "close"), ("limit", 5)],
)
if not data:
continue
items = data if isinstance(data, list) else data.get("items", [])
for card in items:
name = card.get("player_name", "Unknown")
current_value = int(card.get("current_value", 0))
next_threshold = int(card.get("next_threshold") or 0)
if next_threshold:
pct = f"{min(current_value / next_threshold, 1.0):.0%}"
else:
pct = "100%"
close_lines.append(f"{name} ({pct})")
lines.extend(close_lines[:5])
except Exception:
pass
return "\n".join(lines) if lines else None
async def complete_game( async def complete_game(
session: Session, session: Session,
interaction: discord.Interaction, interaction: discord.Interaction,
@ -4343,18 +4457,8 @@ async def complete_game(
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 refractor processing (non-blocking)
# WP-13: update season stats then evaluate refractor milestones for all # WP-13: season stats update + refractor milestone evaluation.
# participating players. Wrapped in try/except so any failure here is evo_result = await _run_post_game_refractor_hook(db_game["id"], interaction.channel)
# non-fatal — the game is already saved and refractor will catch up on the
# next evaluate call.
try:
await db_post(f"season-stats/update-game/{db_game['id']}")
evo_result = await db_post(f"refractor/evaluate-game/{db_game['id']}")
if evo_result and evo_result.get("tier_ups"):
for tier_up in evo_result["tier_ups"]:
await notify_tier_completion(interaction.channel, tier_up)
except Exception as e:
logger.warning(f"Post-game refractor processing failed (non-fatal): {e}")
session.delete(this_play) session.delete(this_play)
session.commit() session.commit()
@ -4373,6 +4477,15 @@ async def complete_game(
summary_embed.add_field(name=f"{winning_team.abbrev} Rewards", value=win_reward) summary_embed.add_field(name=f"{winning_team.abbrev} Rewards", value=win_reward)
summary_embed.add_field(name=f"{losing_team.abbrev} Rewards", value=loss_reward) summary_embed.add_field(name=f"{losing_team.abbrev} Rewards", value=loss_reward)
refractor_text = await _build_refractor_progress_text(
evo_result, winning_team.id, losing_team.id
)
if refractor_text:
summary_embed.add_field(
name="Refractor Progress", value=refractor_text, inline=False
)
summary_embed.add_field( summary_embed.add_field(
name="Highlights", name="Highlights",
value=f"Please share the highlights in {get_channel(interaction, 'pd-news-ticker').mention}!", value=f"Please share the highlights in {get_channel(interaction, 'pd-news-ticker').mention}!",

View File

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

View File

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

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 = 8080): def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8081):
""" """
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: 8080) port: Port to listen on (default: 8081)
""" """
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 = 8080): async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8081):
""" """
Run health server as a background task. Run health server as a background task.

View File

@ -120,8 +120,10 @@ async def get_card_embeds(card, include_stats=False) -> list:
tier = evo_state["current_tier"] tier = evo_state["current_tier"]
badge = TIER_BADGES.get(tier) badge = TIER_BADGES.get(tier)
tier_badge = f"[{badge}] " if badge else "" tier_badge = f"[{badge}] " if badge else ""
except Exception: except Exception as e:
pass logging.debug(
f"badge lookup failed for card {card.get('id')}: {e}", exc_info=True
)
embed = discord.Embed( embed = discord.Embed(
title=f"{tier_badge}{card['player']['p_name']}", title=f"{tier_badge}{card['player']['p_name']}",
@ -337,7 +339,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 = [await get_card_embeds(x) for x in cards] card_embeds = list(await asyncio.gather(*[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
@ -1784,14 +1786,18 @@ 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:
for p_id in pack_ids: await asyncio.gather(
pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] *[
if pack_cards: create_scout_opportunity(
await create_scout_opportunity( [c for c in all_cards if c.get("pack_id") == p_id],
pack_cards, team, pack_channel, author, context team,
pack_channel,
author,
context,
) )
if len(pack_ids) > 1: for p_id in pack_ids
await asyncio.sleep(2) ]
)
async def get_choice_from_cards( async def get_choice_from_cards(

View File

@ -0,0 +1,36 @@
"""
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)
}

View File

@ -12,35 +12,23 @@ 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")
# Human-readable display names for each tier number.
TIER_NAMES = {
0: "Base Card",
1: "Base Chrome",
2: "Refractor",
3: "Gold Refractor",
4: "Superfractor",
}
# Tier-specific embed colors.
TIER_COLORS = {
1: 0x2ECC71, # green
2: 0xF1C40F, # gold
3: 0x9B59B6, # purple
4: 0x1ABC9C, # teal (superfractor)
}
FOOTER_TEXT = "Paper Dynasty Refractor" FOOTER_TEXT = "Paper Dynasty Refractor"
def build_tier_up_embed(tier_up: dict) -> discord.Embed: def build_tier_up_embed(tier_up: dict, image_url: str | None = None) -> 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
------- -------
@ -63,11 +51,6 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
), ),
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="Refractor Tier Up!",
@ -77,12 +60,14 @@ def build_tier_up_embed(tier_up: dict) -> discord.Embed:
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: discord.abc.Messageable, tier_up: dict channel: discord.abc.Messageable, tier_up: dict, image_url: str | None = None
) -> None: ) -> None:
"""Send a tier-up notification embed to the given channel. """Send a tier-up notification embed to the given channel.
@ -95,9 +80,12 @@ async def notify_tier_completion(
A discord.abc.Messageable (e.g. discord.TextChannel). A discord.abc.Messageable (e.g. discord.TextChannel).
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) embed = build_tier_up_embed(tier_up, image_url=image_url)
await channel.send(embed=embed) await channel.send(embed=embed)
except Exception as exc: except Exception as exc:
logger.error( logger.error(

View File

@ -32,7 +32,7 @@ the expected bot response, and pass/fail criteria.
Before running these tests, ensure the following state exists: Before running these tests, ensure the following state exists:
### Bot State ### Bot State
- [ ] Bot is online and healthy: `GET http://sba-bots:8080/health` returns 200 - [ ] 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'` - [ ] Refractor cog is loaded: check bot logs for `Loaded extension 'cogs.refractor'`
- [ ] Test user has the `PD Players` role on the dev server - [ ] Test user has the `PD Players` role on the dev server
@ -72,7 +72,7 @@ API layer is functional. Execute via shell or Playwright network interception.
### REF-API-01: Bot health endpoint ### REF-API-01: Bot health endpoint
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Command** | `curl -sf http://sba-bots:8080/health` | | **Command** | `curl -sf http://sba-bots:8081/health` |
| **Expected** | HTTP 200, body contains health status | | **Expected** | HTTP 200, body contains health status |
| **Pass criteria** | Non-empty 200 response | | **Pass criteria** | Non-empty 200 response |
@ -382,11 +382,11 @@ API layer is functional. Execute via shell or Playwright network interception.
These tests verify that tier badges appear in card embed titles across all These tests verify that tier badges appear in card embed titles across all
commands that display card embeds via `get_card_embeds()`. commands that display card embeds via `get_card_embeds()`.
### REF-40: Tier badge on /card command (player lookup) ### REF-40: Tier badge on /player command (player lookup)
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Description** | Look up a card that has a refractor tier > 0 | | **Description** | Look up a card that has a refractor tier > 0 |
| **Discord command** | `/card {player_name}` (use a player known to have refractor state) | | **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) | | **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 | | **Pass criteria** | 1. Embed title starts with the correct tier badge in brackets |
| | 2. Player name follows the badge | | | 2. Player name follows the badge |
@ -396,7 +396,7 @@ commands that display card embeds via `get_card_embeds()`.
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Description** | Look up a card with current_tier=0 | | **Description** | Look up a card with current_tier=0 |
| **Discord command** | `/card {player_name}` (use a player at T0) | | **Discord command** | `/player {player_name}` (use a player at T0) |
| **Expected result** | Embed title is just `Player Name` with no bracket prefix | | **Expected result** | Embed title is just `Player Name` with no bracket prefix |
| **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` | | **Pass criteria** | Title does not contain `[BC]`, `[R]`, `[GR]`, or `[SF]` |
@ -404,7 +404,7 @@ commands that display card embeds via `get_card_embeds()`.
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Description** | Look up a card that has no RefractorCardState row | | **Description** | Look up a card that has no RefractorCardState row |
| **Discord command** | `/card {player_name}` (use a player with no refractor state) | | **Discord command** | `/player {player_name}` (use a player with no refractor state) |
| **Expected result** | Embed title is just `Player Name` with no bracket prefix | | **Expected result** | Embed title is just `Player Name` with no bracket prefix |
| **Pass criteria** | 1. Title has no badge prefix | | **Pass criteria** | 1. Title has no badge prefix |
| | 2. No error in bot logs about the refractor API call | | | 2. No error in bot logs about the refractor API call |
@ -414,7 +414,7 @@ commands that display card embeds via `get_card_embeds()`.
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Description** | Start a card purchase for a player with refractor state | | **Description** | Start a card purchase for a player with refractor state |
| **Discord command** | `/buy {player_name}` | | **Discord command** | `/buy card-by-name {player_name}` |
| **Expected result** | The card embed shown during purchase confirmation includes the tier badge | | **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 | | **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. | | **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. |
@ -423,16 +423,16 @@ commands that display card embeds via `get_card_embeds()`.
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Description** | Open a pack and check if revealed cards show tier badges | | **Description** | Open a pack and check if revealed cards show tier badges |
| **Discord command** | `/openpack` (or equivalent pack opening command) | | **Discord command** | `/open-packs` |
| **Expected result** | Cards displayed via `display_cards()` -> `get_card_embeds()` show tier badges if applicable | | **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 | | **Pass criteria** | Cards with refractor state show badges; cards without state show no badge and no error |
### REF-45: Badge consistency between /card and /refractor status ### REF-45: Badge consistency between /player and /refractor status
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Description** | Compare the badge shown for the same player in both views | | **Description** | Compare the badge shown for the same player in both views |
| **Discord command** | Run both `/card {player}` and `/refractor status` for the same player | | **Discord command** | Run both `/player {player}` and `/refractor status` for the same player |
| **Expected result** | The badge in the `/card` embed title (`[BC]`, `[R]`, etc.) matches the tier shown in `/refractor status` | | **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] | | **Pass criteria** | Tier badge letter matches: T1=[BC], T2=[R], T3=[GR], T4=[SF] |
--- ---
@ -542,10 +542,8 @@ REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
| | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") | | | - Title: "SUPERFRACTOR!" (not "Refractor Tier Up!") |
| | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` | | | - Description: `**{Player Name}** has reached maximum refractor tier on the **{Track Name}** track` |
| | - Color: teal (`0x1ABC9C`) | | | - Color: teal (`0x1ABC9C`) |
| | - Extra field: "Rating Boosts" with value "Rating boosts coming in a future update!" |
| **Pass criteria** | 1. Title is "SUPERFRACTOR!" | | **Pass criteria** | 1. Title is "SUPERFRACTOR!" |
| | 2. Description mentions "maximum refractor tier" | | | 2. Description mentions "maximum refractor tier" |
| | 3. "Rating Boosts" field is present |
### REF-63: Multiple tier-ups in one game ### REF-63: Multiple tier-ups in one game
| Field | Value | | Field | Value |
@ -570,11 +568,11 @@ REF-55, REF-60 through REF-64) can be validated via API calls and bot logs.
These tests verify that tier badges appear (or correctly do not appear) in all These tests verify that tier badges appear (or correctly do not appear) in all
commands that display card information. commands that display card information.
### REF-70: /roster command -- cards show tier badges ### REF-70: /team command -- cards show tier badges
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Discord command** | `/roster` or equivalent command that lists team cards | | **Discord command** | `/team` |
| **Expected result** | If roster display uses `get_card_embeds()`, cards with refractor state show tier badges | | **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 | | **Pass criteria** | Cards at T1+ have badges; T0 cards have none |
### REF-71: /show-card defense (in-game) -- no badge expected ### REF-71: /show-card defense (in-game) -- no badge expected
@ -586,12 +584,13 @@ commands that display card information.
| **Pass criteria** | This is EXPECTED behavior -- in-game card display does not fetch refractor state | | **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. | | **Notes** | This is a known limitation, not a bug. Document for future consideration. |
### REF-72: /scouting view -- badge on scouted cards ### REF-72: /scout-tokens -- no badge expected
| Field | Value | | Field | Value |
|---|---| |---|---|
| **Discord command** | `/scout {player_name}` (if the scouting cog uses get_card_embeds) | | **Discord command** | `/scout-tokens` |
| **Expected result** | If the scouting view calls get_card_embeds, badges should appear | | **Expected result** | Scout tokens display does not show card embeds, so no badges are expected |
| **Pass criteria** | Verify whether scouting uses get_card_embeds or its own embed builder | | **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. |
--- ---
@ -665,16 +664,38 @@ design but means tier-up notifications are best-effort.
Run order for Playwright automation: Run order for Playwright automation:
1. [ ] Execute REF-API-01 through REF-API-10 (API health + list endpoint) 1. [x] Execute REF-API-01 through REF-API-10 (API health + list endpoint)
2. [ ] Execute REF-01 through REF-06 (basic /refractor status) - Tested 2026-03-25: REF-API-03 (single card ✓), REF-API-06 (list ✓), REF-API-07 (card_type filter ✓), REF-API-10 (pagination ✓)
3. [ ] Execute REF-10 through REF-19 (filters) - 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 ✓)
4. [ ] Execute REF-20 through REF-23 (pagination) - REF-API-01 (bot health) not tested via API (port conflict with adminer on localhost:8080), but bot confirmed healthy via logs
5. [ ] Execute REF-30 through REF-34 (edge cases) 2. [x] Execute REF-01 through REF-06 (basic /refractor status)
6. [ ] Execute REF-40 through REF-45 (tier badges on card embeds) - 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) 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) 8. [ ] Execute REF-60 through REF-64 (tier-up notifications -- requires threshold crossing)
9. [ ] Execute REF-70 through REF-72 (cross-command badge propagation) 9. [N/A] Execute REF-70 through REF-72 (cross-command badge propagation)
10. [ ] Execute REF-80 through REF-82 (force-evaluate API) - 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 ### Approximate Time Estimates
- API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes - API health checks + list endpoint (REF-API-01 through REF-API-10): 2-3 minutes

View File

@ -14,7 +14,7 @@ STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pddev.manticorum.com/ap
echo "" echo ""
echo "=== Discord Bot ===" echo "=== Discord Bot ==="
# Health check # Health check
curl -sf http://sba-bots:8080/health >/dev/null 2>&1 && echo "PASS: bot health OK" || echo "FAIL: bot health endpoint" 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 # Recent refractor activity in logs
echo "" echo ""

View File

@ -251,78 +251,36 @@ class TestEmbedColorUnchanged:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTierBadgesFormatConsistency: class TestTierSymbolsCompleteness:
""" """
T1-7: Assert that TIER_BADGES in cogs.refractor (format: "[BC]") and T1-7: Assert that TIER_SYMBOLS in cogs.refractor covers all tiers 0-4
helpers.main (format: "BC") are consistent wrapping the helpers.main and that helpers.main TIER_BADGES still covers tiers 1-4 for card embeds.
value in brackets must produce the cogs.refractor value.
Why: The two modules intentionally use different formats for different Why: The refractor status command uses Unicode TIER_SYMBOLS for display,
rendering contexts: while card embed titles use helpers.main TIER_BADGES in bracket format.
- helpers.main uses bare strings ("BC") because get_card_embeds Both must cover the full tier range for their respective contexts.
wraps them in brackets when building the embed title.
- cogs.refractor uses bracket strings ("[BC]") because
format_refractor_entry inlines them directly into the display string.
If either definition is updated without updating the other, embed titles
and /refractor status output will display inconsistent badges. This test
acts as an explicit contract check so any future change to either dict
is immediately surfaced here.
""" """
def test_cogs_badge_equals_bracketed_helpers_badge_for_all_tiers(self): def test_tier_symbols_covers_all_tiers(self):
""" """TIER_SYMBOLS must have entries for T0 through T4."""
For every tier in cogs.refractor TIER_BADGES, wrapping the from cogs.refractor import TIER_SYMBOLS
helpers.main TIER_BADGES value in square brackets must produce
the cogs.refractor value.
i.e., f"[{helpers_badge}]" == cog_badge for all tiers. for tier in range(5):
""" assert tier in TIER_SYMBOLS, f"TIER_SYMBOLS missing tier {tier}"
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert set(cog_badges.keys()) == set(helpers_badges.keys()), ( def test_tier_badges_covers_evolved_tiers(self):
"TIER_BADGES key sets differ between cogs.refractor and helpers.main. " """helpers.main TIER_BADGES must have entries for T1 through T4."""
f"cogs keys: {set(cog_badges.keys())}, helpers keys: {set(helpers_badges.keys())}" from helpers.main import TIER_BADGES
)
for tier, cog_badge in cog_badges.items(): for tier in range(1, 5):
helpers_badge = helpers_badges[tier] assert tier in TIER_BADGES, f"TIER_BADGES missing tier {tier}"
expected = f"[{helpers_badge}]"
assert cog_badge == expected, (
f"Tier {tier} badge mismatch: "
f"cogs.refractor={cog_badge!r}, "
f"helpers.main={helpers_badge!r} "
f"(expected cog badge to equal '[{helpers_badge}]')"
)
def test_t1_badge_relationship(self): def test_tier_symbols_are_unique(self):
"""T1: helpers.main 'BC' wrapped in brackets equals cogs.refractor '[BC]'.""" """Each tier must have a distinct symbol."""
from cogs.refractor import TIER_BADGES as cog_badges from cogs.refractor import TIER_SYMBOLS
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[1]}]" == cog_badges[1] values = list(TIER_SYMBOLS.values())
assert len(values) == len(set(values)), f"Duplicate symbols found: {values}"
def test_t2_badge_relationship(self):
"""T2: helpers.main 'R' wrapped in brackets equals cogs.refractor '[R]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[2]}]" == cog_badges[2]
def test_t3_badge_relationship(self):
"""T3: helpers.main 'GR' wrapped in brackets equals cogs.refractor '[GR]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[3]}]" == cog_badges[3]
def test_t4_badge_relationship(self):
"""T4: helpers.main 'SF' wrapped in brackets equals cogs.refractor '[SF]'."""
from cogs.refractor import TIER_BADGES as cog_badges
from helpers.main import TIER_BADGES as helpers_badges
assert f"[{helpers_badges[4]}]" == cog_badges[4]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

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

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

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

@ -29,7 +29,7 @@ from cogs.refractor import (
apply_close_filter, apply_close_filter,
paginate, paginate,
TIER_NAMES, TIER_NAMES,
TIER_BADGES, TIER_SYMBOLS,
PAGE_SIZE, PAGE_SIZE,
) )
@ -40,12 +40,12 @@ from cogs.refractor import (
@pytest.fixture @pytest.fixture
def batter_state(): def batter_state():
"""A mid-progress batter card state.""" """A mid-progress batter card state (API response shape)."""
return { return {
"player_name": "Mike Trout", "player_name": "Mike Trout",
"card_type": "batter", "track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 1, "current_tier": 1,
"formula_value": 120, "current_value": 120,
"next_threshold": 149, "next_threshold": 149,
} }
@ -55,9 +55,9 @@ def evolved_state():
"""A fully evolved card state (T4).""" """A fully evolved card state (T4)."""
return { return {
"player_name": "Shohei Ohtani", "player_name": "Shohei Ohtani",
"card_type": "batter", "track": {"card_type": "batter", "formula": "pa + tb * 2"},
"current_tier": 4, "current_tier": 4,
"formula_value": 300, "current_value": 300,
"next_threshold": None, "next_threshold": None,
} }
@ -67,9 +67,9 @@ def sp_state():
"""A starting pitcher card state at T2.""" """A starting pitcher card state at T2."""
return { return {
"player_name": "Sandy Alcantara", "player_name": "Sandy Alcantara",
"card_type": "sp", "track": {"card_type": "sp", "formula": "ip + k"},
"current_tier": 2, "current_tier": 2,
"formula_value": 95, "current_value": 95,
"next_threshold": 120, "next_threshold": 120,
} }
@ -84,38 +84,44 @@ class TestRenderProgressBar:
Tests for render_progress_bar(). Tests for render_progress_bar().
Verifies width, fill character, empty character, boundary conditions, Verifies width, fill character, empty character, boundary conditions,
and clamping when current exceeds threshold. and clamping when current exceeds threshold. Default width is 12.
Uses Unicode block chars: (filled) and (empty).
""" """
def test_empty_bar(self): def test_empty_bar(self):
"""current=0 → all dashes.""" """current=0 → all empty blocks."""
assert render_progress_bar(0, 100) == "[----------]" assert render_progress_bar(0, 100) == "" * 12
def test_full_bar(self): def test_full_bar(self):
"""current == threshold → all equals.""" """current == threshold → all filled blocks."""
assert render_progress_bar(100, 100) == "[==========]" assert render_progress_bar(100, 100) == "" * 12
def test_partial_fill(self): def test_partial_fill(self):
"""120/149 ≈ 80.5%8 filled of 10.""" """120/149 ≈ 80.5%~10 filled of 12."""
bar = render_progress_bar(120, 149) bar = render_progress_bar(120, 149)
assert bar == "[========--]" filled = bar.count("")
empty = bar.count("")
assert filled + empty == 12
assert filled == 10 # round(0.805 * 12) = 10
def test_half_fill(self): def test_half_fill(self):
"""50/100 = 50% → 5 filled.""" """50/100 = 50% → 6 filled."""
assert render_progress_bar(50, 100) == "[=====-----]" bar = render_progress_bar(50, 100)
assert bar.count("") == 6
assert bar.count("") == 6
def test_over_threshold_clamps_to_full(self): def test_over_threshold_clamps_to_full(self):
"""current > threshold should not overflow the bar.""" """current > threshold should not overflow the bar."""
assert render_progress_bar(200, 100) == "[==========]" assert render_progress_bar(200, 100) == "" * 12
def test_zero_threshold_returns_full_bar(self): def test_zero_threshold_returns_full_bar(self):
"""threshold=0 avoids division by zero and returns full bar.""" """threshold=0 avoids division by zero and returns full bar."""
assert render_progress_bar(0, 0) == "[==========]" assert render_progress_bar(0, 0) == "" * 12
def test_custom_width(self): def test_custom_width(self):
"""Width parameter controls bar length.""" """Width parameter controls bar length."""
bar = render_progress_bar(5, 10, width=4) bar = render_progress_bar(5, 10, width=4)
assert bar == "[==--]" assert bar == "▰▰▱▱"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -140,44 +146,29 @@ class TestFormatRefractorEntry:
def test_tier_label_in_output(self, batter_state): def test_tier_label_in_output(self, batter_state):
"""Current tier name (Base Chrome for T1) appears in output.""" """Current tier name (Base Chrome for T1) appears in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "(Base Chrome)" in result assert "Base Chrome" in result
def test_progress_values_in_output(self, batter_state): def test_progress_values_in_output(self, batter_state):
"""current/threshold values appear in output.""" """current/threshold values appear in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "120/149" in result assert "120/149" in result
def test_formula_label_batter(self, batter_state): def test_percentage_in_output(self, batter_state):
"""Batter formula label PA+TB×2 appears in output.""" """Percentage appears in parentheses in output."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "PA+TB×2" in result assert "(80%)" in result or "(81%)" in result
def test_tier_progression_arrow(self, batter_state):
"""T1 → T2 arrow progression appears for non-evolved cards."""
result = format_refractor_entry(batter_state)
assert "T1 → T2" in result
def test_sp_formula_label(self, sp_state):
"""SP formula label IP+K appears for starting pitchers."""
result = format_refractor_entry(sp_state)
assert "IP+K" in result
def test_fully_evolved_no_threshold(self, evolved_state): def test_fully_evolved_no_threshold(self, evolved_state):
"""T4 card with next_threshold=None shows FULLY EVOLVED.""" """T4 card with next_threshold=None shows MAX."""
result = format_refractor_entry(evolved_state) result = format_refractor_entry(evolved_state)
assert "FULLY EVOLVED" in result assert "`MAX`" in result
def test_fully_evolved_by_tier(self, batter_state): def test_fully_evolved_by_tier(self, batter_state):
"""current_tier=4 triggers fully evolved display even with a threshold.""" """current_tier=4 triggers fully evolved display even with a threshold."""
batter_state["current_tier"] = 4 batter_state["current_tier"] = 4
batter_state["next_threshold"] = 200 batter_state["next_threshold"] = 200
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "FULLY EVOLVED" in result assert "`MAX`" in result
def test_fully_evolved_no_arrow(self, evolved_state):
"""Fully evolved cards don't show a tier arrow."""
result = format_refractor_entry(evolved_state)
assert "" not in result
def test_two_line_output(self, batter_state): def test_two_line_output(self, batter_state):
"""Output always has exactly two lines (name line + bar line).""" """Output always has exactly two lines (name line + bar line)."""
@ -191,69 +182,66 @@ class TestFormatRefractorEntry:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestTierBadges: class TestTierSymbols:
""" """
Verify TIER_BADGES values and that format_refractor_entry prepends badges Verify TIER_SYMBOLS values and that format_refractor_entry prepends
correctly for T1-T4. T0 cards should have no badge prefix. the correct label for each tier. Labels use short readable text (T0-T4).
""" """
def test_t1_badge_value(self): def test_t0_symbol(self):
"""T1 badge is [BC] (Base Chrome).""" """T0 label is empty (base cards get no prefix)."""
assert TIER_BADGES[1] == "[BC]" assert TIER_SYMBOLS[0] == "Base"
def test_t2_badge_value(self): def test_t1_symbol(self):
"""T2 badge is [R] (Refractor).""" """T1 label is 'T1'."""
assert TIER_BADGES[2] == "[R]" assert TIER_SYMBOLS[1] == "T1"
def test_t3_badge_value(self): def test_t2_symbol(self):
"""T3 badge is [GR] (Gold Refractor).""" """T2 label is 'T2'."""
assert TIER_BADGES[3] == "[GR]" assert TIER_SYMBOLS[2] == "T2"
def test_t4_badge_value(self): def test_t3_symbol(self):
"""T4 badge is [SF] (Superfractor).""" """T3 label is 'T3'."""
assert TIER_BADGES[4] == "[SF]" assert TIER_SYMBOLS[3] == "T3"
def test_t0_no_badge(self): def test_t4_symbol(self):
"""T0 has no badge entry in TIER_BADGES.""" """T4 label is 'T4★'."""
assert 0 not in TIER_BADGES assert TIER_SYMBOLS[4] == "T4★"
def test_format_entry_t1_badge_present(self, batter_state): def test_format_entry_t1_suffix_tag(self, batter_state):
"""format_refractor_entry prepends [BC] badge for T1 cards.""" """T1 cards show [T1] suffix tag after the tier name."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
assert "[BC]" in result assert "[T1]" in result
def test_format_entry_t2_badge_present(self, sp_state): def test_format_entry_t2_suffix_tag(self, sp_state):
"""format_refractor_entry prepends [R] badge for T2 cards.""" """T2 cards show [T2] suffix tag."""
result = format_refractor_entry(sp_state) result = format_refractor_entry(sp_state)
assert "[R]" in result assert "[T2]" in result
def test_format_entry_t4_badge_present(self, evolved_state): def test_format_entry_t4_suffix_tag(self, evolved_state):
"""format_refractor_entry prepends [SF] badge for T4 cards.""" """T4 cards show [T4★] suffix tag."""
result = format_refractor_entry(evolved_state) result = format_refractor_entry(evolved_state)
assert "[SF]" in result assert "[T4★]" in result
def test_format_entry_t0_no_badge(self): def test_format_entry_t0_name_only(self):
"""format_refractor_entry does not prepend any badge for T0 cards.""" """T0 cards show just the bold name, no tier suffix."""
state = { state = {
"player_name": "Rookie Player", "player_name": "Rookie Player",
"card_type": "batter",
"current_tier": 0, "current_tier": 0,
"formula_value": 10, "current_value": 10,
"next_threshold": 50, "next_threshold": 50,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
assert "[BC]" not in result first_line = result.split("\n")[0]
assert "[R]" not in result assert first_line == "**Rookie Player**"
assert "[GR]" not in result
assert "[SF]" not in result
def test_format_entry_badge_before_name(self, batter_state): def test_format_entry_tag_after_name(self, batter_state):
"""Badge appears before the player name in the bold section.""" """Tag appears after the player name in the first line."""
result = format_refractor_entry(batter_state) result = format_refractor_entry(batter_state)
first_line = result.split("\n")[0] first_line = result.split("\n")[0]
badge_pos = first_line.find("[BC]")
name_pos = first_line.find("Mike Trout") name_pos = first_line.find("Mike Trout")
assert badge_pos < name_pos tag_pos = first_line.find("[T1]")
assert name_pos < tag_pos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -271,34 +259,34 @@ class TestApplyCloseFilter:
def test_close_card_included(self): def test_close_card_included(self):
"""Card at exactly 80% is included.""" """Card at exactly 80% is included."""
state = {"current_tier": 1, "formula_value": 80, "next_threshold": 100} state = {"current_tier": 1, "current_value": 80, "next_threshold": 100}
assert apply_close_filter([state]) == [state] assert apply_close_filter([state]) == [state]
def test_above_80_percent_included(self): def test_above_80_percent_included(self):
"""Card above 80% is included.""" """Card above 80% is included."""
state = {"current_tier": 0, "formula_value": 95, "next_threshold": 100} state = {"current_tier": 0, "current_value": 95, "next_threshold": 100}
assert apply_close_filter([state]) == [state] assert apply_close_filter([state]) == [state]
def test_below_80_percent_excluded(self): def test_below_80_percent_excluded(self):
"""Card below 80% threshold is excluded.""" """Card below 80% threshold is excluded."""
state = {"current_tier": 1, "formula_value": 79, "next_threshold": 100} state = {"current_tier": 1, "current_value": 79, "next_threshold": 100}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_fully_evolved_excluded(self): def test_fully_evolved_excluded(self):
"""T4 cards are never returned by close filter.""" """T4 cards are never returned by close filter."""
state = {"current_tier": 4, "formula_value": 300, "next_threshold": None} state = {"current_tier": 4, "current_value": 300, "next_threshold": None}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_none_threshold_excluded(self): def test_none_threshold_excluded(self):
"""Cards with no next_threshold (regardless of tier) are excluded.""" """Cards with no next_threshold (regardless of tier) are excluded."""
state = {"current_tier": 3, "formula_value": 200, "next_threshold": None} state = {"current_tier": 3, "current_value": 200, "next_threshold": None}
assert apply_close_filter([state]) == [] assert apply_close_filter([state]) == []
def test_mixed_list(self): def test_mixed_list(self):
"""Only qualifying cards are returned from a mixed list.""" """Only qualifying cards are returned from a mixed list."""
close = {"current_tier": 1, "formula_value": 90, "next_threshold": 100} close = {"current_tier": 1, "current_value": 90, "next_threshold": 100}
not_close = {"current_tier": 1, "formula_value": 50, "next_threshold": 100} not_close = {"current_tier": 1, "current_value": 50, "next_threshold": 100}
evolved = {"current_tier": 4, "formula_value": 300, "next_threshold": None} evolved = {"current_tier": 4, "current_value": 300, "next_threshold": None}
result = apply_close_filter([close, not_close, evolved]) result = apply_close_filter([close, not_close, evolved])
assert result == [close] assert result == [close]
@ -427,47 +415,51 @@ def mock_interaction():
class TestTierNamesDivergenceCheck: class TestTierNamesDivergenceCheck:
""" """
T1-6: Assert that TIER_NAMES in cogs.refractor and helpers.refractor_notifs T1-6: Assert that TIER_NAMES in all three consumers (cogs.refractor,
are identical (same keys, same values). helpers.refractor_notifs, cogs.players) is identical.
Why: TIER_NAMES is duplicated in two modules. If one is updated and the All three consumers now import from helpers.refractor_constants, so this
other is not (e.g. a tier is renamed or a new tier is added), tier labels test acts as a tripwire against accidental re-localization of the constant.
in the /refractor status embed and the tier-up notification embed will If any consumer re-declares a local copy that diverges, these tests will
diverge silently. This test acts as a divergence tripwire it will fail catch it.
the moment the two copies fall out of sync, forcing an explicit fix.
""" """
def test_tier_names_are_identical_across_modules(self): def test_tier_names_are_identical_across_modules(self):
""" """
Import TIER_NAMES from both modules and assert deep equality. Import TIER_NAMES from all three consumers and assert deep equality.
The test imports the name at call-time rather than at module level to The test imports at call-time rather than module level to ensure it
ensure it always reads the current definition and is not affected by always reads the current definition and is not affected by caching or
module-level caching or monkeypatching in other tests. monkeypatching in other tests.
""" """
from cogs.refractor import TIER_NAMES as cog_tier_names from cogs.refractor import TIER_NAMES as cog_tier_names
from helpers.refractor_notifs import TIER_NAMES as notifs_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, ( assert cog_tier_names == notifs_tier_names == constants_tier_names, (
"TIER_NAMES differs between cogs.refractor and helpers.refractor_notifs. " "TIER_NAMES differs across consumers. "
"Both copies must be kept in sync. "
f"cogs.refractor: {cog_tier_names!r} " f"cogs.refractor: {cog_tier_names!r} "
f"helpers.refractor_notifs: {notifs_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): def test_tier_names_have_same_keys(self):
"""Keys (tier numbers) must be identical in both modules.""" """Keys (tier numbers) must be identical in all consumers."""
from cogs.refractor import TIER_NAMES as cog_tier_names from cogs.refractor import TIER_NAMES as cog_tier_names
from helpers.refractor_notifs import TIER_NAMES as notifs_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()), ( assert (
"TIER_NAMES key sets differ between modules." 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): def test_tier_names_have_same_values(self):
"""Display strings (values) must be identical for every shared key.""" """Display strings (values) must be identical for every shared key."""
from cogs.refractor import TIER_NAMES as cog_tier_names from cogs.refractor import TIER_NAMES as cog_tier_names
from helpers.refractor_notifs import TIER_NAMES as notifs_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(): for tier, name in cog_tier_names.items():
assert notifs_tier_names.get(tier) == name, ( assert notifs_tier_names.get(tier) == name, (
@ -475,6 +467,11 @@ class TestTierNamesDivergenceCheck:
f"cogs.refractor={name!r}, " f"cogs.refractor={name!r}, "
f"helpers.refractor_notifs={notifs_tier_names.get(tier)!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}"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -506,9 +503,9 @@ class TestApplyCloseFilterWithAllT4Cards:
the "no cards close to advancement" message rather than an empty embed. the "no cards close to advancement" message rather than an empty embed.
""" """
t4_cards = [ t4_cards = [
{"current_tier": 4, "formula_value": 300, "next_threshold": None}, {"current_tier": 4, "current_value": 300, "next_threshold": None},
{"current_tier": 4, "formula_value": 500, "next_threshold": None}, {"current_tier": 4, "current_value": 500, "next_threshold": None},
{"current_tier": 4, "formula_value": 275, "next_threshold": None}, {"current_tier": 4, "current_value": 275, "next_threshold": None},
] ]
result = apply_close_filter(t4_cards) result = apply_close_filter(t4_cards)
assert result == [], ( assert result == [], (
@ -523,7 +520,7 @@ class TestApplyCloseFilterWithAllT4Cards:
""" """
t4_high_value = { t4_high_value = {
"current_tier": 4, "current_tier": 4,
"formula_value": 9999, "current_value": 9999,
"next_threshold": None, "next_threshold": None,
} }
assert apply_close_filter([t4_high_value]) == [] assert apply_close_filter([t4_high_value]) == []
@ -552,9 +549,9 @@ class TestFormatRefractorEntryMalformedInput:
than crashing with a KeyError. than crashing with a KeyError.
""" """
state = { state = {
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 1, "current_tier": 1,
"formula_value": 100, "current_value": 100,
"next_threshold": 150, "next_threshold": 150,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
@ -562,12 +559,12 @@ class TestFormatRefractorEntryMalformedInput:
def test_missing_formula_value_uses_zero(self): def test_missing_formula_value_uses_zero(self):
""" """
When formula_value is absent, the progress calculation should use 0 When current_value is absent, the progress calculation should use 0
without raising a TypeError. without raising a TypeError.
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"card_type": "batter", "track": {"card_type": "batter"},
"current_tier": 1, "current_tier": 1,
"next_threshold": 150, "next_threshold": 150,
} }
@ -585,20 +582,19 @@ class TestFormatRefractorEntryMalformedInput:
lines = result.split("\n") lines = result.split("\n")
assert len(lines) == 2 assert len(lines) == 2
def test_missing_card_type_uses_raw_fallback(self): def test_missing_card_type_does_not_crash(self):
""" """
When card_type is absent, the code defaults to 'batter' internally When card_type is absent from the track, the code should still
(via .get("card_type", "batter")), so "PA+TB×2" should appear as the produce a valid two-line output without crashing.
formula label.
""" """
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"current_tier": 1, "current_tier": 1,
"formula_value": 50, "current_value": 50,
"next_threshold": 100, "next_threshold": 100,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
assert "PA+TB×2" in result assert "50/100" in result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -623,30 +619,27 @@ class TestRenderProgressBarBoundaryPrecision:
rest empty. The bar must not appear more than minimally filled. rest empty. The bar must not appear more than minimally filled.
""" """
bar = render_progress_bar(1, 100) bar = render_progress_bar(1, 100)
# Interior is 10 chars: count '=' vs '-' filled_count = bar.count("")
interior = bar[1:-1] # strip '[' and ']'
filled_count = interior.count("=")
assert filled_count <= 1, ( assert filled_count <= 1, (
f"1/100 should show 0 or 1 filled segment, got {filled_count}: {bar!r}" 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): def test_ninety_nine_of_hundred_is_nearly_full(self):
""" """
99/100 = 99% should produce a bar with 9 or 10 filled segments. 99/100 = 99% should produce a bar with 11 or 12 filled segments.
The bar must NOT be completely empty or show fewer than 9 filled. The bar must NOT be completely empty or show fewer than 11 filled.
""" """
bar = render_progress_bar(99, 100) bar = render_progress_bar(99, 100)
interior = bar[1:-1] filled_count = bar.count("")
filled_count = interior.count("=") assert filled_count >= 11, (
assert filled_count >= 9, ( f"99/100 should show 11 or 12 filled segments, got {filled_count}: {bar!r}"
f"99/100 should show 9 or 10 filled segments, got {filled_count}: {bar!r}"
) )
# But it must not overflow the bar width # Bar width must be exactly 12
assert len(interior) == 10 assert len(bar) == 12
def test_zero_of_hundred_is_completely_empty(self): def test_zero_of_hundred_is_completely_empty(self):
"""0/100 = all dashes — re-verify the all-empty baseline.""" """0/100 = all empty blocks — re-verify the all-empty baseline."""
assert render_progress_bar(0, 100) == "[----------]" assert render_progress_bar(0, 100) == "" * 12
def test_negative_current_does_not_overflow_bar(self): def test_negative_current_does_not_overflow_bar(self):
""" """
@ -656,14 +649,12 @@ class TestRenderProgressBarBoundaryPrecision:
a future refactor removing the clamp. a future refactor removing the clamp.
""" """
bar = render_progress_bar(-5, 100) bar = render_progress_bar(-5, 100)
interior = bar[1:-1] filled_count = bar.count("")
# No filled segments should exist for a negative value
filled_count = interior.count("=")
assert filled_count == 0, ( assert filled_count == 0, (
f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}" f"Negative current should produce 0 filled segments, got {filled_count}: {bar!r}"
) )
# Bar width must be exactly 10 # Bar width must be exactly 12
assert len(interior) == 10 assert len(bar) == 12
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -671,79 +662,32 @@ class TestRenderProgressBarBoundaryPrecision:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestRPFormulaLabel: class TestCardTypeVariants:
""" """
T3-4: Verify that relief pitchers (card_type="rp") show the "IP+K" formula T3-4/T3-5: Verify that format_refractor_entry produces valid output for
label in format_refractor_entry output. all card types including unknown ones, without crashing.
Why: FORMULA_LABELS maps both "sp" and "rp" to "IP+K". The existing test
suite only verifies "sp" (via the sp_state fixture). Adding "rp" explicitly
prevents a future refactor from accidentally giving RPs a different label
or falling through to the raw card_type fallback.
""" """
def test_rp_formula_label_is_ip_plus_k(self): def test_rp_card_produces_valid_output(self):
""" """Relief pitcher card produces a valid two-line string."""
A card with card_type="rp" must show "IP+K" as the formula label
in its progress line.
"""
rp_state = { rp_state = {
"player_name": "Edwin Diaz", "player_name": "Edwin Diaz",
"card_type": "rp", "track": {"card_type": "rp"},
"current_tier": 1, "current_tier": 1,
"formula_value": 45, "current_value": 45,
"next_threshold": 60, "next_threshold": 60,
} }
result = format_refractor_entry(rp_state) result = format_refractor_entry(rp_state)
assert "IP+K" in result, ( assert "Edwin Diaz" in result
f"Relief pitcher card should show 'IP+K' formula label, got: {result!r}" assert "45/60" in result
)
# ---------------------------------------------------------------------------
# T3-5: Unknown card_type fallback
# ---------------------------------------------------------------------------
class TestUnknownCardTypeFallback:
"""
T3-5: format_refractor_entry should use the raw card_type string as the
formula label when the type is not in FORMULA_LABELS, rather than crashing.
Why: FORMULA_LABELS only covers "batter", "sp", and "rp". If the API
introduces a new card type (e.g. "util" for utility players) before the
bot is updated, FORMULA_LABELS.get(card_type, card_type) will fall back to
the raw string. This test ensures that fallback path produces readable
output rather than an error, and explicitly documents what to expect.
"""
def test_unknown_card_type_uses_raw_string_as_label(self):
"""
card_type="util" is not in FORMULA_LABELS. The output should include
"util" as the formula label (the raw fallback) and must not raise.
"""
util_state = {
"player_name": "Ben Zobrist",
"card_type": "util",
"current_tier": 2,
"formula_value": 80,
"next_threshold": 120,
}
result = format_refractor_entry(util_state)
assert "util" in result, (
f"Unknown card_type should appear verbatim as the formula label, got: {result!r}"
)
def test_unknown_card_type_does_not_crash(self): def test_unknown_card_type_does_not_crash(self):
""" """Unknown card_type produces a valid two-line string."""
Any unknown card_type must produce a valid two-line string without
raising an exception.
"""
state = { state = {
"player_name": "Test Player", "player_name": "Test Player",
"card_type": "dh", "track": {"card_type": "dh"},
"current_tier": 1, "current_tier": 1,
"formula_value": 30, "current_value": 30,
"next_threshold": 50, "next_threshold": 50,
} }
result = format_refractor_entry(state) result = format_refractor_entry(state)
@ -794,7 +738,10 @@ async def test_refractor_status_empty_roster(mock_bot, mock_interaction):
team = {"id": 1, "sname": "Test"} team = {"id": 1, "sname": "Test"}
with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)): with patch("cogs.refractor.get_team_by_owner", new=AsyncMock(return_value=team)):
with patch("cogs.refractor.db_get", new=AsyncMock(return_value={"cards": []})): with patch(
"cogs.refractor.db_get",
new=AsyncMock(return_value={"items": [], "count": 0}),
):
await cog.refractor_status.callback(cog, mock_interaction) await cog.refractor_status.callback(cog, mock_interaction)
call_kwargs = mock_interaction.edit_original_response.call_args call_kwargs = mock_interaction.edit_original_response.call_args

View File

@ -3,7 +3,7 @@ Tests for Refractor 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 note field. 2. Tier 4 (Superfractor) embeds include the special title, description, and color.
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.
@ -143,36 +143,11 @@ class TestBuildTierUpEmbedSuperfractor:
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_note_field_present(self): def test_no_extra_fields(self):
"""Tier 4 must include a note field about future rating boosts.""" """Tier 4 embed should have no extra fields — boosts are live, no teaser needed."""
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)
field_names = [f.name for f in embed.fields] assert len(embed.fields) == 0
assert any(
"rating" in name.lower()
or "boost" in name.lower()
or "note" in name.lower()
for name in field_names
), "Expected a field mentioning rating boosts for tier 4 embed"
def test_note_field_value_mentions_future_update(self):
"""The note field value must reference the future rating boost update."""
tier_up = make_tier_up(old_tier=3, new_tier=4)
embed = build_tier_up_embed(tier_up)
note_field = next(
(
f
for f in embed.fields
if "rating" in f.name.lower()
or "boost" in f.name.lower()
or "note" in f.name.lower()
),
None,
)
assert note_field is not None
assert (
"future" in note_field.value.lower() or "update" in note_field.value.lower()
)
def test_footer_text_is_paper_dynasty_refractor(self): def test_footer_text_is_paper_dynasty_refractor(self):
"""Footer must remain 'Paper Dynasty Refractor' for tier 4 as well.""" """Footer must remain 'Paper Dynasty Refractor' for tier 4 as well."""

View File

@ -0,0 +1,257 @@
"""
Tests for the post-game Refractor Progress embed field (#147).
Covers _build_refractor_progress_text() which formats tier-ups and
near-threshold cards into the summary embed field value, and the updated
_run_post_game_refractor_hook() return value.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from command_logic.logic_gameplay import (
_build_refractor_progress_text,
_run_post_game_refractor_hook,
)
# ---------------------------------------------------------------------------
# _build_refractor_progress_text
# ---------------------------------------------------------------------------
class TestBuildRefractorProgressText:
"""_build_refractor_progress_text formats tier-ups and close cards."""
async def test_returns_none_when_no_tier_ups_and_no_close_cards(self):
"""Returns None when evaluate-game had no tier-ups and refractor/cards returns empty.
Caller uses None to skip adding the field to the embed entirely.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {"items": [], "count": 0}
result = await _build_refractor_progress_text(
evo_result={"tier_ups": []},
winning_team_id=1,
losing_team_id=2,
)
assert result is None
async def test_returns_none_when_evo_result_is_none(self):
"""Returns None gracefully when the hook returned None (e.g. on API failure).
Near-threshold fetch still runs; returns None when that also yields nothing.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is None
async def test_tier_up_shows_player_name_and_tier_name(self):
"""Tier-ups are formatted as '⬆ **Name** → Tier Name'.
The tier name comes from TIER_NAMES (e.g. new_tier=1 'Base Chrome').
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{
"player_id": 10,
"player_name": "Mike Trout",
"new_tier": 1,
}
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "" in result
assert "Mike Trout" in result
assert "Base Chrome" in result
async def test_multiple_tier_ups_each_on_own_line(self):
"""Each tier-up gets its own line in the output."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1},
{
"player_id": 11,
"player_name": "Shohei Ohtani",
"new_tier": 2,
},
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "Mike Trout" in result
assert "Shohei Ohtani" in result
assert "Base Chrome" in result
assert "Refractor" in result
async def test_near_threshold_card_shows_percentage(self):
"""Near-threshold cards appear as '◈ Name (pct%)'.
The percentage is current_value / next_threshold rounded to nearest integer.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {
"items": [
{
"player_name": "Sandy Koufax",
"current_value": 120,
"next_threshold": 149,
}
],
"count": 1,
}
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "" in result
assert "Sandy Koufax" in result
assert "81%" in result # 120/149 = ~80.5% → 81%
async def test_near_threshold_fetch_queried_for_both_teams(self):
"""refractor/cards is called once per team with progress=close."""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
await _build_refractor_progress_text(
evo_result=None,
winning_team_id=3,
losing_team_id=7,
)
team_ids_queried = []
for call in mock_get.call_args_list:
params = dict(call.kwargs.get("params", []))
if "team_id" in params:
team_ids_queried.append(params["team_id"])
assert 3 in team_ids_queried
assert 7 in team_ids_queried
async def test_near_threshold_api_failure_is_non_fatal(self):
"""An exception during the near-threshold fetch does not propagate.
Tier-ups are still shown; close cards silently dropped.
"""
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.side_effect = RuntimeError("API down")
result = await _build_refractor_progress_text(
evo_result={
"tier_ups": [
{"player_id": 10, "player_name": "Mike Trout", "new_tier": 1}
]
},
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert "Mike Trout" in result
async def test_close_cards_capped_at_five(self):
"""At most 5 near-threshold entries are included across both teams."""
many_cards = [
{"player_name": f"Player {i}", "current_value": 90, "next_threshold": 100}
for i in range(10)
]
with patch(
"command_logic.logic_gameplay.db_get", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = {"items": many_cards, "count": len(many_cards)}
result = await _build_refractor_progress_text(
evo_result=None,
winning_team_id=1,
losing_team_id=2,
)
assert result is not None
assert result.count("") <= 5
# ---------------------------------------------------------------------------
# _run_post_game_refractor_hook return value
# ---------------------------------------------------------------------------
class TestRefractorHookReturnValue:
"""_run_post_game_refractor_hook returns evo_result on success, None on failure."""
async def test_returns_evo_result_when_successful(self):
"""The evaluate-game response dict is returned so complete_game can use it."""
evo_response = {
"tier_ups": [{"player_id": 1, "player_name": "Babe Ruth", "new_tier": 2}]
}
def _side_effect(url, *args, **kwargs):
if url.startswith("season-stats"):
return None
return evo_response
with (
patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post,
patch(
"command_logic.logic_gameplay._trigger_variant_renders",
new_callable=AsyncMock,
return_value={},
),
patch(
"command_logic.logic_gameplay.notify_tier_completion",
new_callable=AsyncMock,
),
):
mock_post.side_effect = _side_effect
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result == evo_response
async def test_returns_none_on_exception(self):
"""Hook returns None when an exception occurs (game result is unaffected)."""
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.side_effect = RuntimeError("db unreachable")
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result is None
async def test_returns_evo_result_when_no_tier_ups(self):
"""Returns the full evo_result even when tier_ups is empty or absent."""
evo_response = {"tier_ups": []}
with patch(
"command_logic.logic_gameplay.db_post", new_callable=AsyncMock
) as mock_post:
mock_post.return_value = evo_response
result = await _run_post_game_refractor_hook(42, MagicMock())
assert result == evo_response