- Consolidate SCOUT_TOKENS_PER_DAY and get_scout_tokens_used() into helpers/scouting.py (was duplicated across 3 files) - Add midnight_timestamp() utility to helpers/utils.py - Remove _build_scouted_ids() wrapper, use self.claims directly - Fix build_scout_embed return type annotation - Use Discord <t:UNIX:R> relative timestamps for scout window countdown - Add 66-test suite covering helpers, ScoutView, and cog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
291 lines
10 KiB
Python
291 lines
10 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, get_scout_tokens_used
|
|
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")
|
|
|
|
|
|
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()
|
|
# Total scout count
|
|
self.total_scouts = 0
|
|
|
|
for i, card in enumerate(cards):
|
|
button = ScoutButton(
|
|
card=card,
|
|
position=i,
|
|
scout_view=self,
|
|
)
|
|
self.add_item(button)
|
|
|
|
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
|
|
|
|
card_list = build_scouted_card_list(self.card_lines, self.claims)
|
|
|
|
title = f"Scout Opportunity! ({self.total_scouts} scouted)"
|
|
embed = get_team_embed(title=title, team=self.opener_team)
|
|
if self.expires_unix:
|
|
time_line = f"Scout window closes <t:{self.expires_unix}:R>."
|
|
else:
|
|
time_line = "Scout window closes in **30 minutes**."
|
|
|
|
embed.description = (
|
|
f"**{self.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"],
|
|
)
|
|
|
|
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
|
|
|
|
card_list = build_scouted_card_list(self.card_lines, self.claims)
|
|
|
|
if self.total_scouts > 0:
|
|
title = f"Scout Window Closed ({self.total_scouts} 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
|
|
|
|
# Prevent double-click race for same user
|
|
if interaction.user.id in view.processing_users:
|
|
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
|
|
|
|
# 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(),
|
|
},
|
|
)
|
|
|
|
# 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
|
|
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)
|
|
view.total_scouts += 1
|
|
|
|
# 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_users.discard(interaction.user.id)
|