- 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>
282 lines
8.9 KiB
Python
282 lines
8.9 KiB
Python
"""
|
||
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',
|
||
]
|