paper-dynasty-discord/discord_ui/scout_view.py
Cal Corum 2d5bd86d52 feat: Add Scouting feature (Wonder Pick-style social pack opening)
When a player opens a pack, a scout opportunity is posted to #pack-openings
with face-down card buttons. Other players can blind-pick one card using
daily scout tokens (2/day), receiving a copy. The opener keeps all cards.

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:22:58 +00:00

340 lines
12 KiB
Python

"""
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)