""" 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()