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