Compare commits
13 Commits
41c33e6d42
...
ec0945af3a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec0945af3a | ||
|
|
da55cbe4d4 | ||
|
|
e160be4137 | ||
|
|
8e605c2140 | ||
|
|
77c3f3004c | ||
|
|
ed00a97c0d | ||
| 0aafc3fa46 | |||
| 0ce0707e3e | |||
| 75b9968149 | |||
| 89f80727bd | |||
|
|
d569e91905 | ||
|
|
d538c679c3 | ||
|
|
14103e48b7 |
@ -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 }}
|
||||
|
||||
90
cogs/economy_new/scouting.py
Normal file
90
cogs/economy_new/scouting.py
Normal 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))
|
||||
@ -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
272
discord_ui/scout_view.py
Normal 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)
|
||||
@ -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 *
|
||||
|
||||
@ -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
243
helpers/scouting.py
Normal 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}")
|
||||
129
helpers/utils.py
129
helpers/utils.py
@ -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()
|
||||
|
||||
@ -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())
|
||||
|
||||
0
tests/scouting/__init__.py
Normal file
0
tests/scouting/__init__.py
Normal file
170
tests/scouting/conftest.py
Normal file
170
tests/scouting/conftest.py
Normal 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)
|
||||
1015
tests/scouting/test_scout_view.py
Normal file
1015
tests/scouting/test_scout_view.py
Normal file
File diff suppressed because it is too large
Load Diff
269
tests/scouting/test_scouting_cog.py
Normal file
269
tests/scouting/test_scouting_cog.py
Normal 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)
|
||||
374
tests/scouting/test_scouting_helpers.py
Normal file
374
tests/scouting/test_scouting_helpers.py
Normal 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
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user