Add dev-only loaded dice command for testing /ab rolls

- New !loaded <d6> <2d6> <d20> [user_id] command for predetermined dice
- Loaded values consumed on next /ab roll (one-shot)
- Supports targeting other users by ID for testing
- Admin-restricted prefix commands (!loaded, !unload, !checkload)
- Self-contained in commands/dev/ package

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-07 22:45:01 -06:00
parent 889b3a4e2d
commit 6b35a14066
4 changed files with 351 additions and 3 deletions

2
bot.py
View File

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

47
commands/dev/__init__.py Normal file
View File

@ -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']

281
commands/dev/loaded_dice.py Normal file
View File

@ -0,0 +1,281 @@
"""
Loaded Dice - Developer Testing System
HIDDEN DEV COMMAND for testing dice-dependent gameplay mechanics.
Use `!loaded <d6> <2d6_total> <d20>` 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 <d6> <2d6_total> <d20> [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',
]

View File

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