diff --git a/bot.py b/bot.py index 1111b82..4e893dc 100644 --- a/bot.py +++ b/bot.py @@ -124,6 +124,7 @@ class SBABot(commands.Bot): from commands.spoiler import setup_spoiler from commands.injuries import setup_injuries from commands.gameplay import setup_gameplay + from commands.dev import setup_dev # Define command packages to load command_packages = [ @@ -143,6 +144,7 @@ class SBABot(commands.Bot): ("spoiler", setup_spoiler), ("injuries", setup_injuries), ("gameplay", setup_gameplay), + ("dev", setup_dev), # Dev-only commands (admin restricted) ] total_successful = 0 diff --git a/commands/dev/__init__.py b/commands/dev/__init__.py new file mode 100644 index 0000000..e7aadbb --- /dev/null +++ b/commands/dev/__init__.py @@ -0,0 +1,47 @@ +""" +Dev Commands Package + +Developer-only commands for testing. Hidden from regular users. +""" +import logging +from discord.ext import commands + +from .loaded_dice import LoadedDiceCommands + +logger = logging.getLogger(__name__) + + +async def setup_dev(bot: commands.Bot) -> tuple[int, int, list[str]]: + """ + Setup all dev command modules. + + Returns: + tuple: (successful_count, failed_count, failed_modules) + """ + dev_cogs = [ + ("LoadedDiceCommands", LoadedDiceCommands), + ] + + successful = 0 + failed = 0 + failed_modules = [] + + for cog_name, cog_class in dev_cogs: + try: + await bot.add_cog(cog_class(bot)) + logger.info(f"✅ Loaded {cog_name}") + successful += 1 + except Exception as e: + logger.error(f"❌ Failed to load {cog_name}: {e}", exc_info=True) + failed += 1 + failed_modules.append(cog_name) + + if failed == 0: + logger.info(f"🎉 All {successful} dev command modules loaded successfully") + else: + logger.warning(f"âš ī¸ Dev commands loaded with issues: {successful} successful, {failed} failed") + + return successful, failed, failed_modules + + +__all__ = ['setup_dev', 'LoadedDiceCommands'] diff --git a/commands/dev/loaded_dice.py b/commands/dev/loaded_dice.py new file mode 100644 index 0000000..658872f --- /dev/null +++ b/commands/dev/loaded_dice.py @@ -0,0 +1,281 @@ +""" +Loaded Dice - Developer Testing System + +HIDDEN DEV COMMAND for testing dice-dependent gameplay mechanics. +Use `!loaded <2d6_total> ` to set up a loaded roll for your next /ab. + +Example: + !loaded 3 7 15 → Next /ab will roll 3 for d6, 7 for 2d6, 15 for d20 + +The loaded values are consumed after one use (one-shot). +Only users with Administrator permission can use this command. +""" +import logging +from dataclasses import dataclass +from typing import Optional + +import discord +from discord.ext import commands + +from utils.logging import get_contextual_logger +from views.embeds import EmbedColors, EmbedTemplate + + +logger = logging.getLogger(__name__) + + +@dataclass +class LoadedRoll: + """Represents a pending loaded roll for a user.""" + d6_1: int # First d6 (1-6) + d6_2_total: int # 2d6 total (2-12) + d20: int # d20 value (1-20) + user_id: int # User who set this up + + +# In-memory storage for pending loaded rolls +# Maps user_id -> LoadedRoll +_pending_loaded_rolls: dict[int, LoadedRoll] = {} + + +def set_loaded_roll(user_id: int, d6_1: int, d6_2_total: int, d20: int) -> None: + """ + Set up a loaded roll for a user's next /ab command. + + Args: + user_id: Discord user ID + d6_1: Value for the first d6 (1-6) + d6_2_total: Total for the 2d6 roll (2-12) + d20: Value for the d20 roll (1-20) + """ + _pending_loaded_rolls[user_id] = LoadedRoll( + d6_1=d6_1, + d6_2_total=d6_2_total, + d20=d20, + user_id=user_id + ) + logger.info(f"Loaded roll set for user {user_id}: d6={d6_1}, 2d6={d6_2_total}, d20={d20}") + + +def get_and_consume_loaded_roll(user_id: int) -> Optional[LoadedRoll]: + """ + Get and consume a pending loaded roll for a user. + + Returns the loaded roll if one exists, then removes it from storage. + Returns None if no loaded roll is pending. + + Args: + user_id: Discord user ID + + Returns: + LoadedRoll if pending, None otherwise + """ + loaded = _pending_loaded_rolls.pop(user_id, None) + if loaded: + logger.info(f"Consumed loaded roll for user {user_id}: d6={loaded.d6_1}, 2d6={loaded.d6_2_total}, d20={loaded.d20}") + return loaded + + +def has_pending_loaded_roll(user_id: int) -> bool: + """Check if a user has a pending loaded roll.""" + return user_id in _pending_loaded_rolls + + +def clear_loaded_roll(user_id: int) -> bool: + """ + Clear a pending loaded roll without consuming it. + + Returns True if a roll was cleared, False if none existed. + """ + if user_id in _pending_loaded_rolls: + del _pending_loaded_rolls[user_id] + logger.info(f"Cleared loaded roll for user {user_id}") + return True + return False + + +class LoadedDiceCommands(commands.Cog): + """ + Developer commands for loaded dice testing. + + These are prefix commands (!) to avoid cluttering the slash command list. + Only administrators can use these commands. + """ + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.LoadedDiceCommands') + + async def cog_check(self, ctx: commands.Context) -> bool: + """Only allow administrators to use dev commands.""" + if not ctx.guild: + return False + + if not isinstance(ctx.author, discord.Member): + return False + + if ctx.author.id != 258104532423147520: + # Silently fail - don't reveal command exists to non-admins + return False + + return True + + @commands.command(name="loaded", aliases=["load", "ld"]) + async def set_loaded_dice( + self, + ctx: commands.Context, + d6_1: int, + d6_2_total: int, + d20: int, + target_user_id: int | None = None + ): + """ + Set up a loaded roll for the next /ab command. + + Usage: + !loaded <2d6_total> [user_id] + + Examples: + !loaded 3 7 15 → Load for yourself + !loaded 5 12 1 123456789 → Load for user ID 123456789 + + The loaded values are consumed after one /ab use. + """ + # Determine target user (default to command author) + target_id = target_user_id or ctx.author.id + + self.logger.info( + f"Loaded dice command from {ctx.author}: d6={d6_1}, 2d6={d6_2_total}, d20={d20}, target_id={target_id}" + ) + + # Validate d6_1 (1-6) + if d6_1 < 1 or d6_1 > 6: + await ctx.send("❌ First d6 must be 1-6", delete_after=10) + return + + # Validate 2d6 total (2-12) + if d6_2_total < 2 or d6_2_total > 12: + await ctx.send("❌ 2d6 total must be 2-12", delete_after=10) + return + + # Validate d20 (1-20) + if d20 < 1 or d20 > 20: + await ctx.send("❌ d20 must be 1-20", delete_after=10) + return + + # Store the loaded roll for target user + set_loaded_roll(target_id, d6_1, d6_2_total, d20) + + # Create confirmation embed + card_type = "Batter" if d6_1 <= 3 else "Pitcher" + + # Build description based on target + if target_id == ctx.author.id: + description = "Your next `/ab` roll will use these values:" + else: + description = f"Next `/ab` roll for <@{target_id}> will use these values:" + + embed = EmbedTemplate.create_base_embed( + title="🎲 Loaded Dice Set", + description=description, + color=EmbedColors.WARNING + ) + + embed.add_field( + name="Loaded Values", + value=f"```\nd6: {d6_1} ({card_type} card)\n2d6: {d6_2_total}\nd20: {d20}\n```", + inline=False + ) + + # Special indicators + specials = [] + if d20 == 1: + specials.append("âš ī¸ Wild Pitch check") + elif d20 == 2: + specials.append("âš ī¸ Passed Ball check") + + if d6_1 == 6 and d6_2_total in [7, 8, 9, 10, 11, 12]: + injury_rating = 13 - d6_2_total + specials.append(f"🩹 Injury check (rating {injury_rating})") + + if specials: + embed.add_field( + name="Special Plays", + value='\n'.join(specials), + inline=False + ) + + # Footer with target info + if target_id == ctx.author.id: + footer = "🔧 Dev Mode â€ĸ Values consumed on next /ab â€ĸ Use !unload to cancel" + else: + footer = f"🔧 Dev Mode â€ĸ Target: {target_id} â€ĸ Use !unload {target_id} to cancel" + + embed.set_footer(text=footer) + + await ctx.send(embed=embed, delete_after=30) + + # Delete the command message to keep it hidden + try: + await ctx.message.delete() + except discord.Forbidden: + pass # Can't delete in DMs or without permissions + + @commands.command(name="unload", aliases=["unld", "clearload"]) + async def clear_loaded_dice(self, ctx: commands.Context, target_user_id: int | None = None): + """Clear any pending loaded dice without using them.""" + target_id = target_user_id or ctx.author.id + self.logger.info(f"Unload command from {ctx.author}, target_id={target_id}") + + if clear_loaded_roll(target_id): + if target_id == ctx.author.id: + await ctx.send("✅ Loaded dice cleared", delete_after=10) + else: + await ctx.send(f"✅ Loaded dice cleared for <@{target_id}>", delete_after=10) + else: + await ctx.send("â„šī¸ No loaded dice pending", delete_after=10) + + # Delete the command message + try: + await ctx.message.delete() + except discord.Forbidden: + pass + + @commands.command(name="checkload", aliases=["chkld"]) + async def check_loaded_dice(self, ctx: commands.Context, target_user_id: int | None = None): + """Check if loaded dice are pending for a user.""" + target_id = target_user_id or ctx.author.id + self.logger.info(f"Check load command from {ctx.author}, target_id={target_id}") + + if target_id in _pending_loaded_rolls: + loaded = _pending_loaded_rolls[target_id] + card_type = "Batter" if loaded.d6_1 <= 3 else "Pitcher" + target_text = "" if target_id == ctx.author.id else f" for <@{target_id}>" + await ctx.send( + f"🎲 Loaded{target_text}: d6={loaded.d6_1} ({card_type}), 2d6={loaded.d6_2_total}, d20={loaded.d20}", + delete_after=15 + ) + else: + await ctx.send("â„šī¸ No loaded dice pending", delete_after=10) + + # Delete the command message + try: + await ctx.message.delete() + except discord.Forbidden: + pass + + +async def setup(bot: commands.Bot): + """Load the loaded dice commands cog.""" + await bot.add_cog(LoadedDiceCommands(bot)) + + +# Export for use by dice commands +__all__ = [ + 'LoadedDiceCommands', + 'LoadedRoll', + 'set_loaded_roll', + 'get_and_consume_loaded_roll', + 'has_pending_loaded_roll', + 'clear_loaded_roll', +] diff --git a/commands/dice/rolls.py b/commands/dice/rolls.py index a4d8752..0935005 100644 --- a/commands/dice/rolls.py +++ b/commands/dice/rolls.py @@ -18,6 +18,7 @@ from utils.team_utils import get_user_major_league_team from utils.text_utils import split_text_for_fields from utils.dice_utils import DiceRoll, parse_and_roll_multiple_dice, parse_and_roll_single_dice from views.embeds import EmbedColors, EmbedTemplate +from commands.dev.loaded_dice import get_and_consume_loaded_roll from .chart_data import ( INFIELD_X_CHART, OUTFIELD_X_CHART, @@ -130,9 +131,26 @@ class DiceRollCommands(commands.Cog): await interaction.response.defer() embed_color = await self._get_channel_embed_color(interaction) - # Use the standard baseball dice combination - dice_notation = "1d6;2d6;1d20" - roll_results = parse_and_roll_multiple_dice(dice_notation) + # Check for loaded dice (dev testing) + loaded = get_and_consume_loaded_roll(interaction.user.id) + if loaded: + self.logger.info( + f"Using loaded dice for {interaction.user}: d6={loaded.d6_1}, 2d6={loaded.d6_2_total}, d20={loaded.d20}" + ) + # Create DiceRoll objects from loaded values + # For 2d6, we split the total into two dice (arbitrary split that sums correctly) + d6_2a = min(loaded.d6_2_total - 1, 6) # First die (max 6) + d6_2b = loaded.d6_2_total - d6_2a # Second die gets remainder + roll_results = [ + DiceRoll("1d6", 1, 6, [loaded.d6_1], loaded.d6_1), + DiceRoll("2d6", 2, 6, [d6_2a, d6_2b], loaded.d6_2_total), + DiceRoll("1d20", 1, 20, [loaded.d20], loaded.d20), + ] + dice_notation = "1d6;2d6;1d20" + else: + # Use the standard baseball dice combination + dice_notation = "1d6;2d6;1d20" + roll_results = parse_and_roll_multiple_dice(dice_notation) injury_risk = (roll_results[0].total == 6) and (roll_results[1].total in [7, 8, 9, 10, 11, 12]) d6_total = roll_results[1].total