mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-01 16:48:26 +00:00
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.
327 lines
11 KiB
Python
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)
|