Files
meshcore-bot/modules/commands/dice_command.py
T
agessaman 11905a0679 fix: read superscript and circled digits in arguments as text instead of crashing
Eight parsers checked str.isdigit() and then called int(). isdigit() also accepts characters like ², ① and ⁴⁵, which int() rejects, so wx and gwx raised before their error handling for "wx seattle ²" and sent no reply, dice and roll raised from their parsers, and repeater's batch size and purge days and the feed regex: and regex_cond: group numbers failed the same way. They now check str.isdecimal(), which accepts exactly the digits int() does (ASCII and other scripts' decimal digits), so the rest are read as text.
2026-10-01 20:51:56 -07:00

298 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Dice command for the MeshCore Bot
Handles dice rolling for D&D and other tabletop games
"""
import random
from ..models import MeshMessage
from .base_command import BaseCommand
class DiceCommand(BaseCommand):
"""Handles dice rolling commands"""
# Plugin metadata
# Read-only informational output; safe for scheduled {cmd:...} rendering.
render_safe = True
name = "dice"
keywords = ['dice']
description = "Roll dice for D&D and tabletop games. Use 'dice' for d6, 'dice d20' for d20, 'dice 2d6' for 2d6, 'dice d10 d6' for mixed dice, 'dice decade' for decade die (00-90), etc."
category = "games"
# Documentation
short_description = "Roll dice for tabletop games"
usage = "dice [NdX|dX|decade]"
examples = [
"dice",
"dice d20",
"dice 2d6",
"dice d10 d6",
"dice decade"
]
parameters = [
{"name": "dice", "description": "Dice notation: d6, 2d8, d10 d6, decade"}
]
# Standard D&D dice types
DICE_TYPES = {
'd4': 4,
'd6': 6,
'd8': 8,
'd10': 10,
'd12': 12,
'd16': 16,
'd20': 20
}
def __init__(self, bot):
"""Initialize the dice command.
Args:
bot: The bot instance.
"""
super().__init__(bot)
self.dice_enabled = self.get_config_value('Dice_Command', 'enabled', fallback=True, value_type='bool')
def can_execute(self, message: MeshMessage, skip_channel_check: bool = False) -> bool:
"""Check if this command can be executed with the given message.
Args:
message: The message triggering the command.
Returns:
bool: True if command is enabled and checks pass, False otherwise.
"""
if not self.dice_enabled:
return False
return super().can_execute(message)
def get_help_text(self) -> str:
"""Get help text for the dice command.
Returns:
str: Help text string.
"""
return self.translate('commands.dice.help')
def parse_dice_notation(self, dice_input: str) -> tuple:
"""Parse dice notation and return (sides, count, is_decade).
Supports: d20, 20, d6, 6, 2d6, 4d10, decade, etc.
Args:
dice_input: The dice string to parse.
Returns:
tuple: (sides, count, is_decade) or (None, None, False) if invalid.
"""
dice_input = dice_input.strip().lower()
# Handle special D&D dice types
if dice_input == "decade":
return (10, 1, True) # Decade die (00-90)
# Handle multiple dice notation (e.g., "2d6", "4d10", "3d20")
if 'd' in dice_input:
parts = dice_input.split('d')
if len(parts) == 2:
count_str, sides_str = parts
# Handle cases like "d6" (no count specified)
if not count_str:
count = 1
else:
try:
count = int(count_str)
if count < 1 or count > 10: # Reasonable limit
return None, None, False
except ValueError:
return None, None, False
# Parse sides
try:
sides = int(sides_str)
if sides in self.DICE_TYPES.values():
return sides, count, False
else:
return None, None, False
except ValueError:
return None, None, False
# Handle direct number (e.g., "20" -> d20)
if dice_input.isdecimal():
sides = int(dice_input)
if sides in self.DICE_TYPES.values():
return sides, 1, False
else:
return None, None, False
# Handle dice type names (e.g., "d20", "d6")
if dice_input in self.DICE_TYPES:
return self.DICE_TYPES[dice_input], 1, False
return None, None, False
def parse_mixed_dice(self, dice_input: str) -> list:
"""Parse mixed dice notation and return list of (sides, count, is_decade) tuples.
Supports: "d10 d6", "2d6 d20", "d4 d8 d12", "decade", etc.
Args:
dice_input: The space-separated dice string.
Returns:
list: List of (sides, count, is_decade) tuples, or empty list if invalid.
"""
dice_input = dice_input.strip()
if not dice_input:
return []
# Split by spaces to get individual dice specifications
dice_specs = dice_input.split()
parsed_dice = []
for spec in dice_specs:
sides, count, is_decade = self.parse_dice_notation(spec)
if sides is None:
return [] # Invalid specification found
parsed_dice.append((sides, count, is_decade))
return parsed_dice
def roll_dice(self, sides: int, count: int = 1, is_decade: bool = False) -> list:
"""Roll dice and return list of results.
For decade dice, returns values 0, 10, 20, ..., 90 (formatted as 00, 10, 20, etc.)
Args:
sides: Number of sides on the die.
count: Number of dice to roll.
is_decade: Whether it's a decade die (00-90).
Returns:
list: List of integer results.
"""
if is_decade:
# Decade die: 00, 10, 20, 30, 40, 50, 60, 70, 80, 90
return [random.randint(0, 9) * 10 for _ in range(count)]
else:
return [random.randint(1, sides) for _ in range(count)]
def format_dice_result(self, sides: int, count: int, results: list, is_decade: bool = False) -> str:
"""Format dice roll results into a readable string.
Args:
sides: Number of sides.
count: Number of dice.
results: List of roll results.
is_decade: Whether it's a decade die.
Returns:
str: Formatted result string.
"""
if is_decade:
# Format decade dice results (00, 10, 20, etc.)
formatted_results = [f"{r:02d}" for r in results]
if count == 1:
return f"🎲 decade: {formatted_results[0]}"
else:
results_str = ", ".join(formatted_results)
total = sum(results)
return f"🎲 {count}decade: [{results_str}] = {total}"
else:
if count == 1:
# Single die roll
return self.translate('commands.dice.single_die', sides=sides, result=results[0])
else:
# Multiple dice
total = sum(results)
results_str = ", ".join(map(str, results))
return self.translate('commands.dice.multiple_dice', count=count, sides=sides, results=results_str, total=total)
def format_mixed_dice_result(self, dice_results: list) -> str:
"""Format mixed dice roll results into a readable string.
Args:
dice_results: list of tuples (sides, count, results_list, is_decade).
Returns:
str: Formatted result string for all dice.
"""
parts = []
grand_total = 0
for sides, count, results, is_decade in dice_results:
total = sum(results)
grand_total += total
if is_decade:
# Format decade dice
formatted_results = [f"{r:02d}" for r in results]
if count == 1:
parts.append(f"decade: {formatted_results[0]}")
else:
results_str = ", ".join(formatted_results)
parts.append(f"{count}decade: [{results_str}] = {total}")
else:
if count == 1:
parts.append(f"d{sides}: {results[0]}")
else:
results_str = ", ".join(map(str, results))
parts.append(f"{count}d{sides}: [{results_str}] = {total}")
result_str = " + ".join(parts)
if len(dice_results) > 1:
result_str += f" | Total: {grand_total}"
return f"🎲 {result_str}"
async def execute(self, message: MeshMessage) -> bool:
"""Execute the dice command.
Args:
message: The message triggering the command.
Returns:
bool: True if executed successfully, False otherwise.
"""
_trigger, dice_part = self.split_trigger_and_args(message.content)
# Default to d6 if no specification
if not dice_part:
sides = 6
count = 1
results = self.roll_dice(sides, count)
response = self.format_dice_result(sides, count, results)
return await self.send_response(message, response)
# Parse dice specification after the trigger keyword / alias
# Try parsing as mixed dice first (multiple dice types)
mixed_dice = self.parse_mixed_dice(dice_part)
if len(mixed_dice) > 1:
# Multiple dice types - roll each type
dice_results = []
for sides, count, is_decade in mixed_dice:
results = self.roll_dice(sides, count, is_decade)
dice_results.append((sides, count, results, is_decade))
# Format and send response
response = self.format_mixed_dice_result(dice_results)
return await self.send_response(message, response)
elif len(mixed_dice) == 1:
# Single dice type (could be multiple dice of same type like "2d6")
sides, count, is_decade = mixed_dice[0]
results = self.roll_dice(sides, count, is_decade)
response = self.format_dice_result(sides, count, results, is_decade)
return await self.send_response(message, response)
else:
# Invalid dice specification - return error with usage info
available_dice = ", ".join(list(self.DICE_TYPES.keys()) + ["decade"])
help_text = self.get_help_text()
error_msg = self.translate('commands.dice.invalid_dice_type', available=available_dice)
response = f"{error_msg}\n\n{help_text}"
return await self.send_response(message, response)