| | """ |
| | 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 |
| | """ |
| |
|
| | |
| | SUPPORTED_TOOLS: set[str] = {"roll", "roll_check", "mcp_roll", "mcp_roll_check"} |
| |
|
| | |
| | |
| | DICE_PATTERN = re.compile( |
| | r"^(\d*)d(\d+)([+-]\d+)?$", |
| | re.IGNORECASE, |
| | ) |
| |
|
| | |
| | |
| | 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() |
| |
|
| | |
| | advanced_match = self.ADVANCED_DICE_PATTERN.match(notation) |
| | if advanced_match: |
| | return self._roll_advanced( |
| | advanced_match, |
| | notation, |
| | reason, |
| | secret, |
| | timestamp, |
| | ) |
| |
|
| | |
| | basic_match = self.DICE_PATTERN.match(notation) |
| | if basic_match: |
| | return self._roll_basic( |
| | basic_match, |
| | notation, |
| | reason, |
| | secret, |
| | timestamp, |
| | ) |
| |
|
| | |
| | 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 |
| |
|
| | |
| | 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, |
| | } |
| |
|
| | |
| | rolls = [random.randint(1, die_size) for _ in range(num_dice)] |
| | total = sum(rolls) + modifier |
| |
|
| | |
| | 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, |
| | "individual_rolls": rolls, |
| | "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() |
| | keep_count = int(match.group(4)) if match.group(4) else 1 |
| | modifier = int(match.group(5)) if match.group(5) else 0 |
| |
|
| | |
| | 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, |
| | } |
| |
|
| | |
| | rolls = [random.randint(1, die_size) for _ in range(num_dice)] |
| |
|
| | |
| | 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 |
| |
|
| | |
| | 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() |
| |
|
| | |
| | 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 |
| |
|
| | |
| | success = None |
| | result_str = "" |
| | if dc is not None: |
| | success = total >= dc |
| | result_str = "SUCCESS" if success else "FAILURE" |
| |
|
| | |
| | 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)}" |
| |
|
| | |
| | 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 |
| | """ |
| | |
| | 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, |
| | } |
| |
|