""" DungeonMaster AI - MCP Fallback Handlers Provides fallback functionality when MCP server is unavailable. Local dice rolling ensures basic gameplay can continue. """ from __future__ import annotations import logging import random import re from datetime import datetime logger = logging.getLogger(__name__) class FallbackHandler: """ Provides fallback functionality when MCP server is unavailable. Currently supports: - Basic dice rolling (roll, roll_check) Future fallbacks could include: - Cached monster/spell data - Basic character stat lookups """ # Tools that have fallback implementations SUPPORTED_TOOLS: set[str] = {"roll", "roll_check", "mcp_roll", "mcp_roll_check"} # Regex for parsing dice notation # Matches: 2d6, 1d20+5, 4d6-2, d8, 2d10+3 DICE_PATTERN = re.compile( r"^(\d*)d(\d+)([+-]\d+)?$", re.IGNORECASE, ) # Advanced dice notation (keep highest/lowest) # Matches: 4d6kh3, 2d20kl1 ADVANCED_DICE_PATTERN = re.compile( r"^(\d+)d(\d+)(k[hl])(\d+)?([+-]\d+)?$", re.IGNORECASE, ) def can_handle(self, tool_name: str) -> bool: """Check if a fallback exists for this tool.""" return tool_name in self.SUPPORTED_TOOLS def get_unavailable_message(self) -> str: """User-friendly message about limited functionality.""" return ( "The game server is temporarily unavailable. " "Some features like rules lookup and character management are limited, " "but basic dice rolling still works." ) async def handle_roll( self, notation: str, reason: str | None = None, secret: bool = False, ) -> dict[str, object]: """ Local dice rolling fallback. Supports: - Basic: 2d6, 1d20+5, d8 - Keep highest: 4d6kh3 (roll 4d6, keep highest 3) - Keep lowest: 2d20kl1 (disadvantage) Args: notation: Dice notation string reason: Optional reason for the roll secret: Whether this is a secret roll (not shown to players) Returns: Result dict compatible with MCP roll tool format """ notation = notation.strip().lower() timestamp = datetime.now().isoformat() # Try advanced notation first (keep highest/lowest) advanced_match = self.ADVANCED_DICE_PATTERN.match(notation) if advanced_match: return self._roll_advanced( advanced_match, notation, reason, secret, timestamp, ) # Try basic notation basic_match = self.DICE_PATTERN.match(notation) if basic_match: return self._roll_basic( basic_match, notation, reason, secret, timestamp, ) # Invalid notation logger.warning(f"Invalid dice notation in fallback: {notation}") return { "success": False, "error": f"Invalid dice notation: {notation}", "degraded_mode": True, } def _roll_basic( self, match: re.Match[str], notation: str, reason: str | None, secret: bool, timestamp: str, ) -> dict[str, object]: """Handle basic dice notation like 2d6+3.""" num_dice = int(match.group(1)) if match.group(1) else 1 die_size = int(match.group(2)) modifier = int(match.group(3)) if match.group(3) else 0 # Sanity checks if num_dice < 1 or num_dice > 100: return { "success": False, "error": f"Invalid number of dice: {num_dice} (must be 1-100)", "degraded_mode": True, } if die_size < 2 or die_size > 100: return { "success": False, "error": f"Invalid die size: d{die_size} (must be d2-d100)", "degraded_mode": True, } # Roll the dice rolls = [random.randint(1, die_size) for _ in range(num_dice)] total = sum(rolls) + modifier # Build breakdown string if modifier > 0: breakdown = f"[{', '.join(map(str, rolls))}] + {modifier}" elif modifier < 0: breakdown = f"[{', '.join(map(str, rolls))}] - {abs(modifier)}" else: breakdown = f"[{', '.join(map(str, rolls))}]" return { "success": True, "notation": notation, "total": total, "rolls": rolls, "dice": rolls, # Alias for compatibility "individual_rolls": rolls, # Another alias "modifier": modifier, "breakdown": breakdown, "formatted": f"{notation} = {breakdown} = {total}", "reason": reason or "", "secret": secret, "timestamp": timestamp, "degraded_mode": True, } def _roll_advanced( self, match: re.Match[str], notation: str, reason: str | None, secret: bool, timestamp: str, ) -> dict[str, object]: """Handle advanced dice notation like 4d6kh3.""" num_dice = int(match.group(1)) die_size = int(match.group(2)) keep_type = match.group(3).lower() # kh or kl keep_count = int(match.group(4)) if match.group(4) else 1 modifier = int(match.group(5)) if match.group(5) else 0 # Sanity checks if num_dice < 1 or num_dice > 100: return { "success": False, "error": f"Invalid number of dice: {num_dice}", "degraded_mode": True, } if keep_count > num_dice: return { "success": False, "error": f"Cannot keep {keep_count} dice from {num_dice} rolled", "degraded_mode": True, } # Roll all dice rolls = [random.randint(1, die_size) for _ in range(num_dice)] # Keep highest or lowest sorted_rolls = sorted(rolls, reverse=(keep_type == "kh")) kept_rolls = sorted_rolls[:keep_count] dropped_rolls = sorted_rolls[keep_count:] total = sum(kept_rolls) + modifier # Build breakdown string kept_str = f"[{', '.join(map(str, kept_rolls))}]" dropped_str = f" (dropped: {', '.join(map(str, dropped_rolls))})" if dropped_rolls else "" if modifier > 0: breakdown = f"{kept_str}{dropped_str} + {modifier}" elif modifier < 0: breakdown = f"{kept_str}{dropped_str} - {abs(modifier)}" else: breakdown = f"{kept_str}{dropped_str}" return { "success": True, "notation": notation, "total": total, "rolls": rolls, "dice": rolls, "individual_rolls": rolls, "kept_rolls": kept_rolls, "dropped_rolls": dropped_rolls, "modifier": modifier, "breakdown": breakdown, "formatted": f"{notation} = {breakdown} = {total}", "reason": reason or "", "secret": secret, "timestamp": timestamp, "degraded_mode": True, } async def handle_roll_check( self, modifier: int = 0, dc: int | None = None, advantage: bool = False, disadvantage: bool = False, skill_name: str | None = None, ) -> dict[str, object]: """ Local ability check/save fallback. Rolls 1d20 with modifier, handles advantage/disadvantage. Args: modifier: Modifier to add to the roll dc: Difficulty class to check against advantage: Roll with advantage (2d20 keep highest) disadvantage: Roll with disadvantage (2d20 keep lowest) skill_name: Name of skill/ability for logging Returns: Result dict compatible with MCP roll_check format """ timestamp = datetime.now().isoformat() # Roll d20(s) if advantage and not disadvantage: rolls = [random.randint(1, 20), random.randint(1, 20)] d20_result = max(rolls) roll_type = "advantage" elif disadvantage and not advantage: rolls = [random.randint(1, 20), random.randint(1, 20)] d20_result = min(rolls) roll_type = "disadvantage" else: rolls = [random.randint(1, 20)] d20_result = rolls[0] roll_type = "normal" total = d20_result + modifier # Check success/failure success = None result_str = "" if dc is not None: success = total >= dc result_str = "SUCCESS" if success else "FAILURE" # Build breakdown if len(rolls) > 1: rolls_str = f"[{rolls[0]}, {rolls[1]}] → {d20_result}" else: rolls_str = str(d20_result) if modifier >= 0: breakdown = f"{rolls_str} + {modifier}" else: breakdown = f"{rolls_str} - {abs(modifier)}" # Build formatted string skill_part = f" ({skill_name})" if skill_name else "" dc_part = f" vs DC {dc}" if dc is not None else "" result_part = f" - {result_str}" if result_str else "" formatted = f"d20{skill_part} = {breakdown} = {total}{dc_part}{result_part}" return { "success": True, "total": total, "d20_result": d20_result, "rolls": rolls, "modifier": modifier, "dc": dc, "check_success": success, "result": result_str.lower() if result_str else None, "roll_type": roll_type, "is_critical": d20_result == 20, "is_fumble": d20_result == 1, "skill_name": skill_name, "breakdown": breakdown, "formatted": formatted, "timestamp": timestamp, "degraded_mode": True, } async def handle( self, tool_name: str, arguments: dict[str, object], ) -> dict[str, object]: """ Route a tool call to the appropriate fallback handler. Args: tool_name: Name of the tool arguments: Tool arguments Returns: Fallback result or error dict """ # Normalize tool name (remove mcp_ prefix if present) normalized_name = tool_name.replace("mcp_", "") if normalized_name == "roll": reason_val = arguments.get("reason") return await self.handle_roll( notation=str(arguments.get("notation", "1d20")), reason=str(reason_val) if reason_val else None, secret=bool(arguments.get("secret", False)), ) elif normalized_name == "roll_check": dc_val = arguments.get("dc") return await self.handle_roll_check( modifier=int(str(arguments.get("modifier", 0))), dc=int(str(dc_val)) if dc_val is not None else None, advantage=bool(arguments.get("advantage", False)), disadvantage=bool(arguments.get("disadvantage", False)), skill_name=str(arguments.get("skill_name")) if arguments.get("skill_name") else None, ) else: return { "success": False, "error": f"No fallback available for tool: {tool_name}", "degraded_mode": True, }