paper-dynasty-discord/discord_ui/scout_view.py
Cal Corum 970aef760a
All checks were successful
Build Docker Image / build (pull_request) Successful in 1m17s
fix: support packs with >5 cards in scout view
Spread scout buttons across multiple rows (5 per row) instead of
all on row 0. Cap at 25 buttons (Discord max) using the last 25 cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 11:43:06 -05:00

275 lines
9.5 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 the same
card — each gets their own copy.
"""
import logging
import discord
from api_calls import db_get, 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[-25:]):
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=position // 5,
)
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
current = await db_get("current")
await db_post(
"rewards",
payload={
"name": "Scout Token",
"team_id": scouter_team["id"],
"season": PD_SEASON,
"week": current["week"],
"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)