Files
meshcore-bot/modules/commands/dice_command.py
T
agessaman fc63039a48 fix: address Codex review of tonight's work
Correctness:

- {path_distance} was always blank in production. The resolution code that
  builds repeater_info dropped latitude/longitude in every branch, so the
  calculator could never find a coordinate. My tests passed hand-built
  dicts straight to the calculator and never exercised the builder, which
  is why they stayed green. Coordinates are now carried through all four
  construction sites, and the new tests drive _lookup_repeater_names via
  its lookup_func hook so the real builder runs.

- The #80 route guard was defeated two ways in the channel handler. When
  the RF data was an uncorrelated fallback, control fell through to the
  raw-hex and routing_info fallbacks below, which took the route from the
  unrelated packet anyway; the guard had actually made that path
  reachable. message.routing_info was also assigned unconditionally, and
  the path command reads it. "Not attributable" is now a terminal branch
  and the routing_info hand-off checks provenance.

- Same fix was incomplete for DMs: routing_info was captured and turned
  into path_info before the provenance check ran, so the later check only
  declined to overwrite an already-wrong value. Guarded at the source.

- Rendering could transmit for real. Capture only intercepts
  send_response, but advert calls send_advert() directly and
  send_response_chunked never checked capture_sink. Chunked sends are now
  captured, and rendering is opt-in via BaseCommand.render_safe (default
  False) instead of a denylist that cannot be complete. This also closes
  the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
  longer broadcast configuration to a channel.

- Multi-part rendered output was rejoined into one oversized send.
  Scheduled messages are now split to the RF body budget and sent through
  send_channel_messages_chunked, on character boundaries so multi-byte
  text is not corrupted.

- _last_path_distance_km is instance state that was only set on success,
  so an invalid path request could show the previous request's distance.
  Reset at the start of every execute().

- Stale-contact retries were only counted on non-OK results, so timeouts
  and exceptions left a contact eligible forever and could recreate the
  storm. All failed attempts count now.

Web viewer:

- A failed config reload was reported as a successful save. The API and
  UI now distinguish "saved and active" from "saved, restart needed".

- Preview count was unbounded; clamped to 1-20.

- update_ini_values is a read-modify-replace with no locking, so two
  concurrent viewer saves could lose one. Serialised behind a lock.
2026-08-22 00:16:03 -07:00

327 lines
11 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 matches_keyword(self, message: MeshMessage) -> bool:
"""Override to handle dice-specific matching.
Args:
message: The received message.
Returns:
bool: True if message is a dice command, False otherwise.
"""
content_lower = self.cleanup_message_for_matching(message)
# Check for exact "dice" match
if content_lower == "dice":
return True
# Check for dice with parameters (dice d20, dice 20, dice d6, etc.)
# Match any message starting with "dice " - validation happens in execute()
if content_lower.startswith("dice "):
words = content_lower.split()
if len(words) >= 2 and words[0] == "dice":
return True # Match any dice command, validation in execute()
return False
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.isdigit():
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.
"""
content = message.content.strip()
# Handle command-style messages
if content.startswith('!'):
content = content[1:].strip()
# Default to d6 if no specification
if content.lower() == "dice":
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
dice_part = content[5:].strip() # Get everything after "dice "
# 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)