diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 83712e6..a5a64ba 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -11,15 +11,15 @@ import logging import discord -from api_calls import db_get, db_post +from api_calls import db_post from helpers.main import get_team_by_owner, get_card_embeds from helpers.scouting import ( SCOUT_TOKENS_PER_DAY, - build_scouted_card_list, + build_scout_embed, get_scout_tokens_used, ) from helpers.utils import int_timestamp -from helpers.discord_utils import get_team_embed +from helpers.discord_utils import get_team_embed, send_to_channel from helpers.constants import IMAGES, PD_SEASON logger = logging.getLogger("discord_app") @@ -60,8 +60,6 @@ class ScoutView(discord.ui.View): 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( @@ -71,30 +69,21 @@ class ScoutView(discord.ui.View): ) 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 - 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 ." - 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"], + 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: @@ -109,22 +98,12 @@ class ScoutView(discord.ui.View): 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"], + 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: @@ -228,14 +207,12 @@ class ScoutButton(discord.ui.Button): ) # 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, + "season": PD_SEASON, "created": int_timestamp(), }, ) @@ -246,7 +223,6 @@ class ScoutButton(discord.ui.Button): 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() @@ -269,8 +245,6 @@ class ScoutButton(discord.ui.Button): # 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 " diff --git a/helpers/main.py b/helpers/main.py index 4dfc659..0b989a5 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -1770,15 +1770,17 @@ async def open_st_pr_packs(all_packs: list, team: dict, context): await context.channel.send(content=f"Let's head down to {pack_channel.mention}!") await display_cards(all_cards, team, pack_channel, author, pack_cover=pack_cover) - # Create scout opportunities for each pack - from helpers.scouting import create_scout_opportunity + # Create scout opportunities for each pack (Standard/Premium only) + from helpers.scouting import create_scout_opportunity, SCOUTABLE_PACK_TYPES - for p_id in pack_ids: - pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] - if pack_cards: - await create_scout_opportunity( - pack_cards, team, pack_channel, author, context - ) + pack_type_name = all_packs[0].get("pack_type", {}).get("name") + if pack_type_name in SCOUTABLE_PACK_TYPES: + for p_id in pack_ids: + pack_cards = [c for c in all_cards if c.get("pack_id") == p_id] + if pack_cards: + await create_scout_opportunity( + pack_cards, team, pack_channel, author, context + ) if len(pack_ids) > 1: await asyncio.sleep(2) diff --git a/helpers/scouting.py b/helpers/scouting.py index 6666b94..6c926bb 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -7,6 +7,7 @@ and embed formatting for the scouting feature. import datetime import logging +import os import random import discord @@ -20,6 +21,8 @@ logger = logging.getLogger("discord_app") SCOUT_TOKENS_PER_DAY = 2 SCOUT_WINDOW_SECONDS = 1800 # 30 minutes +_scoutable_raw = os.environ.get("SCOUTABLE_PACK_TYPES", "Standard,Premium") +SCOUTABLE_PACK_TYPES = {s.strip() for s in _scoutable_raw.split(",") if s.strip()} # Rarity value → display symbol RARITY_SYMBOLS = { @@ -71,39 +74,70 @@ def _build_card_lines(cards: list[dict]) -> list[tuple[int, str]]: def build_scout_embed( opener_team: dict, - cards: list[dict], + cards: list[dict] = None, card_lines: list[tuple[int, str]] = None, expires_unix: int = None, + claims: dict[int, list[str]] = None, + total_scouts: int = 0, + closed: bool = False, ) -> tuple[discord.Embed, list[tuple[int, str]]]: """Build the embed shown above the scout buttons. Shows a shuffled list of cards (rarity + player name) so scouters know what's in the pack but not which button maps to which card. Returns (embed, card_lines) so the view can store the shuffled order. - """ - embed = get_team_embed(title="Scout Opportunity!", team=opener_team) - if card_lines is None: + Parameters + ---------- + closed : if True, renders the "Scout Window Closed" variant + claims : scouted card tracking dict for build_scouted_card_list + total_scouts : number of scouts so far (for title display) + """ + if card_lines is None and cards is not None: card_lines = _build_card_lines(cards) - card_list = "\n".join(line for _, line in card_lines) - - if expires_unix: - time_line = f"Scout window closes ." + if claims and card_lines: + card_list = build_scouted_card_list(card_lines, claims) + elif card_lines: + card_list = "\n".join(line for _, line in card_lines) else: - time_line = "Scout window closes in **30 minutes**." + card_list = "" - embed.description = ( - f"**{opener_team['lname']}** just opened a pack!\n\n" - f"**Cards in this pack:**\n{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"], - ) + if closed: + if total_scouts > 0: + title = f"Scout Window Closed ({total_scouts} scouted)" + else: + title = "Scout Window Closed" + elif total_scouts > 0: + title = f"Scout Opportunity! ({total_scouts} scouted)" + else: + title = "Scout Opportunity!" + + embed = get_team_embed(title=title, team=opener_team) + + if closed: + embed.description = f"**{opener_team['lname']}**'s pack\n\n" f"{card_list}" + embed.set_footer( + text=f"Paper Dynasty Season {PD_SEASON}", + icon_url=IMAGES["logo"], + ) + else: + if expires_unix: + time_line = f"Scout window closes ." + else: + time_line = "Scout window closes in **30 minutes**." + + embed.description = ( + f"**{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"], + ) return embed, card_lines @@ -163,7 +197,9 @@ async def create_scout_opportunity( return now = datetime.datetime.now() - expires_at = int_timestamp(now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)) + expires_dt = now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS) + expires_at = int_timestamp(expires_dt) + expires_unix = int(expires_dt.timestamp()) created = int_timestamp(now) card_ids = [c["id"] for c in pack_cards] @@ -183,9 +219,6 @@ async def create_scout_opportunity( logger.error(f"Failed to create scout opportunity: {e}") return - expires_unix = int( - (now + datetime.timedelta(seconds=SCOUT_WINDOW_SECONDS)).timestamp() - ) embed, card_lines = build_scout_embed( opener_team, pack_cards, expires_unix=expires_unix ) diff --git a/tests/scouting/test_scout_view.py b/tests/scouting/test_scout_view.py index 906bb97..186c0b1 100644 --- a/tests/scouting/test_scout_view.py +++ b/tests/scouting/test_scout_view.py @@ -210,14 +210,12 @@ class TestScoutButtonSuccess: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_successful_scout_creates_card_copy( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -230,7 +228,6 @@ class TestScoutButtonSuccess: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -369,14 +366,12 @@ class TestMultiScout: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_two_users_can_scout_same_card( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -388,7 +383,6 @@ class TestMultiScout: """Two different users should both be able to scout the same card.""" view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -430,14 +424,12 @@ class TestMultiScout: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_same_user_cannot_scout_twice( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -449,7 +441,6 @@ class TestMultiScout: view = self._make_view_with_message(sample_cards, opener_team, mock_bot) mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -579,7 +570,9 @@ class TestScoutViewTimeout: view.card_lines = [ (c["player"]["player_id"], f"Line {i}") for i, c in enumerate(sample_cards) ] - view.total_scouts = 5 + # Set up claims so total_scouts property returns 5 + pid = sample_cards[0]["player"]["player_id"] + view.claims[pid] = ["Team A", "Team B", "Team C", "Team D", "Team E"] view.message = AsyncMock(spec=discord.Message) view.message.edit = AsyncMock() @@ -617,14 +610,12 @@ class TestProcessingUserCleanup: @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_processing_cleared_on_success( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -648,7 +639,6 @@ class TestProcessingUserCleanup: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -744,24 +734,22 @@ class TestProcessingUserCleanup: # --------------------------------------------------------------------------- -# db_get("current") fallback +# Rewards use PD_SEASON constant # --------------------------------------------------------------------------- -class TestCurrentSeasonFallback: - """Tests for the fallback when db_get('current') returns None.""" +class TestRewardsSeason: + """Tests that reward records always use the PD_SEASON constant.""" @pytest.mark.asyncio @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) - async def test_current_returns_none_uses_fallback( + async def test_rewards_use_pd_season( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, sample_cards, @@ -769,7 +757,7 @@ class TestCurrentSeasonFallback: scouter_team, mock_bot, ): - """When db_get('current') returns None, rewards should use PD_SEASON fallback.""" + """Reward records should always use the PD_SEASON constant for season.""" view = ScoutView( scout_opp_id=1, cards=sample_cards, @@ -785,7 +773,6 @@ class TestCurrentSeasonFallback: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = None # db_get("current") returns None mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -803,14 +790,13 @@ class TestCurrentSeasonFallback: assert view.total_scouts == 1 assert 12345 in view.scouted_users - # Verify the rewards POST used fallback values + # Verify the rewards POST uses PD_SEASON # Order: scout_claims (0), cards (1), rewards (2) from helpers.constants import PD_SEASON reward_call = mock_db_post.call_args_list[2] assert reward_call[0][0] == "rewards" assert reward_call[1]["payload"]["season"] == PD_SEASON - assert reward_call[1]["payload"]["week"] == 1 # --------------------------------------------------------------------------- @@ -822,17 +808,15 @@ class TestShinyScoutNotification: """Tests for the rare-card notification path (rarity >= 5).""" @pytest.mark.asyncio - @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_shiny_card_sends_notification( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, @@ -857,7 +841,6 @@ class TestShinyScoutNotification: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -877,17 +860,15 @@ class TestShinyScoutNotification: assert call_args[0][1] == "pd-network-news" @pytest.mark.asyncio - @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_non_shiny_card_no_notification( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, @@ -912,7 +893,6 @@ class TestShinyScoutNotification: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] @@ -930,17 +910,15 @@ class TestShinyScoutNotification: mock_send_to_channel.assert_not_called() @pytest.mark.asyncio - @patch("helpers.discord_utils.send_to_channel", new_callable=AsyncMock) + @patch("discord_ui.scout_view.send_to_channel", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_card_embeds", new_callable=AsyncMock) @patch("discord_ui.scout_view.db_post", new_callable=AsyncMock) - @patch("discord_ui.scout_view.db_get", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_scout_tokens_used", new_callable=AsyncMock) @patch("discord_ui.scout_view.get_team_by_owner", new_callable=AsyncMock) async def test_shiny_notification_failure_does_not_crash( self, mock_get_team, mock_get_tokens, - mock_db_get, mock_db_post, mock_card_embeds, mock_send_to_channel, @@ -965,7 +943,6 @@ class TestShinyScoutNotification: mock_get_team.return_value = scouter_team mock_get_tokens.return_value = 0 - mock_db_get.return_value = {"season": 4, "week": 1} # db_get("current") mock_db_post.return_value = {"id": 100} mock_card_embeds.return_value = [Mock(spec=discord.Embed)] mock_send_to_channel.side_effect = Exception("Channel not found")