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>
This commit is contained in:
parent
3a32e52971
commit
409370ed0e
104
cogs/economy_new/scouting.py
Normal file
104
cogs/economy_new/scouting.py
Normal file
@ -0,0 +1,104 @@
|
||||
"""
|
||||
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.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")
|
||||
|
||||
SCOUT_TOKENS_PER_DAY = 2
|
||||
|
||||
|
||||
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
|
||||
|
||||
now = datetime.datetime.now()
|
||||
midnight = int_timestamp(
|
||||
datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
|
||||
)
|
||||
|
||||
used_today = await db_get(
|
||||
"rewards",
|
||||
params=[
|
||||
("name", "Scout Token"),
|
||||
("team_id", team["id"]),
|
||||
("created_after", midnight),
|
||||
],
|
||||
)
|
||||
tokens_used = used_today["count"] if used_today else 0
|
||||
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,32 @@ 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
|
||||
from .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",
|
||||
"ScoutView",
|
||||
]
|
||||
|
||||
339
discord_ui/scout_view.py
Normal file
339
discord_ui/scout_view.py
Normal file
@ -0,0 +1,339 @@
|
||||
"""
|
||||
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 different
|
||||
cards from the same pack — each costs one scout token.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
import discord
|
||||
|
||||
from api_calls import db_get, db_post, db_patch
|
||||
from helpers.main import get_team_by_owner, get_card_embeds
|
||||
from helpers.utils import int_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
|
||||
|
||||
|
||||
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
|
||||
- Each card can be scouted once; multiple players can scout different cards
|
||||
- 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,
|
||||
):
|
||||
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.message: discord.Message | None = None
|
||||
self.card_lines: list[tuple[int, str]] = []
|
||||
|
||||
# Per-card claim tracking: position -> scouter team name
|
||||
self.claimed_positions: dict[int, str] = {}
|
||||
# Per-user lock: user IDs who have already scouted this pack
|
||||
self.scouted_users: set[int] = set()
|
||||
# Positions currently being processed (prevent double-click race)
|
||||
self.processing: set[int] = set()
|
||||
|
||||
for i, card in enumerate(cards):
|
||||
button = ScoutButton(
|
||||
card=card,
|
||||
position=i,
|
||||
scout_view=self,
|
||||
)
|
||||
self.add_item(button)
|
||||
|
||||
@property
|
||||
def all_claimed(self) -> bool:
|
||||
return len(self.claimed_positions) >= len(self.cards)
|
||||
|
||||
async def update_message(self):
|
||||
"""Refresh the embed with current claim state."""
|
||||
if not self.message:
|
||||
return
|
||||
|
||||
from helpers.scouting import build_scouted_card_list
|
||||
|
||||
scouted_ids = {}
|
||||
for pos, team_name in self.claimed_positions.items():
|
||||
player_id = self.cards[pos]["player"]["player_id"]
|
||||
scouted_ids[player_id] = team_name
|
||||
|
||||
card_list = build_scouted_card_list(self.card_lines, scouted_ids)
|
||||
claim_count = len(self.claimed_positions)
|
||||
|
||||
if self.all_claimed:
|
||||
title = "Fully Scouted!"
|
||||
footer_text = f"Paper Dynasty Season {PD_SEASON} \u2022 All cards scouted"
|
||||
else:
|
||||
title = f"Scout Opportunity! ({claim_count}/{len(self.cards)} scouted)"
|
||||
footer_text = (
|
||||
f"Paper Dynasty Season {PD_SEASON} \u2022 One scout per player"
|
||||
)
|
||||
|
||||
embed = get_team_embed(title=title, team=self.opener_team)
|
||||
embed.description = (
|
||||
f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}\n\n"
|
||||
)
|
||||
if not self.all_claimed:
|
||||
embed.description += (
|
||||
"Pick a card — but which is which?\n"
|
||||
"Costs 1 Scout Token (2 per day, resets at midnight Central)."
|
||||
)
|
||||
|
||||
embed.set_footer(text=footer_text, icon_url=IMAGES["logo"])
|
||||
|
||||
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:
|
||||
from helpers.scouting import build_scouted_card_list
|
||||
|
||||
scouted_ids = {}
|
||||
for pos, team_name in self.claimed_positions.items():
|
||||
player_id = self.cards[pos]["player"]["player_id"]
|
||||
scouted_ids[player_id] = team_name
|
||||
|
||||
card_list = build_scouted_card_list(self.card_lines, scouted_ids)
|
||||
claim_count = len(self.claimed_positions)
|
||||
|
||||
if claim_count > 0:
|
||||
title = (
|
||||
f"Scout Window Closed ({claim_count}/{len(self.cards)} scouted)"
|
||||
)
|
||||
else:
|
||||
title = "Scout Window Closed"
|
||||
|
||||
embed = get_team_embed(title=title, team=self.opener_team)
|
||||
embed.description = (
|
||||
f"**{self.opener_team['lname']}**'s pack\n\n" f"{card_list}"
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Paper Dynasty Season {PD_SEASON}",
|
||||
icon_url=IMAGES["logo"],
|
||||
)
|
||||
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
|
||||
|
||||
# This card already taken
|
||||
if self.position in view.claimed_positions:
|
||||
await interaction.response.send_message(
|
||||
"This card was already scouted! Try a different one.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Prevent double-click race on same card
|
||||
if self.position in view.processing:
|
||||
await interaction.response.send_message(
|
||||
"Hold on, someone's claiming this card right now...",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
view.processing.add(self.position)
|
||||
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
|
||||
now = datetime.datetime.now()
|
||||
midnight = int_timestamp(
|
||||
datetime.datetime(now.year, now.month, now.day, 0, 0, 0)
|
||||
)
|
||||
used_today = await db_get(
|
||||
"rewards",
|
||||
params=[
|
||||
("name", "Scout Token"),
|
||||
("team_id", scouter_team["id"]),
|
||||
("created_after", midnight),
|
||||
],
|
||||
)
|
||||
tokens_used = used_today["count"] if used_today else 0
|
||||
|
||||
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
|
||||
|
||||
# Consume a scout token
|
||||
current = await db_get("current")
|
||||
await db_post(
|
||||
"rewards",
|
||||
payload={
|
||||
"name": "Scout Token",
|
||||
"team_id": scouter_team["id"],
|
||||
"season": current["season"] if current else PD_SEASON,
|
||||
"week": current["week"] if current else 1,
|
||||
"created": int_timestamp(now),
|
||||
},
|
||||
)
|
||||
|
||||
# Create a copy of the card for the scouter
|
||||
await db_post(
|
||||
"cards",
|
||||
payload={
|
||||
"cards": [
|
||||
{
|
||||
"player_id": self.card["player"]["player_id"],
|
||||
"team_id": scouter_team["id"],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
# Track the claim
|
||||
view.claimed_positions[self.position] = scouter_team["lname"]
|
||||
view.scouted_users.add(interaction.user.id)
|
||||
|
||||
# Update this button
|
||||
self.disabled = True
|
||||
self.style = discord.ButtonStyle.success
|
||||
self.label = "Scouted!"
|
||||
|
||||
# If all cards claimed, disable remaining buttons and stop
|
||||
if view.all_claimed:
|
||||
for item in view.children:
|
||||
item.disabled = True
|
||||
view.stop()
|
||||
|
||||
# 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:
|
||||
from helpers.discord_utils import send_to_channel
|
||||
|
||||
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.discard(self.position)
|
||||
@ -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,18 @@ 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
|
||||
from helpers.scouting import create_scout_opportunity
|
||||
|
||||
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,
|
||||
|
||||
173
helpers/scouting.py
Normal file
173
helpers/scouting.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""
|
||||
Scouting Helper Functions
|
||||
|
||||
Handles creation of scout opportunities after pack openings
|
||||
and embed formatting for the scouting feature.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import random
|
||||
|
||||
import discord
|
||||
|
||||
from api_calls import db_post
|
||||
from helpers.utils import int_timestamp
|
||||
from helpers.discord_utils import get_team_embed
|
||||
from helpers.constants import IMAGES, PD_SEASON
|
||||
|
||||
logger = logging.getLogger("discord_app")
|
||||
|
||||
SCOUT_WINDOW_SECONDS = 1800 # 30 minutes
|
||||
|
||||
# Rarity value → display symbol
|
||||
RARITY_SYMBOLS = {
|
||||
8: "\U0001f7e1", # HoF — yellow
|
||||
5: "\U0001f7e3", # MVP — purple
|
||||
3: "\U0001f535", # All-Star — blue
|
||||
2: "\U0001f7e2", # Starter — green
|
||||
1: "\u26aa", # Reserve — white
|
||||
0: "\u26ab", # Replacement — black
|
||||
}
|
||||
|
||||
|
||||
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")
|
||||
lines.append(
|
||||
(
|
||||
player["player_id"],
|
||||
f"{symbol} {player['rarity']['name']} — {player['p_name']}",
|
||||
)
|
||||
)
|
||||
random.shuffle(lines)
|
||||
return lines
|
||||
|
||||
|
||||
def build_scout_embed(
|
||||
opener_team: dict,
|
||||
cards: list[dict],
|
||||
card_lines: list[tuple[int, str]] = None,
|
||||
) -> discord.Embed:
|
||||
"""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.
|
||||
"""
|
||||
embed = get_team_embed(title="Scout Opportunity!", team=opener_team)
|
||||
|
||||
if card_lines is None:
|
||||
card_lines = _build_card_lines(cards)
|
||||
|
||||
card_list = "\n".join(line for _, line in card_lines)
|
||||
|
||||
embed.description = (
|
||||
f"**{opener_team['lname']}** just opened a pack!\n\n"
|
||||
f"**Cards in this pack:**\n{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"This window closes in **30 minutes**."
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Paper Dynasty Season {PD_SEASON} \u2022 One player per pack",
|
||||
icon_url=IMAGES["logo"],
|
||||
)
|
||||
return embed, card_lines
|
||||
|
||||
|
||||
def build_scouted_card_list(
|
||||
card_lines: list[tuple[int, str]],
|
||||
scouted_cards: dict[int, str],
|
||||
) -> str:
|
||||
"""Rebuild the card list marking scouted cards with the scouter's team name.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
card_lines : shuffled list of (player_id, display_line) tuples
|
||||
scouted_cards : {player_id: scouter_team_name} for each claimed card
|
||||
"""
|
||||
result = []
|
||||
for player_id, line in card_lines:
|
||||
if player_id in scouted_cards:
|
||||
team_name = scouted_cards[player_id]
|
||||
result.append(f"{line} \u2014 \u2714\ufe0f *{team_name}*")
|
||||
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_at = int_timestamp(now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS))
|
||||
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)
|
||||
|
||||
# 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,
|
||||
)
|
||||
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}")
|
||||
@ -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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user