| | """ |
| | 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 = 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" |
| |
|
| | |
| | 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: |
| | 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" |
| |
|
| | |
| | sanitized = " ".join(message.split()) |
| |
|
| | if len(sanitized) > max_length: |
| | return False, "", f"Message is too long (max {max_length} characters)" |
| |
|
| | |
| | |
| | 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" |
| |
|
| | |
| | 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 |
| | """ |
| | |
| | text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) |
| | text = re.sub(r"\*(.+?)\*", r"\1", text) |
| | text = re.sub(r"~~(.+?)~~", r"\1", text) |
| | text = re.sub(r"`(.+?)`", r"\1", text) |
| | text = re.sub(r"```[\s\S]*?```", "", text) |
| | text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) |
| |
|
| | |
| | text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text) |
| |
|
| | |
| | text = re.sub( |
| | r"[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF]", |
| | "", |
| | text, |
| | ) |
| |
|
| | |
| | text = " ".join(text.split()) |
| |
|
| | return text.strip() |
| |
|