From 33260fd5fa5f59d71fe1e5b32432cd1d3b24fbad Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 9 Mar 2026 13:12:35 -0500 Subject: [PATCH 1/3] feat: add buy-scout-token option when daily limit exceeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user exceeds their 2/day scout token limit, they are now offered a button to purchase an extra token for 200₼ instead of being blocked. Updates /scout-tokens message to mention the purchase option. Co-Authored-By: Claude Opus 4.6 --- cogs/economy_new/scouting.py | 12 +++-- discord_ui/scout_view.py | 101 +++++++++++++++++++++++++++++++++-- helpers/scouting.py | 1 + 3 files changed, 106 insertions(+), 8 deletions(-) diff --git a/cogs/economy_new/scouting.py b/cogs/economy_new/scouting.py index 118927a..53d2e8d 100644 --- a/cogs/economy_new/scouting.py +++ b/cogs/economy_new/scouting.py @@ -10,11 +10,14 @@ from discord import app_commands from discord.ext import commands, tasks from api_calls import db_get -from helpers.scouting import SCOUT_TOKENS_PER_DAY, get_scout_tokens_used +from helpers.scouting import ( + SCOUT_TOKEN_COST, + SCOUT_TOKENS_PER_DAY, + get_scout_tokens_used, +) from helpers.utils import int_timestamp from helpers.discord_utils import get_team_embed from helpers.main import get_team_by_owner -from helpers.constants import PD_SEASON, IMAGES logger = logging.getLogger("discord_app") @@ -54,7 +57,10 @@ class Scouting(commands.Cog): ) if tokens_remaining == 0: - embed.description += "\n\nYou've used all your tokens! Check back tomorrow." + embed.description += ( + f"\n\nYou've used all your free tokens! " + f"You can still scout by purchasing a token for **{SCOUT_TOKEN_COST}₼**." + ) await interaction.followup.send(embed=embed, ephemeral=True) diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 7dcecff..584b168 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -11,9 +11,10 @@ import logging import discord -from api_calls import db_get, db_post +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, @@ -167,11 +168,27 @@ class ScoutButton(discord.ui.Button): 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, + # Offer to buy an extra scout token + buy_view = BuyScoutTokenView( + scouter_team=scouter_team, + responder_id=interaction.user.id, ) - return + 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: @@ -272,3 +289,77 @@ class ScoutButton(discord.ui.Button): 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 + await db_patch("teams", object_id=team["id"], params=[("wallet", new_wallet)]) + + 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() diff --git a/helpers/scouting.py b/helpers/scouting.py index 6c926bb..22d95d9 100644 --- a/helpers/scouting.py +++ b/helpers/scouting.py @@ -20,6 +20,7 @@ from helpers.constants import IMAGES, PD_SEASON logger = logging.getLogger("discord_app") SCOUT_TOKENS_PER_DAY = 2 +SCOUT_TOKEN_COST = 200 # Currency cost to buy an extra scout token 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()} -- 2.25.1 From a509a4ebf5ff1cc1a5925e042bd68003130b7874 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 9 Mar 2026 13:16:48 -0500 Subject: [PATCH 2/3] fix: prevent scout view timeout reset when embed updates message.edit(view=self) re-registers the view in discord.py's ViewStore, resetting the 30-minute timeout timer. Scouted packs never showed "Scout Window Closed" because each scout pushed the timeout further out. Co-Authored-By: Claude Opus 4.6 --- discord_ui/scout_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 584b168..277b275 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -88,7 +88,7 @@ class ScoutView(discord.ui.View): ) try: - await self.message.edit(embed=embed, view=self) + await self.message.edit(embed=embed) except Exception as e: logger.error(f"Failed to update scout message: {e}") -- 2.25.1 From db15993b021ba33c7d3f219d7f5a84882b8f0976 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Mon, 9 Mar 2026 13:25:44 -0500 Subject: [PATCH 3/3] fix: handle db_patch failure in buy scout token flow Wrap the wallet deduction in try/except so a failed db_patch immediately stops the view and shows an error, instead of leaving it open for 30s. Co-Authored-By: Claude Opus 4.6 --- discord_ui/scout_view.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/discord_ui/scout_view.py b/discord_ui/scout_view.py index 277b275..9617c9d 100644 --- a/discord_ui/scout_view.py +++ b/discord_ui/scout_view.py @@ -339,7 +339,18 @@ class BuyScoutTokenView(discord.ui.View): # Deduct currency new_wallet = team["wallet"] - SCOUT_TOKEN_COST - await db_patch("teams", object_id=team["id"], params=[("wallet", new_wallet)]) + 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 -- 2.25.1