bhupesh-sf's picture
first commit
f8ba6bf verified
"""
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,
}