DungeonMaster-AI / src /utils /validators.py
bhupesh-sf's picture
first commit
f8ba6bf verified
"""
DungeonMaster AI - Input Validators
Utilities for validating user input and game data.
"""
import re
from typing import Any
class ValidationError(Exception):
"""Custom exception for validation errors."""
def __init__(self, message: str, field: str | None = None):
self.message = message
self.field = field
super().__init__(self.message)
def validate_dice_notation(notation: str) -> bool:
"""
Validate dice notation format.
Valid formats:
- d20, D20
- 2d6, 2D6
- 1d8+5, 1d8-2
- 4d6kh3 (keep highest 3)
- 2d20kl1 (keep lowest 1, disadvantage)
Args:
notation: The dice notation string
Returns:
True if valid, False otherwise
"""
# Pattern for standard dice notation with optional modifiers and keep syntax
pattern = r"^(\d+)?[dD](\d+)(kh\d+|kl\d+)?([+-]\d+)?$"
return bool(re.match(pattern, notation.strip()))
def validate_character_name(name: str) -> tuple[bool, str]:
"""
Validate a character name.
Args:
name: The proposed character name
Returns:
Tuple of (is_valid, error_message or empty string)
"""
if not name or not name.strip():
return False, "Character name cannot be empty"
name = name.strip()
if len(name) < 2:
return False, "Character name must be at least 2 characters"
if len(name) > 50:
return False, "Character name must be 50 characters or less"
# Allow letters, spaces, apostrophes, and hyphens
if not re.match(r"^[a-zA-Z][a-zA-Z\s'\-]*$", name):
return False, "Character name must start with a letter and contain only letters, spaces, apostrophes, and hyphens"
return True, ""
def validate_ability_score(score: int, method: str = "standard") -> tuple[bool, str]:
"""
Validate an ability score.
Args:
score: The ability score value
method: The generation method ("standard", "point_buy", "rolled")
Returns:
Tuple of (is_valid, error_message or empty string)
"""
if not isinstance(score, int):
return False, "Ability score must be an integer"
if method == "point_buy":
if score < 8 or score > 15:
return False, "Point buy scores must be between 8 and 15"
elif method == "standard":
valid_scores = [8, 10, 12, 13, 14, 15]
if score not in valid_scores:
return False, f"Standard array scores must be one of {valid_scores}"
else: # rolled or other
if score < 3 or score > 18:
return False, "Rolled scores must be between 3 and 18"
return True, ""
def validate_level(level: int) -> tuple[bool, str]:
"""
Validate a character level.
Args:
level: The character level
Returns:
Tuple of (is_valid, error_message or empty string)
"""
if not isinstance(level, int):
return False, "Level must be an integer"
if level < 1 or level > 20:
return False, "Level must be between 1 and 20"
return True, ""
def validate_hp(
current: int, maximum: int, allow_negative: bool = False
) -> tuple[bool, str]:
"""
Validate HP values.
Args:
current: Current HP
maximum: Maximum HP
allow_negative: Whether to allow negative current HP (for massive damage)
Returns:
Tuple of (is_valid, error_message or empty string)
"""
if not isinstance(current, int) or not isinstance(maximum, int):
return False, "HP values must be integers"
if maximum < 1:
return False, "Maximum HP must be at least 1"
if not allow_negative and current < 0:
return False, "Current HP cannot be negative"
if allow_negative and current < -maximum:
return False, "Current HP cannot be less than negative maximum HP"
return True, ""
def validate_player_input(
message: str, max_length: int = 1000
) -> tuple[bool, str, str]:
"""
Validate and sanitize player input.
Args:
message: The player's input message
max_length: Maximum allowed length
Returns:
Tuple of (is_valid, sanitized_message, error_message)
"""
if not message:
return False, "", "Please enter an action or message"
# Strip and normalize whitespace
sanitized = " ".join(message.split())
if len(sanitized) > max_length:
return False, "", f"Message is too long (max {max_length} characters)"
# Check for potentially problematic content
# (In a real app, might want more sophisticated content filtering)
if sanitized.count("```") > 4:
return False, sanitized, "Message contains too many code blocks"
return True, sanitized, ""
def validate_session_data(data: dict[str, Any]) -> tuple[bool, str]:
"""
Validate session/save data structure.
Args:
data: The session data dictionary
Returns:
Tuple of (is_valid, error_message or empty string)
"""
required_fields = ["session_id", "started_at", "system"]
for field in required_fields:
if field not in data:
return False, f"Missing required field: {field}"
if data.get("system") not in ["dnd5e", "pathfinder2e", "call_of_cthulhu", "fate"]:
return False, "Invalid game system"
if "party" in data and not isinstance(data["party"], list):
return False, "Party must be a list"
if "game_state" in data:
state = data["game_state"]
if "in_combat" in state and not isinstance(state["in_combat"], bool):
return False, "in_combat must be a boolean"
return True, ""
def validate_adventure_data(data: dict[str, Any]) -> tuple[bool, str]:
"""
Validate adventure JSON structure.
Args:
data: The adventure data dictionary
Returns:
Tuple of (is_valid, error_message or empty string)
"""
if "metadata" not in data:
return False, "Adventure must have metadata"
metadata = data["metadata"]
required_metadata = ["name", "description", "difficulty"]
for field in required_metadata:
if field not in metadata:
return False, f"Metadata missing required field: {field}"
if "starting_scene" not in data:
return False, "Adventure must have a starting_scene"
if "scenes" not in data or not isinstance(data["scenes"], list):
return False, "Adventure must have a scenes array"
if len(data["scenes"]) == 0:
return False, "Adventure must have at least one scene"
# Validate scene references
scene_ids = {scene.get("scene_id") for scene in data["scenes"]}
starting_id = data["starting_scene"].get("scene_id")
if starting_id not in scene_ids:
return False, f"Starting scene '{starting_id}' not found in scenes"
return True, ""
def sanitize_for_tts(text: str) -> str:
"""
Sanitize text for text-to-speech processing.
Removes or converts elements that might cause TTS issues.
Args:
text: The raw text
Returns:
Sanitized text suitable for TTS
"""
# Remove markdown formatting
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # Bold
text = re.sub(r"\*(.+?)\*", r"\1", text) # Italic
text = re.sub(r"~~(.+?)~~", r"\1", text) # Strikethrough
text = re.sub(r"`(.+?)`", r"\1", text) # Inline code
text = re.sub(r"```[\s\S]*?```", "", text) # Code blocks
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) # Headers
# Remove links, keep text
text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)
# Remove emojis (basic pattern)
text = re.sub(
r"[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF]",
"",
text,
)
# Normalize whitespace
text = " ".join(text.split())
return text.strip()