|
|
""" |
|
|
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, |
|
|
} |
|
|
|