feat: limit scouting to Standard/Premium packs, simplify scout view

- Add SCOUTABLE_PACK_TYPES env var (default: Standard,Premium) to control
  which pack types offer scout opportunities
- Unify embed construction into build_scout_embed() — removes 3 near-duplicate
  embed builders across scout_view.py and scouting.py
- Replace manual total_scouts counter with derived property from claims dict
- Remove redundant db_get("current") API call per scout click — use PD_SEASON
- Remove duplicate expiry computation in create_scout_opportunity
- Move send_to_channel to top-level import, remove redundant local import
- Update tests to match simplified code

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-03-06 21:12:46 -06:00 committed by cal
parent 0432f9d3f4
commit 1b83be89bb
4 changed files with 99 additions and 113 deletions

View File

@ -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 <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"],
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 "

View File

@ -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)

View File

@ -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 <t:{expires_unix}:R>."
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 <t:{expires_unix}:R>."
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
)

View File

@ -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")