Compare commits

..

13 Commits

Author SHA1 Message Date
Cal Corum
9b5ded1e1e fix: standardize home-run detection to use batter_final only (#22)
Set batter_final from batter_to_base before the HR check so that
_on_final fields are the single source of truth. Removes the
two-path `batter_final == 4 or batter_to_base == 4` condition and
the associated TODO comment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 01:33:09 -06:00
Cal Corum
da55cbe4d4 feat: limit scouting to Standard/Premium packs, simplify scout view
All checks were successful
Build Docker Image / build (push) Successful in 1m17s
- Add SCOUTABLE_PACK_TYPES env var (default: Standard,Premium) to control
  which pack types offer scout opportunities
- Unify embed construction into build_scout_embed() — removes 3 near-duplicate
  embed builders across scout_view.py and scouting.py
- Replace manual total_scouts counter with derived property from claims dict
- Remove redundant db_get("current") API call per scout click — use PD_SEASON
- Remove duplicate expiry computation in create_scout_opportunity
- Move send_to_channel to top-level import, remove redundant local import
- Update tests to match simplified code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:12:46 -06:00
Cal Corum
e160be4137 fix: add missing pack, description, image fields to scouting test fixtures
All checks were successful
Build Docker Image / build (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:47:52 -06:00
Cal Corum
8e605c2140 fix: add pack_id to scouted card creation, enhance embed with card links
All checks were successful
Build Docker Image / build (push) Successful in 1m16s
- Include pack_id in db_post("cards") payload (API requires it)
- Player names now link to card image URLs in scout embed
- Display format: "🟡 All-Star — [2023 Mike Trout](card_image_url)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:22:57 -06:00
Cal Corum
77c3f3004c fix: align scouting rarity symbols with system colors
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:03:15 -06:00
Cal Corum
ed00a97c0d fix: update owner_only to use Cal's correct Discord ID
All checks were successful
Build Docker Image / build (push) Successful in 1m22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:57:25 -06:00
cal
0aafc3fa46 Merge pull request 'feat: Scouting feature (Wonder Pick-style social pack opening)' (#50) from feature/scouting into next-release
All checks were successful
Build Docker Image / build (push) Successful in 2m51s
Reviewed-on: #50
2026-03-05 03:17:07 +00:00
cal
0ce0707e3e Update .gitea/workflows/docker-build.yml 2026-03-05 03:16:36 +00:00
cal
75b9968149 Update .gitea/workflows/docker-build.yml 2026-03-05 03:15:37 +00:00
cal
89f80727bd Update .gitea/workflows/docker-build.yml 2026-03-05 03:12:20 +00:00
Cal Corum
d569e91905 fix: Address PR review findings — two bugs and cleanup
- Fix int_timestamp() no-arg path returning seconds instead of
  milliseconds, which would silently break the daily scout token cap
  against the real API
- Acknowledge double-click interactions with ephemeral message instead
  of silently returning (Discord requires all interactions to be acked)
- Reorder scout flow: create card copy before consuming token so a
  failure doesn't cost the player a token for nothing
- Move build_scouted_card_list import to top of scout_view.py
- Remove unused asyncio import from helpers/scouting.py
- Fix footer text inconsistency ("One scout per player" everywhere)
- Update tests for new operation order and double-click behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:04:53 +00:00
Cal Corum
d538c679c3 refactor: Consolidate scouting utilities, add test suite, use Discord timestamps
- Consolidate SCOUT_TOKENS_PER_DAY and get_scout_tokens_used() into
  helpers/scouting.py (was duplicated across 3 files)
- Add midnight_timestamp() utility to helpers/utils.py
- Remove _build_scouted_ids() wrapper, use self.claims directly
- Fix build_scout_embed return type annotation
- Use Discord <t:UNIX:R> relative timestamps for scout window countdown
- Add 66-test suite covering helpers, ScoutView, and cog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:04:53 +00:00
Cal Corum
14103e48b7 feat: Add Scouting feature (Wonder Pick-style social pack opening)
When a player opens a pack, a scout opportunity is posted to #pack-openings
with face-down card buttons. Other players can blind-pick one card using
daily scout tokens (2/day), receiving a copy. The opener keeps all cards.

New files:
- discord_ui/scout_view.py: ScoutView with dynamic buttons and claim logic
- helpers/scouting.py: create_scout_opportunity() and embed builder
- cogs/economy_new/scouting.py: /scout-tokens command and cleanup task

Modified:
- helpers/main.py: Hook into open_st_pr_packs() after display_cards()
- paperdynasty.py: Register scouting cog

Requires new API endpoints in paper-dynasty-database (scout_opportunities).
Tracks #44.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 03:04:53 +00:00
14 changed files with 2628 additions and 147 deletions

View File

@ -12,6 +12,7 @@ on:
push:
branches:
- main
- next-release
pull_request:
branches:
- main
@ -39,35 +40,25 @@ jobs:
id: calver
uses: cal/gitea-actions/calver@main
# Dev build: push with dev + dev-SHA tags (PR/feature branches)
- name: Build Docker image (dev)
if: github.ref != 'refs/heads/main'
uses: https://github.com/docker/build-push-action@v5
- name: Resolve Docker tags
id: tags
uses: cal/gitea-actions/docker-tags@main
with:
context: .
push: true
tags: |
manticorum67/paper-dynasty-discordapp:dev
manticorum67/paper-dynasty-discordapp:dev-${{ steps.calver.outputs.sha_short }}
cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache
cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max
image: manticorum67/paper-dynasty-discordapp
version: ${{ steps.calver.outputs.version }}
sha_short: ${{ steps.calver.outputs.sha_short }}
# Production build: push with latest + CalVer tags (main only)
- name: Build Docker image (production)
if: github.ref == 'refs/heads/main'
- name: Build and push Docker image
uses: https://github.com/docker/build-push-action@v5
with:
context: .
push: true
tags: |
manticorum67/paper-dynasty-discordapp:latest
manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }}
manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }}
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache
cache-to: type=registry,ref=manticorum67/paper-dynasty-discordapp:buildcache,mode=max
- name: Tag release
if: success() && github.ref == 'refs/heads/main'
if: success() && steps.tags.outputs.channel == 'stable'
uses: cal/gitea-actions/gitea-tag@main
with:
version: ${{ steps.calver.outputs.version }}
@ -77,26 +68,23 @@ jobs:
run: |
echo "## Docker Build Successful" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Channel:** \`${{ steps.tags.outputs.channel }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
echo "- \`manticorum67/paper-dynasty-discordapp:${{ steps.calver.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY
IFS=',' read -ra TAG_ARRAY <<< "${{ steps.tags.outputs.tags }}"
for tag in "${TAG_ARRAY[@]}"; do
echo "- \`${tag}\`" >> $GITHUB_STEP_SUMMARY
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
echo "- Branch: \`${{ steps.calver.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Timestamp: \`${{ steps.calver.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "Pushed to Docker Hub!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:latest\`" >> $GITHUB_STEP_SUMMARY
else
echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY
fi
echo "Pull with: \`docker pull manticorum67/paper-dynasty-discordapp:${{ steps.tags.outputs.primary_tag }}\`" >> $GITHUB_STEP_SUMMARY
- name: Discord Notification - Success
if: success() && github.ref == 'refs/heads/main'
if: success() && steps.tags.outputs.channel != 'dev'
uses: cal/gitea-actions/discord-notify@main
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
@ -108,7 +96,7 @@ jobs:
timestamp: ${{ steps.calver.outputs.timestamp }}
- name: Discord Notification - Failure
if: failure() && github.ref == 'refs/heads/main'
if: failure() && steps.tags.outputs.channel != 'dev'
uses: cal/gitea-actions/discord-notify@main
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}

View File

@ -0,0 +1,90 @@
"""
Scouting Cog Scout token management and expired opportunity cleanup.
"""
import datetime
import logging
import discord
from discord import app_commands
from discord.ext import commands, tasks
from api_calls import db_get
from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used
from helpers.utils import int_timestamp
from helpers.discord_utils import get_team_embed
from helpers.main import get_team_by_owner
from helpers.constants import PD_SEASON, IMAGES
logger = logging.getLogger("discord_app")
class Scouting(commands.Cog):
"""Scout token tracking and expired opportunity cleanup."""
def __init__(self, bot):
self.bot = bot
self.cleanup_expired.start()
async def cog_unload(self):
self.cleanup_expired.cancel()
@app_commands.command(
name="scout-tokens",
description="Check how many scout tokens you have left today",
)
async def scout_tokens_command(self, interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
team = await get_team_by_owner(interaction.user.id)
if not team:
await interaction.followup.send(
"You need a Paper Dynasty team first!",
ephemeral=True,
)
return
tokens_used = await get_scout_tokens_used(team["id"])
tokens_remaining = max(0, SCOUT_TOKENS_PER_DAY - tokens_used)
embed = get_team_embed(title="Scout Tokens", team=team)
embed.description = (
f"**{tokens_remaining}** of **{SCOUT_TOKENS_PER_DAY}** tokens remaining today.\n\n"
f"Tokens reset at midnight Central."
)
if tokens_remaining == 0:
embed.description += "\n\nYou've used all your tokens! Check back tomorrow."
await interaction.followup.send(embed=embed, ephemeral=True)
@tasks.loop(minutes=15)
async def cleanup_expired(self):
"""Log expired unclaimed scout opportunities.
This is a safety net the ScoutView's on_timeout handles the UI side.
If the bot restarted mid-scout, those views are lost; this just logs it.
"""
try:
now = int_timestamp(datetime.datetime.now())
expired = await db_get(
"scout_opportunities",
params=[
("claimed", False),
("expired_before", now),
],
)
if expired and expired.get("count", 0) > 0:
logger.info(
f"Found {expired['count']} expired unclaimed scout opportunities"
)
except Exception as e:
logger.debug(f"Scout cleanup check failed (API may not be ready): {e}")
@cleanup_expired.before_loop
async def before_cleanup(self):
await self.bot.wait_until_ready()
async def setup(bot):
await bot.add_cog(Scouting(bot))

View File

@ -7,17 +7,34 @@ This package contains all Discord UI classes and components used throughout the
from .confirmations import Question, Confirm, ButtonOptions
from .pagination import Pagination
from .selectors import (
SelectChoicePackTeam, SelectOpenPack, SelectPaperdexCardset,
SelectPaperdexTeam, SelectBuyPacksCardset, SelectBuyPacksTeam,
SelectUpdatePlayerTeam, SelectView
SelectChoicePackTeam,
SelectOpenPack,
SelectPaperdexCardset,
SelectPaperdexTeam,
SelectBuyPacksCardset,
SelectBuyPacksTeam,
SelectUpdatePlayerTeam,
SelectView,
)
from .dropdowns import Dropdown, DropdownView
# ScoutView intentionally NOT imported here to avoid circular import:
# helpers.main → discord_ui → scout_view → helpers.main
# Import directly: from discord_ui.scout_view import ScoutView
__all__ = [
'Question', 'Confirm', 'ButtonOptions',
'Pagination',
'SelectChoicePackTeam', 'SelectOpenPack', 'SelectPaperdexCardset',
'SelectPaperdexTeam', 'SelectBuyPacksCardset', 'SelectBuyPacksTeam',
'SelectUpdatePlayerTeam', 'SelectView',
'Dropdown', 'DropdownView'
]
"Question",
"Confirm",
"ButtonOptions",
"Pagination",
"SelectChoicePackTeam",
"SelectOpenPack",
"SelectPaperdexCardset",
"SelectPaperdexTeam",
"SelectBuyPacksCardset",
"SelectBuyPacksTeam",
"SelectUpdatePlayerTeam",
"SelectView",
"Dropdown",
"DropdownView",
]

272
discord_ui/scout_view.py Normal file
View File

@ -0,0 +1,272 @@
"""
Scout View Face-down card button UI for the Scouting feature.
When a player opens a pack, a ScoutView is posted with one button per card.
Other players can click a button to "scout" (blind-pick) one card, receiving
a copy. The opener keeps all their cards. Multiple players can scout the same
card each gets their own copy.
"""
import logging
import discord
from api_calls import db_post
from helpers.main import get_team_by_owner, get_card_embeds
from helpers.scouting import (
SCOUT_TOKENS_PER_DAY,
build_scout_embed,
get_scout_tokens_used,
)
from helpers.utils import int_timestamp
from helpers.discord_utils import get_team_embed, send_to_channel
from helpers.constants import IMAGES, PD_SEASON
logger = logging.getLogger("discord_app")
class ScoutView(discord.ui.View):
"""Displays face-down card buttons for a scout opportunity.
- One button per card, labeled "Card 1" ... "Card N"
- Any player EXCEPT the pack opener can interact
- Any card can be scouted multiple times by different players
- One scout per player per pack
- Timeout: 30 minutes
"""
def __init__(
self,
scout_opp_id: int,
cards: list[dict],
opener_team: dict,
opener_user_id: int,
bot,
expires_unix: int = None,
):
super().__init__(timeout=1800.0)
self.scout_opp_id = scout_opp_id
self.cards = cards
self.opener_team = opener_team
self.opener_user_id = opener_user_id
self.bot = bot
self.expires_unix = expires_unix
self.message: discord.Message | None = None
self.card_lines: list[tuple[int, str]] = []
# Per-card claim tracking: player_id -> list of scouter team names
self.claims: dict[int, list[str]] = {}
# Per-user lock: user IDs who have already scouted this pack
self.scouted_users: set[int] = set()
# Users currently being processed (prevent double-click race)
self.processing_users: set[int] = set()
for i, card in enumerate(cards):
button = ScoutButton(
card=card,
position=i,
scout_view=self,
)
self.add_item(button)
@property
def total_scouts(self) -> int:
return sum(len(v) for v in self.claims.values())
async def update_message(self):
"""Refresh the embed with current claim state."""
if not self.message:
return
embed, _ = build_scout_embed(
self.opener_team,
card_lines=self.card_lines,
expires_unix=self.expires_unix,
claims=self.claims,
total_scouts=self.total_scouts,
)
try:
await self.message.edit(embed=embed, view=self)
except Exception as e:
logger.error(f"Failed to update scout message: {e}")
async def on_timeout(self):
"""Disable all buttons and update the embed when the window expires."""
for item in self.children:
item.disabled = True
if self.message:
try:
embed, _ = build_scout_embed(
self.opener_team,
card_lines=self.card_lines,
claims=self.claims,
total_scouts=self.total_scouts,
closed=True,
)
await self.message.edit(embed=embed, view=self)
except Exception as e:
logger.error(f"Failed to edit expired scout message: {e}")
class ScoutButton(discord.ui.Button):
"""A single face-down card button in a ScoutView."""
def __init__(self, card: dict, position: int, scout_view: ScoutView):
super().__init__(
label=f"Card {position + 1}",
style=discord.ButtonStyle.secondary,
row=0,
)
self.card = card
self.position = position
self.scout_view: ScoutView = scout_view
async def callback(self, interaction: discord.Interaction):
view = self.scout_view
# Block the opener
if interaction.user.id == view.opener_user_id:
await interaction.response.send_message(
"You can't scout your own pack!",
ephemeral=True,
)
return
# One scout per player per pack
if interaction.user.id in view.scouted_users:
await interaction.response.send_message(
"You already scouted a card from this pack!",
ephemeral=True,
)
return
# Prevent double-click race for same user
if interaction.user.id in view.processing_users:
await interaction.response.send_message(
"Your scout is already being processed!",
ephemeral=True,
)
return
view.processing_users.add(interaction.user.id)
await interaction.response.defer(ephemeral=True)
try:
# Get scouting player's team
scouter_team = await get_team_by_owner(interaction.user.id)
if not scouter_team:
await interaction.followup.send(
"You need a Paper Dynasty team to scout! Ask an admin to set one up.",
ephemeral=True,
)
return
# Check scout token balance
tokens_used = await get_scout_tokens_used(scouter_team["id"])
if tokens_used >= SCOUT_TOKENS_PER_DAY:
await interaction.followup.send(
"You're out of scout tokens for today! You get 2 per day, resetting at midnight Central.",
ephemeral=True,
)
return
# Record the claim in the database
try:
await db_post(
"scout_claims",
payload={
"scout_opportunity_id": view.scout_opp_id,
"card_id": self.card["id"],
"claimed_by_team_id": scouter_team["id"],
},
)
except Exception as e:
logger.error(f"Failed to record scout claim: {e}")
await interaction.followup.send(
"Something went wrong claiming this scout. Try again!",
ephemeral=True,
)
return
# Create a copy of the card for the scouter (before consuming token
# so a failure here doesn't cost the player a token for nothing)
await db_post(
"cards",
payload={
"cards": [
{
"player_id": self.card["player"]["player_id"],
"team_id": scouter_team["id"],
"pack_id": self.card["pack"]["id"],
}
],
},
)
# Consume a scout token
await db_post(
"rewards",
payload={
"name": "Scout Token",
"team_id": scouter_team["id"],
"season": PD_SEASON,
"created": int_timestamp(),
},
)
# Track the claim
player_id = self.card["player"]["player_id"]
if player_id not in view.claims:
view.claims[player_id] = []
view.claims[player_id].append(scouter_team["lname"])
view.scouted_users.add(interaction.user.id)
# Update the shared embed
await view.update_message()
# Send the scouter their card details (ephemeral)
player_name = self.card["player"]["p_name"]
rarity_name = self.card["player"]["rarity"]["name"]
card_for_embed = {
"player": self.card["player"],
"team": scouter_team,
}
card_embeds = await get_card_embeds(card_for_embed)
await interaction.followup.send(
content=f"You scouted a **{rarity_name}** {player_name}!",
embeds=card_embeds,
ephemeral=True,
)
# Notify for shiny scouts (rarity >= 5)
if self.card["player"]["rarity"]["value"] >= 5:
try:
notif_embed = get_team_embed(title="Rare Scout!", team=scouter_team)
notif_embed.description = (
f"**{scouter_team['lname']}** scouted a "
f"**{rarity_name}** {player_name}!"
)
notif_embed.set_thumbnail(
url=self.card["player"].get("headshot", IMAGES["logo"])
)
await send_to_channel(
view.bot, "pd-network-news", embed=notif_embed
)
except Exception as e:
logger.error(f"Failed to send shiny scout notification: {e}")
except Exception as e:
logger.error(f"Unexpected error in scout callback: {e}", exc_info=True)
try:
await interaction.followup.send(
"Something went wrong. Please try again.",
ephemeral=True,
)
except Exception:
pass
finally:
view.processing_users.discard(interaction.user.id)

View File

@ -6,7 +6,7 @@ The package is organized into logical modules for better maintainability.
Modules:
- constants: Application constants and configuration
- utils: General utility functions
- utils: General utility functions
- random_content: Random content generators
- search_utils: Search and fuzzy matching functionality
- discord_utils: Discord helper functions
@ -21,9 +21,10 @@ Modules:
# This allows existing code to continue working during the migration
from helpers.main import *
# Import from migrated modules
# Import from migrated modules
from .constants import *
from .utils import *
from .random_content import *
from .search_utils import *
from .discord_utils import *
from .search_utils import *
from .discord_utils import *
from .scouting import *

View File

@ -8,7 +8,7 @@ import traceback
import discord
import pygsheets
import requests
import aiohttp
from discord.ext import commands
from api_calls import *
@ -43,17 +43,21 @@ async def get_player_photo(player):
)
try:
resp = requests.get(req_url, timeout=0.5)
except Exception as e:
async with aiohttp.ClientSession() as session:
async with session.get(
req_url, timeout=aiohttp.ClientTimeout(total=0.5)
) as resp:
if resp.status == 200:
data = await resp.json()
if data["player"] and data["player"][0]["strSport"] == "Baseball":
await db_patch(
"players",
object_id=player["player_id"],
params=[("headshot", data["player"][0]["strThumb"])],
)
return data["player"][0]["strThumb"]
except Exception:
return None
if resp.status_code == 200 and resp.json()["player"]:
if resp.json()["player"][0]["strSport"] == "Baseball":
await db_patch(
"players",
object_id=player["player_id"],
params=[("headshot", resp.json()["player"][0]["strThumb"])],
)
return resp.json()["player"][0]["strThumb"]
return None
@ -1681,9 +1685,9 @@ async def paperdex_team_embed(team: dict, mlb_team: dict) -> list[discord.Embed]
for cardset_id in coll_data:
if cardset_id != "total_owned":
if coll_data[cardset_id]["players"]:
coll_data[cardset_id]["embeds"][0].description = (
f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}"
)
coll_data[cardset_id]["embeds"][
0
].description = f"{mlb_team['lname']} / {coll_data[cardset_id]['name']}"
coll_data[cardset_id]["embeds"][0].add_field(
name="# Collected / # Total Cards",
value=f"{coll_data[cardset_id]['owned']} / {len(coll_data[cardset_id]['players'])}",
@ -1749,6 +1753,8 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
all_cards = []
for p_id in pack_ids:
new_cards = await db_get("cards", params=[("pack_id", p_id)])
for card in new_cards["cards"]:
card.setdefault("pack_id", p_id)
all_cards.extend(new_cards["cards"])
if not all_cards:
@ -1764,6 +1770,20 @@ async def open_st_pr_packs(all_packs: list, team: dict, context):
await context.channel.send(content=f"Let's head down to {pack_channel.mention}!")
await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover)
# Create scout opportunities for each pack (Standard/Premium only)
from helpers.scouting import create_scout_opportunity, SCOUTABLE_PACK_TYPES
pack_type_name = all_packs[0].get("pack_type", {}).get("name")
if pack_type_name in SCOUTABLE_PACK_TYPES:
for p_id in pack_ids:
pack_cards = [c for c in all_cards if c.get("pack_id") == p_id]
if pack_cards:
await create_scout_opportunity(
pack_cards, team, pack_channel, author, context
)
if len(pack_ids) > 1:
await asyncio.sleep(2)
async def get_choice_from_cards(
interaction: discord.Interaction,

243
helpers/scouting.py Normal file
View File

@ -0,0 +1,243 @@
"""
Scouting Helper Functions
Handles creation of scout opportunities after pack openings
and embed formatting for the scouting feature.
"""
import datetime
import logging
import os
import random
import discord
from api_calls import db_get, db_post
from helpers.utils import int_timestamp, midnight_timestamp
from helpers.discord_utils import get_team_embed
from helpers.constants import IMAGES, PD_SEASON
logger = logging.getLogger("discord_app")
SCOUT_TOKENS_PER_DAY = 2
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium")
SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()}
# Rarity value → display symbol
RARITY_SYMBOLS = {
8: "\U0001f7e3", # HoF — purple (#751cea)
5: "\U0001f535", # MVP — cyan/blue (#56f1fa)
3: "\U0001f7e1", # All-Star — gold (#FFD700)
2: "\u26aa", # Starter — silver (#C0C0C0)
1: "\U0001f7e4", # Reserve — bronze (#CD7F32)
0: "\u26ab", # Replacement — dark gray (#454545)
}
async def get_scout_tokens_used(team_id: int) -> int:
"""Return how many scout tokens a team has used today."""
used_today = await db_get(
"rewards",
params=[
("name", "Scout Token"),
("team_id", team_id),
("created_after", midnight_timestamp()),
],
)
return used_today["count"] if used_today else 0
def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]:
"""Build a shuffled list of (player_id, display_line) tuples."""
lines = []
for card in cards:
player = card["player"]
rarity_val = player["rarity"]["value"]
symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab")
desc = player.get("description", "")
image_url = player.get("image", "")
name_display = (
f"[{desc} {player['p_name']}]({image_url})"
if image_url
else f"{desc} {player['p_name']}"
)
lines.append(
(
player["player_id"],
f"{symbol} {player['rarity']['name']}{name_display}",
)
)
random.shuffle(lines)
return lines
def build_scout_embed(
opener_team: dict,
cards: list[dict] = None,
card_lines: list[tuple[int, str]] = None,
expires_unix: int = None,
claims: dict[int, list[str]] = None,
total_scouts: int = 0,
closed: bool = False,
) -> tuple[discord.Embed, list[tuple[int, str]]]:
"""Build the embed shown above the scout buttons.
Shows a shuffled list of cards (rarity + player name) so scouters
know what's in the pack but not which button maps to which card.
Returns (embed, card_lines) so the view can store the shuffled order.
Parameters
----------
closed : if True, renders the "Scout Window Closed" variant
claims : scouted card tracking dict for build_scouted_card_list
total_scouts : number of scouts so far (for title display)
"""
if card_lines is None and cards is not None:
card_lines = _build_card_lines(cards)
if claims and card_lines:
card_list = build_scouted_card_list(card_lines, claims)
elif card_lines:
card_list = "\n".join(line for _, line in card_lines)
else:
card_list = ""
if closed:
if total_scouts > 0:
title = f"Scout Window Closed ({total_scouts} scouted)"
else:
title = "Scout Window Closed"
elif total_scouts > 0:
title = f"Scout Opportunity! ({total_scouts} scouted)"
else:
title = "Scout Opportunity!"
embed = get_team_embed(title=title, team=opener_team)
if closed:
embed.description = f"**{opener_team['lname']}**'s pack\n\n" f"{card_list}"
embed.set_footer(
text=f"Paper Dynasty Season {PD_SEASON}",
icon_url=IMAGES["logo"],
)
else:
if expires_unix:
time_line = f"Scout window closes <t:{expires_unix}:R>."
else:
time_line = "Scout window closes in **30 minutes**."
embed.description = (
f"**{opener_team['lname']}**'s pack\n\n"
f"{card_list}\n\n"
f"Pick a card — but which is which?\n"
f"Costs 1 Scout Token (2 per day, resets at midnight Central).\n"
f"{time_line}"
)
embed.set_footer(
text=f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player",
icon_url=IMAGES["logo"],
)
return embed, card_lines
def build_scouted_card_list(
card_lines: list[tuple[int, str]],
scouted_cards: dict[int, list[str]],
) -> str:
"""Rebuild the card list marking scouted cards with scouter team names.
Parameters
----------
card_lines : shuffled list of (player_id, display_line) tuples
scouted_cards : {player_id: [team_name, ...]} for each claimed card
"""
result = []
for player_id, line in card_lines:
teams = scouted_cards.get(player_id)
if teams:
count = len(teams)
names = ", ".join(f"*{t}*" for t in teams)
if count == 1:
result.append(f"{line} \u2014 \u2714\ufe0f {names}")
else:
result.append(f"{line} \u2014 \u2714\ufe0f x{count} ({names})")
else:
result.append(line)
return "\n".join(result)
async def create_scout_opportunity(
pack_cards: list[dict],
opener_team: dict,
channel: discord.TextChannel,
opener_user,
context,
) -> None:
"""Create a scout opportunity and post the ScoutView to the channel.
Called after display_cards() completes in open_st_pr_packs().
Wrapped in try/except so scouting failures never crash pack opening.
Parameters
----------
pack_cards : list of card dicts from a single pack
opener_team : team dict for the pack opener
channel : the #pack-openings channel
opener_user : discord.Member or discord.User who opened the pack
context : the command context (Context or Interaction), used to get bot
"""
from discord_ui.scout_view import ScoutView
# Only create scout opportunities in the pack-openings channel
if not channel or channel.name != "pack-openings":
return
if not pack_cards:
return
now = datetime.datetime.now()
expires_dt = now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)
expires_at = int_timestamp(expires_dt)
expires_unix = int(expires_dt.timestamp())
created = int_timestamp(now)
card_ids = [c["id"] for c in pack_cards]
try:
scout_opp = await db_post(
"scout_opportunities",
payload={
"pack_id": pack_cards[0].get("pack_id"),
"opener_team_id": opener_team["id"],
"card_ids": card_ids,
"expires_at": expires_at,
"created": created,
},
)
except Exception as e:
logger.error(f"Failed to create scout opportunity: {e}")
return
embed, card_lines = build_scout_embed(
opener_team, pack_cards, expires_unix=expires_unix
)
# Get bot reference from context
bot = getattr(context, "bot", None) or getattr(context, "client", None)
view = ScoutView(
scout_opp_id=scout_opp["id"],
cards=pack_cards,
opener_team=opener_team,
opener_user_id=opener_user.id,
bot=bot,
expires_unix=expires_unix,
)
view.card_lines = card_lines
try:
msg = await channel.send(embed=embed, view=view)
view.message = msg
except Exception as e:
logger.error(f"Failed to post scout opportunity message: {e}")

View File

@ -4,60 +4,71 @@ General Utilities
This module contains standalone utility functions with minimal dependencies,
including timestamp conversion, position abbreviations, and simple helpers.
"""
import datetime
from typing import Optional
import discord
def int_timestamp(datetime_obj: Optional[datetime.datetime] = None):
"""Convert current datetime to integer timestamp."""
if datetime_obj:
return int(datetime.datetime.timestamp(datetime_obj) * 1000)
return int(datetime.datetime.now().timestamp())
"""Convert a datetime to an integer millisecond timestamp.
If no argument is given, uses the current time.
"""
if datetime_obj is None:
datetime_obj = datetime.datetime.now()
return int(datetime.datetime.timestamp(datetime_obj) * 1000)
def midnight_timestamp() -> int:
"""Return today's midnight (00:00:00) as an integer millisecond timestamp."""
now = datetime.datetime.now()
midnight = datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
return int_timestamp(midnight)
def get_pos_abbrev(field_pos: str) -> str:
"""Convert position name to standard abbreviation."""
if field_pos.lower() == 'catcher':
return 'C'
elif field_pos.lower() == 'first baseman':
return '1B'
elif field_pos.lower() == 'second baseman':
return '2B'
elif field_pos.lower() == 'third baseman':
return '3B'
elif field_pos.lower() == 'shortstop':
return 'SS'
elif field_pos.lower() == 'left fielder':
return 'LF'
elif field_pos.lower() == 'center fielder':
return 'CF'
elif field_pos.lower() == 'right fielder':
return 'RF'
if field_pos.lower() == "catcher":
return "C"
elif field_pos.lower() == "first baseman":
return "1B"
elif field_pos.lower() == "second baseman":
return "2B"
elif field_pos.lower() == "third baseman":
return "3B"
elif field_pos.lower() == "shortstop":
return "SS"
elif field_pos.lower() == "left fielder":
return "LF"
elif field_pos.lower() == "center fielder":
return "CF"
elif field_pos.lower() == "right fielder":
return "RF"
else:
return 'P'
return "P"
def position_name_to_abbrev(position_name):
"""Convert position name to abbreviation (alternate format)."""
if position_name == 'Catcher':
return 'C'
elif position_name == 'First Base':
return '1B'
elif position_name == 'Second Base':
return '2B'
elif position_name == 'Third Base':
return '3B'
elif position_name == 'Shortstop':
return 'SS'
elif position_name == 'Left Field':
return 'LF'
elif position_name == 'Center Field':
return 'CF'
elif position_name == 'Right Field':
return 'RF'
elif position_name == 'Pitcher':
return 'P'
if position_name == "Catcher":
return "C"
elif position_name == "First Base":
return "1B"
elif position_name == "Second Base":
return "2B"
elif position_name == "Third Base":
return "3B"
elif position_name == "Shortstop":
return "SS"
elif position_name == "Left Field":
return "LF"
elif position_name == "Center Field":
return "CF"
elif position_name == "Right Field":
return "RF"
elif position_name == "Pitcher":
return "P"
else:
return position_name
@ -67,13 +78,13 @@ def user_has_role(user: discord.User | discord.Member, role_name: str) -> bool:
for x in user.roles:
if x.name == role_name:
return True
return False
def get_roster_sheet_legacy(team):
"""Get legacy roster sheet URL for a team."""
return f'https://docs.google.com/spreadsheets/d/{team.gsheet}/edit'
return f"https://docs.google.com/spreadsheets/d/{team.gsheet}/edit"
def get_roster_sheet(team):
@ -83,13 +94,15 @@ def get_roster_sheet(team):
Handles both dict and Team object formats.
"""
# Handle both dict (team["gsheet"]) and object (team.gsheet) formats
gsheet = team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None)
return f'https://docs.google.com/spreadsheets/d/{gsheet}/edit'
gsheet = (
team.get("gsheet") if isinstance(team, dict) else getattr(team, "gsheet", None)
)
return f"https://docs.google.com/spreadsheets/d/{gsheet}/edit"
def get_player_url(team, player) -> str:
"""Generate player URL for SBA or Baseball Reference."""
if team.get('league') == 'SBA':
if team.get("league") == "SBA":
return f'https://statsplus.net/super-baseball-association/player/{player["player_id"]}'
else:
return f'https://www.baseball-reference.com/players/{player["bbref_id"][0]}/{player["bbref_id"]}.shtml'
@ -98,10 +111,11 @@ def get_player_url(team, player) -> str:
def owner_only(ctx) -> bool:
"""Check if user is the bot owner."""
# ID for discord User Cal
owners = [287463767924137994, 1087936030899347516]
owners = [258104532423147520]
# owners += [287463767924137994, 1087936030899347516]
# Handle both Context (has .author) and Interaction (has .user) objects
user = getattr(ctx, 'user', None) or getattr(ctx, 'author', None)
user = getattr(ctx, "user", None) or getattr(ctx, "author", None)
if user and user.id in owners:
return True
@ -121,35 +135,36 @@ def get_context_user(ctx):
discord.User or discord.Member: The user who invoked the command
"""
# Handle both Context (has .author) and Interaction (has .user) objects
return getattr(ctx, 'user', None) or getattr(ctx, 'author', None)
return getattr(ctx, "user", None) or getattr(ctx, "author", None)
def get_cal_user(ctx):
"""Get the Cal user from context. Always returns an object with .mention attribute."""
import logging
logger = logging.getLogger('discord_app')
logger = logging.getLogger("discord_app")
# Define placeholder user class first
class PlaceholderUser:
def __init__(self):
self.mention = "<@287463767924137994>"
self.id = 287463767924137994
# Handle both Context and Interaction objects
if hasattr(ctx, 'bot'): # Context object
if hasattr(ctx, "bot"): # Context object
bot = ctx.bot
logger.debug("get_cal_user: Using Context object")
elif hasattr(ctx, 'client'): # Interaction object
elif hasattr(ctx, "client"): # Interaction object
bot = ctx.client
logger.debug("get_cal_user: Using Interaction object")
else:
logger.error("get_cal_user: No bot or client found in context")
return PlaceholderUser()
if not bot:
logger.error("get_cal_user: bot is None")
return PlaceholderUser()
logger.debug(f"get_cal_user: Searching among members")
try:
for user in bot.get_all_members():
@ -158,7 +173,7 @@ def get_cal_user(ctx):
return user
except Exception as e:
logger.error(f"get_cal_user: Exception in get_all_members: {e}")
# Fallback: try to get user directly by ID
logger.debug("get_cal_user: User not found in get_all_members, trying get_user")
try:
@ -170,7 +185,7 @@ def get_cal_user(ctx):
logger.debug("get_cal_user: get_user returned None")
except Exception as e:
logger.error(f"get_cal_user: Exception in get_user: {e}")
# Last resort: return a placeholder user object with mention
logger.debug("get_cal_user: Using placeholder user")
return PlaceholderUser()
return PlaceholderUser()

View File

@ -12,12 +12,12 @@ from in_game.gameplay_queries import get_channel_game_or_none
from health_server import run_health_server
from notify_restart import send_restart_notification
raw_log_level = os.getenv('LOG_LEVEL')
if raw_log_level == 'DEBUG':
raw_log_level = os.getenv("LOG_LEVEL")
if raw_log_level == "DEBUG":
log_level = logging.DEBUG
elif raw_log_level == 'INFO':
elif raw_log_level == "INFO":
log_level = logging.INFO
elif raw_log_level == 'WARN':
elif raw_log_level == "WARN":
log_level = logging.WARNING
else:
log_level = logging.ERROR
@ -29,17 +29,17 @@ else:
# level=log_level
# )
# logger.getLogger('discord.http').setLevel(logger.INFO)
logger = logging.getLogger('discord_app')
logger = logging.getLogger("discord_app")
logger.setLevel(log_level)
handler = RotatingFileHandler(
filename='logs/discord.log',
filename="logs/discord.log",
# encoding='utf-8',
maxBytes=32 * 1024 * 1024, # 32 MiB
backupCount=5, # Rotate through 5 files
)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
handler.setFormatter(formatter)
# dt_fmt = '%Y-%m-%d %H:%M:%S'
@ -48,27 +48,30 @@ handler.setFormatter(formatter)
logger.addHandler(handler)
COGS = [
'cogs.owner',
'cogs.admins',
'cogs.economy',
'cogs.players',
'cogs.gameplay',
"cogs.owner",
"cogs.admins",
"cogs.economy",
"cogs.players",
"cogs.gameplay",
"cogs.economy_new.scouting",
]
intents = discord.Intents.default()
intents.members = True
intents.message_content = True
bot = commands.Bot(command_prefix='.',
intents=intents,
# help_command=None,
description='The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.',
case_insensitive=True,
owner_id=258104532423147520)
bot = commands.Bot(
command_prefix=".",
intents=intents,
# help_command=None,
description="The Paper Dynasty Bot\nIf you have questions, feel free to contact Cal.",
case_insensitive=True,
owner_id=258104532423147520,
)
@bot.event
async def on_ready():
logger.info('Logged in as:')
logger.info("Logged in as:")
logger.info(bot.user.name)
logger.info(bot.user.id)
@ -77,9 +80,11 @@ async def on_ready():
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError):
async def on_app_command_error(
interaction: discord.Interaction, error: discord.app_commands.AppCommandError
):
"""Global error handler for all app commands (slash commands)."""
logger.error(f'App command error in {interaction.command}: {error}', exc_info=error)
logger.error(f"App command error in {interaction.command}: {error}", exc_info=error)
# CRITICAL: Release play lock if command failed during gameplay
# This prevents permanent user lockouts when exceptions occur
@ -97,22 +102,23 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord.
session.add(current_play)
session.commit()
except Exception as lock_error:
logger.error(f'Failed to release play lock after error: {lock_error}', exc_info=lock_error)
logger.error(
f"Failed to release play lock after error: {lock_error}",
exc_info=lock_error,
)
# Try to respond to the user
try:
if not interaction.response.is_done():
await interaction.response.send_message(
f'❌ An error occurred: {str(error)}',
ephemeral=True
f"❌ An error occurred: {str(error)}", ephemeral=True
)
else:
await interaction.followup.send(
f'❌ An error occurred: {str(error)}',
ephemeral=True
f"❌ An error occurred: {str(error)}", ephemeral=True
)
except Exception as e:
logger.error(f'Failed to send error message to user: {e}')
logger.error(f"Failed to send error message to user: {e}")
async def main():
@ -120,10 +126,10 @@ async def main():
for c in COGS:
try:
await bot.load_extension(c)
logger.info(f'Loaded cog: {c}')
logger.info(f"Loaded cog: {c}")
except Exception as e:
logger.error(f'Failed to load cog: {c}')
logger.error(f'{e}')
logger.error(f"Failed to load cog: {c}")
logger.error(f"{e}")
# Start health server and bot concurrently
async with bot:
@ -132,7 +138,7 @@ async def main():
try:
# Start bot (this blocks until bot stops)
await bot.start(os.environ.get('BOT_TOKEN', 'NONE'))
await bot.start(os.environ.get("BOT_TOKEN", "NONE"))
finally:
# Cleanup: cancel health server when bot stops
health_task.cancel()
@ -141,4 +147,5 @@ async def main():
except asyncio.CancelledError:
pass
asyncio.run(main())

View File

170
tests/scouting/conftest.py Normal file
View File

@ -0,0 +1,170 @@
"""Shared fixtures for scouting feature tests."""
import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, Mock
import discord
from discord.ext import commands
# ---------------------------------------------------------------------------
# Sample data factories
# ---------------------------------------------------------------------------
def _make_player(
player_id,
name,
rarity_name,
rarity_value,
headshot=None,
description="2023",
image=None,
):
"""Build a minimal player dict matching the API shape used by scouting."""
return {
"player_id": player_id,
"p_name": name,
"rarity": {"name": rarity_name, "value": rarity_value, "color": "ffffff"},
"headshot": headshot or "https://example.com/headshot.jpg",
"description": description,
"image": image or f"https://example.com/cards/{player_id}/battingcard.png",
}
def _make_card(card_id, player, pack_id=100):
"""Wrap a player dict inside a card dict (as returned by the cards API)."""
return {"id": card_id, "player": player, "pack": {"id": pack_id}}
@pytest.fixture
def sample_players():
"""Five players spanning different rarities for a realistic pack."""
return [
_make_player(101, "Mike Trout", "MVP", 5),
_make_player(102, "Juan Soto", "All-Star", 3),
_make_player(103, "Marcus Semien", "Starter", 2),
_make_player(104, "Willy Adames", "Reserve", 1),
_make_player(105, "Generic Bench", "Replacement", 0),
]
@pytest.fixture
def sample_cards(sample_players):
"""Five card dicts wrapping the sample players."""
return [_make_card(i + 1, p) for i, p in enumerate(sample_players)]
@pytest.fixture
def opener_team():
"""Team dict for the pack opener."""
return {
"id": 10,
"abbrev": "OPN",
"sname": "Openers",
"lname": "Opening Squad",
"gm_id": 99999,
"gmname": "Opener GM",
"color": "a6ce39",
"logo": "https://example.com/logo.png",
"season": 4,
}
@pytest.fixture
def scouter_team():
"""Team dict for a player who scouts a card."""
return {
"id": 20,
"abbrev": "SCT",
"sname": "Scouts",
"lname": "Scouting Squad",
"gm_id": 88888,
"gmname": "Scout GM",
"color": "3498db",
"logo": "https://example.com/scout_logo.png",
"season": 4,
}
@pytest.fixture
def scouter_team_2():
"""Second scouter team for multi-scout tests."""
return {
"id": 30,
"abbrev": "SC2",
"sname": "Scouts2",
"lname": "Second Scouts",
"gm_id": 77777,
"gmname": "Scout GM 2",
"color": "e74c3c",
"logo": "https://example.com/scout2_logo.png",
"season": 4,
}
# ---------------------------------------------------------------------------
# Discord mocks
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_bot():
"""Mock Discord bot."""
bot = AsyncMock(spec=commands.Bot)
bot.get_cog = Mock(return_value=None)
bot.add_cog = AsyncMock()
bot.wait_until_ready = AsyncMock()
# Mock guild / channel lookup for send_to_channel
channel_mock = AsyncMock(spec=discord.TextChannel)
channel_mock.send = AsyncMock()
guild_mock = Mock(spec=discord.Guild)
guild_mock.text_channels = [channel_mock]
channel_mock.name = "pd-network-news"
bot.guilds = [guild_mock]
return bot
@pytest.fixture
def mock_interaction():
"""Mock Discord interaction for slash commands."""
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.response.send_message = AsyncMock()
interaction.response.is_done = Mock(return_value=False)
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock(spec=discord.Member)
interaction.user.id = 12345
interaction.user.mention = "<@12345>"
interaction.channel = Mock(spec=discord.TextChannel)
interaction.channel.name = "pack-openings"
interaction.channel.send = AsyncMock()
return interaction
@pytest.fixture
def mock_channel():
"""Mock #pack-openings channel."""
channel = AsyncMock(spec=discord.TextChannel)
channel.name = "pack-openings"
channel.send = AsyncMock()
return channel
# ---------------------------------------------------------------------------
# Logging suppression
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def silence_logging():
"""Suppress log noise during tests."""
import logging
logging.getLogger("discord_app").setLevel(logging.CRITICAL)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,269 @@
"""Tests for cogs/economy_new/scouting.py — the Scouting cog.
Covers the /scout-tokens command and the cleanup_expired background task.
Note: Scouting.__init__ calls self.cleanup_expired.start() which requires
a running event loop. All tests that instantiate the cog must be async.
"""
import datetime
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import discord
from discord.ext import commands
from cogs.economy_new.scouting import Scouting, SCOUT_TOKENS_PER_DAY
def _make_team():
return {
"id": 1,
"lname": "Test Team",
"color": "a6ce39",
"logo": "https://example.com/logo.png",
"season": 4,
}
# ---------------------------------------------------------------------------
# Cog setup
# ---------------------------------------------------------------------------
class TestScoutingCogSetup:
"""Tests for cog initialization and lifecycle."""
@pytest.mark.asyncio
async def test_cog_initializes(self, mock_bot):
"""The Scouting cog should initialize without errors."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
assert cog.bot is mock_bot
@pytest.mark.asyncio
async def test_cleanup_task_starts(self, mock_bot):
"""The cleanup_expired loop task should be started on init."""
cog = Scouting(mock_bot)
assert cog.cleanup_expired.is_running()
cog.cleanup_expired.cancel()
@pytest.mark.asyncio
async def test_cog_unload_calls_cancel(self, mock_bot):
"""Unloading the cog should call cancel on the cleanup task."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
# Verify cog_unload runs without error
await cog.cog_unload()
# ---------------------------------------------------------------------------
# /scout-tokens command
# ---------------------------------------------------------------------------
class TestScoutTokensCommand:
"""Tests for the /scout-tokens slash command."""
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_shows_remaining_tokens(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""Should display the correct number of remaining tokens."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = 1 # 1 used today
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
await cog.scout_tokens_command.callback(cog, interaction)
interaction.response.defer.assert_called_once_with(ephemeral=True)
interaction.followup.send.assert_called_once()
call_kwargs = interaction.followup.send.call_args[1]
embed = call_kwargs["embed"]
remaining = SCOUT_TOKENS_PER_DAY - 1
assert str(remaining) in embed.description
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_no_team_rejects(self, mock_get_team, mock_get_tokens, mock_bot):
"""A user without a PD team should get a rejection message."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = None
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
await cog.scout_tokens_command.callback(cog, interaction)
msg = interaction.followup.send.call_args[0][0]
assert "team" in msg.lower()
mock_get_tokens.assert_not_called()
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_all_tokens_used_shows_zero(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""When all tokens are used, should show 0 remaining with extra message."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = SCOUT_TOKENS_PER_DAY
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
await cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
assert "0" in embed.description
assert (
"used all" in embed.description.lower()
or "tomorrow" in embed.description.lower()
)
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_no_tokens_used_shows_full(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""When no tokens have been used, should show the full daily allowance."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = 0
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
await cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
assert str(SCOUT_TOKENS_PER_DAY) in embed.description
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_db_get_returns_none(self, mock_get_team, mock_get_tokens, mock_bot):
"""If get_scout_tokens_used returns 0 (API failure handled internally), should show full tokens."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = (
0 # get_scout_tokens_used handles None internally
)
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
await cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
assert str(SCOUT_TOKENS_PER_DAY) in embed.description
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.get_scout_tokens_used", new_callable=AsyncMock)
@patch("cogs.economy_new.scouting.get_team_by_owner", new_callable=AsyncMock)
async def test_over_limit_tokens_shows_zero(
self, mock_get_team, mock_get_tokens, mock_bot
):
"""If somehow more tokens than the daily limit were used, should show 0 not negative."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_get_team.return_value = _make_team()
mock_get_tokens.return_value = 5 # more than SCOUT_TOKENS_PER_DAY
interaction = AsyncMock(spec=discord.Interaction)
interaction.response = AsyncMock()
interaction.response.defer = AsyncMock()
interaction.followup = AsyncMock()
interaction.followup.send = AsyncMock()
interaction.user = Mock()
interaction.user.id = 12345
await cog.scout_tokens_command.callback(cog, interaction)
embed = interaction.followup.send.call_args[1]["embed"]
# Should show "0" not "-3"
assert "0" in embed.description
assert "-" not in embed.description.split("remaining")[0]
# ---------------------------------------------------------------------------
# cleanup_expired task
# ---------------------------------------------------------------------------
class TestCleanupExpired:
"""Tests for the background cleanup task."""
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock)
async def test_cleanup_logs_expired_opportunities(self, mock_db_get, mock_bot):
"""The cleanup task should query for expired unclaimed opportunities."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_db_get.return_value = {"count": 3}
# Call the coroutine directly (not via the loop)
await cog.cleanup_expired.coro(cog)
mock_db_get.assert_called_once()
call_args = mock_db_get.call_args
assert call_args[0][0] == "scout_opportunities"
@pytest.mark.asyncio
@patch("cogs.economy_new.scouting.db_get", new_callable=AsyncMock)
async def test_cleanup_handles_api_failure(self, mock_db_get, mock_bot):
"""Cleanup should not crash if the API is unavailable."""
cog = Scouting(mock_bot)
cog.cleanup_expired.cancel()
mock_db_get.side_effect = Exception("API not ready")
# Should not raise
await cog.cleanup_expired.coro(cog)

View File

@ -0,0 +1,374 @@
"""Tests for helpers/scouting.py — embed builders and scout opportunity creation.
Covers the pure functions (_build_card_lines, build_scout_embed,
build_scouted_card_list) and the async create_scout_opportunity flow.
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import discord
from helpers.scouting import (
_build_card_lines,
build_scout_embed,
build_scouted_card_list,
create_scout_opportunity,
RARITY_SYMBOLS,
)
# ---------------------------------------------------------------------------
# _build_card_lines
# ---------------------------------------------------------------------------
class TestBuildCardLines:
"""Tests for the shuffled card line builder."""
def test_returns_correct_count(self, sample_cards):
"""Should produce one line per card in the pack."""
lines = _build_card_lines(sample_cards)
assert len(lines) == len(sample_cards)
def test_each_line_contains_player_id(self, sample_cards):
"""Each tuple's first element should be the player_id from the card."""
lines = _build_card_lines(sample_cards)
ids = {pid for pid, _ in lines}
expected_ids = {c["player"]["player_id"] for c in sample_cards}
assert ids == expected_ids
def test_each_line_contains_player_name(self, sample_cards):
"""The display string should include the player's name."""
lines = _build_card_lines(sample_cards)
for pid, display in lines:
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
assert card["player"]["p_name"] in display
def test_each_line_contains_rarity_name(self, sample_cards):
"""The display string should include the rarity tier name."""
lines = _build_card_lines(sample_cards)
for pid, display in lines:
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
assert card["player"]["rarity"]["name"] in display
def test_rarity_symbol_present(self, sample_cards):
"""Each line should start with the appropriate rarity emoji."""
lines = _build_card_lines(sample_cards)
for pid, display in lines:
card = next(c for c in sample_cards if c["player"]["player_id"] == pid)
rarity_val = card["player"]["rarity"]["value"]
expected_symbol = RARITY_SYMBOLS.get(rarity_val, "\u26ab")
assert display.startswith(expected_symbol)
def test_output_is_shuffled(self, sample_cards):
"""Over many runs, the order should not always match the input order.
We run 20 iterations if it comes out sorted every time, the shuffle
is broken (probability ~1/20! per run, effectively zero).
"""
input_order = [c["player"]["player_id"] for c in sample_cards]
saw_different = False
for _ in range(20):
lines = _build_card_lines(sample_cards)
output_order = [pid for pid, _ in lines]
if output_order != input_order:
saw_different = True
break
assert saw_different, "Card lines were never shuffled across 20 runs"
def test_empty_cards(self):
"""Empty input should produce an empty list."""
assert _build_card_lines([]) == []
def test_unknown_rarity_uses_fallback_symbol(self):
"""A rarity value not in RARITY_SYMBOLS should get the black circle fallback."""
card = {
"id": 99,
"player": {
"player_id": 999,
"p_name": "Unknown Rarity",
"rarity": {"name": "Legendary", "value": 99, "color": "gold"},
},
}
lines = _build_card_lines([card])
assert lines[0][1].startswith("\u26ab") # black circle fallback
# ---------------------------------------------------------------------------
# build_scout_embed
# ---------------------------------------------------------------------------
class TestBuildScoutEmbed:
"""Tests for the embed builder shown above scout buttons."""
def test_returns_embed_and_card_lines(self, opener_team, sample_cards):
"""Should return a (discord.Embed, list) tuple."""
embed, card_lines = build_scout_embed(opener_team, sample_cards)
assert isinstance(embed, discord.Embed)
assert isinstance(card_lines, list)
assert len(card_lines) == len(sample_cards)
def test_embed_description_contains_team_name(self, opener_team, sample_cards):
"""The embed body should mention the opener's team name."""
embed, _ = build_scout_embed(opener_team, sample_cards)
assert opener_team["lname"] in embed.description
def test_embed_description_contains_all_player_names(
self, opener_team, sample_cards
):
"""Every player name from the pack should appear in the embed."""
embed, _ = build_scout_embed(opener_team, sample_cards)
for card in sample_cards:
assert card["player"]["p_name"] in embed.description
def test_embed_mentions_token_cost(self, opener_team, sample_cards):
"""The embed should tell users about the scout token cost."""
embed, _ = build_scout_embed(opener_team, sample_cards)
assert "Scout Token" in embed.description
def test_embed_mentions_time_limit(self, opener_team, sample_cards):
"""The embed should mention the 30-minute window."""
embed, _ = build_scout_embed(opener_team, sample_cards)
assert "30 minutes" in embed.description
def test_prebuilt_card_lines_are_reused(self, opener_team, sample_cards):
"""When card_lines are passed in, they should be reused (not rebuilt)."""
prebuilt = [(101, "Custom Line 1"), (102, "Custom Line 2")]
embed, returned_lines = build_scout_embed(
opener_team, sample_cards, card_lines=prebuilt
)
assert returned_lines is prebuilt
assert "Custom Line 1" in embed.description
assert "Custom Line 2" in embed.description
# ---------------------------------------------------------------------------
# build_scouted_card_list
# ---------------------------------------------------------------------------
class TestBuildScoutedCardList:
"""Tests for the card list formatter that marks scouted cards."""
def test_no_scouts_returns_plain_lines(self):
"""With no scouts, output should match the raw card lines."""
card_lines = [
(101, "\U0001f7e3 MVP — Mike Trout"),
(102, "\U0001f535 All-Star — Juan Soto"),
]
result = build_scouted_card_list(card_lines, {})
assert result == "\U0001f7e3 MVP — Mike Trout\n\U0001f535 All-Star — Juan Soto"
def test_single_scout_shows_team_name(self):
"""A card scouted once should show a checkmark and the team name."""
card_lines = [
(101, "\U0001f7e3 MVP — Mike Trout"),
(102, "\U0001f535 All-Star — Juan Soto"),
]
scouted = {101: ["Scouting Squad"]}
result = build_scouted_card_list(card_lines, scouted)
assert "\u2714\ufe0f" in result # checkmark
assert "*Scouting Squad*" in result
# Unscouted card should appear plain
lines = result.split("\n")
assert "\u2714" not in lines[1]
def test_multiple_scouts_shows_count_and_names(self):
"""A card scouted multiple times should show the count and all team names."""
card_lines = [(101, "\U0001f7e3 MVP — Mike Trout")]
scouted = {101: ["Team A", "Team B", "Team C"]}
result = build_scouted_card_list(card_lines, scouted)
assert "x3" in result
assert "*Team A*" in result
assert "*Team B*" in result
assert "*Team C*" in result
def test_mixed_scouted_and_unscouted(self):
"""Only scouted cards should have marks; unscouted cards stay plain."""
card_lines = [
(101, "Line A"),
(102, "Line B"),
(103, "Line C"),
]
scouted = {102: ["Some Team"]}
result = build_scouted_card_list(card_lines, scouted)
lines = result.split("\n")
assert "\u2714" not in lines[0]
assert "\u2714" in lines[1]
assert "\u2714" not in lines[2]
def test_empty_input(self):
"""Empty card lines should produce an empty string."""
assert build_scouted_card_list([], {}) == ""
def test_two_scouts_shows_count(self):
"""Two scouts on the same card should show x2."""
card_lines = [(101, "Line A")]
scouted = {101: ["Team X", "Team Y"]}
result = build_scouted_card_list(card_lines, scouted)
assert "x2" in result
# ---------------------------------------------------------------------------
# create_scout_opportunity
# ---------------------------------------------------------------------------
class TestCreateScoutOpportunity:
"""Tests for the async scout opportunity creation flow."""
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_posts_to_api_and_sends_message(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""Should POST to scout_opportunities and send a message to the channel."""
mock_db_post.return_value = {"id": 42}
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
# API was called to create the opportunity
mock_db_post.assert_called_once()
call_args = mock_db_post.call_args
assert call_args[0][0] == "scout_opportunities"
assert call_args[1]["payload"]["opener_team_id"] == opener_team["id"]
# Message was sent to the channel
mock_channel.send.assert_called_once()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_skips_wrong_channel(
self, mock_db_post, sample_cards, opener_team, mock_bot
):
"""Should silently return when the channel is not #pack-openings."""
channel = AsyncMock(spec=discord.TextChannel)
channel.name = "general"
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, channel, opener_user, context
)
mock_db_post.assert_not_called()
channel.send.assert_not_called()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_skips_empty_pack(
self, mock_db_post, opener_team, mock_channel, mock_bot
):
"""Should silently return when pack_cards is empty."""
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
[], opener_team, mock_channel, opener_user, context
)
mock_db_post.assert_not_called()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_skips_none_channel(
self, mock_db_post, sample_cards, opener_team, mock_bot
):
"""Should handle None channel without crashing."""
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, None, opener_user, context
)
mock_db_post.assert_not_called()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_api_failure_does_not_raise(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""Scout creation failure must never crash the pack opening flow."""
mock_db_post.side_effect = Exception("API down")
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
# Should not raise
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_channel_send_failure_does_not_raise(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""If the channel.send fails, it should be caught gracefully."""
mock_db_post.return_value = {"id": 42}
mock_channel.send.side_effect = discord.HTTPException(
Mock(status=500), "Server error"
)
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
# Should not raise
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_context_client_fallback(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""When context.bot is None, should fall back to context.client for the bot ref."""
mock_db_post.return_value = {"id": 42}
opener_user = Mock()
opener_user.id = 99999
context = Mock(spec=[]) # empty spec — no .bot attribute
context.client = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)
mock_channel.send.assert_called_once()
@pytest.mark.asyncio
@patch("helpers.scouting.db_post", new_callable=AsyncMock)
async def test_view_message_is_assigned(
self, mock_db_post, sample_cards, opener_team, mock_channel, mock_bot
):
"""The message returned by channel.send should be assigned to view.message.
This linkage is required for update_message and on_timeout to work.
"""
mock_db_post.return_value = {"id": 42}
sent_msg = AsyncMock(spec=discord.Message)
mock_channel.send.return_value = sent_msg
opener_user = Mock()
opener_user.id = 99999
context = Mock()
context.bot = mock_bot
await create_scout_opportunity(
sample_cards, opener_team, mock_channel, opener_user, context
)