paper-dynasty-discord/discord_ui/scout_view.py
cal eb17b17dd4
All checks were successful
Build Docker Image / build (push) Successful in 1m21s
Merge pull request 'enhance/scouting' (#81) from enhance/scouting into main
Reviewed-on: #81
2026-03-09 18:35:39 +00:00

377 lines
13 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_patch, db_post
from helpers.main import get_team_by_owner, get_card_embeds
from helpers.scouting import (
SCOUT_TOKEN_COST,
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)
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:
# Offer to buy an extra scout token
buy_view = BuyScoutTokenView(
scouter_team=scouter_team,
responder_id=interaction.user.id,
)
buy_msg = await interaction.followup.send(
f"You're out of scout tokens for today! "
f"You can buy one for **{SCOUT_TOKEN_COST}₼** "
f"(wallet: {scouter_team['wallet']}₼).",
view=buy_view,
ephemeral=True,
wait=True,
)
buy_view.message = buy_msg
await buy_view.wait()
if not buy_view.value:
return
# Refresh team data after purchase
scouter_team = buy_view.scouter_team
# 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)
class BuyScoutTokenView(discord.ui.View):
"""Ephemeral confirmation view for purchasing an extra scout token."""
def __init__(self, scouter_team: dict, responder_id: int):
super().__init__(timeout=30.0)
self.scouter_team = scouter_team
self.responder_id = responder_id
self.value = False
self.message: discord.Message | None = None
if scouter_team["wallet"] < SCOUT_TOKEN_COST:
self.buy_button.disabled = True
self.buy_button.label = (
f"Not enough ₼ ({scouter_team['wallet']}/{SCOUT_TOKEN_COST})"
)
async def on_timeout(self):
"""Disable buttons when the buy window expires."""
for item in self.children:
item.disabled = True
if self.message:
try:
await self.message.edit(view=self)
except Exception:
pass
@discord.ui.button(
label=f"Buy Scout Token ({SCOUT_TOKEN_COST}₼)",
style=discord.ButtonStyle.green,
)
async def buy_button(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.responder_id:
return
# Re-fetch team to get current wallet (prevent stale data)
team = await get_team_by_owner(interaction.user.id)
if not team or team["wallet"] < SCOUT_TOKEN_COST:
await interaction.response.edit_message(
content="You don't have enough ₼ for a scout token.",
view=None,
)
self.stop()
return
# Deduct currency
new_wallet = team["wallet"] - SCOUT_TOKEN_COST
try:
await db_patch(
"teams", object_id=team["id"], params=[("wallet", new_wallet)]
)
except Exception as e:
logger.error(f"Failed to deduct scout token cost: {e}")
await interaction.response.edit_message(
content="Something went wrong processing your purchase. Try again!",
view=None,
)
self.stop()
return
self.scouter_team = team
self.scouter_team["wallet"] = new_wallet
self.value = True
await interaction.response.edit_message(
content=f"Scout token purchased for {SCOUT_TOKEN_COST}₼! Scouting your card...",
view=None,
)
self.stop()
@discord.ui.button(label="No thanks", style=discord.ButtonStyle.grey)
async def cancel_button(
self, interaction: discord.Interaction, button: discord.ui.Button
):
if interaction.user.id != self.responder_id:
return
await interaction.response.edit_message(
content="Saving that money. Smart.",
view=None,
)
self.stop()