""" 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_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_post(f'teams/{team["id"]}/money/-{SCOUT_TOKEN_COST}') 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()