""" Item validation service for AI-granted items. This module validates and resolves items that the AI grants to players during gameplay, ensuring they meet character requirements and game balance rules. """ import uuid from pathlib import Path from typing import Optional import structlog import yaml from app.models.items import Item from app.models.enums import ItemType from app.models.character import Character from app.ai.response_parser import ItemGrant logger = structlog.get_logger(__name__) class ItemValidationError(Exception): """ Exception raised when an item fails validation. Attributes: message: Human-readable error message item_grant: The ItemGrant that failed validation reason: Machine-readable reason code """ def __init__(self, message: str, item_grant: ItemGrant, reason: str): super().__init__(message) self.message = message self.item_grant = item_grant self.reason = reason class ItemValidator: """ Validates and resolves items granted by the AI. This service: 1. Resolves item references (by ID or creates generic items) 2. Validates items against character requirements 3. Logs validation failures for review """ # Map of generic item type strings to ItemType enums TYPE_MAP = { "weapon": ItemType.WEAPON, "armor": ItemType.ARMOR, "consumable": ItemType.CONSUMABLE, "quest_item": ItemType.QUEST_ITEM, } def __init__(self, data_path: Optional[Path] = None): """ Initialize the item validator. Args: data_path: Path to game data directory. Defaults to app/data/ """ if data_path is None: # Default to api/app/data/ data_path = Path(__file__).parent.parent / "data" self.data_path = data_path self._item_registry: dict[str, dict] = {} self._generic_templates: dict[str, dict] = {} self._load_data() logger.info( "ItemValidator initialized", items_loaded=len(self._item_registry), generic_templates_loaded=len(self._generic_templates) ) def _load_data(self) -> None: """Load item data from YAML files.""" # Load main item registry if it exists items_file = self.data_path / "items.yaml" if items_file.exists(): with open(items_file) as f: data = yaml.safe_load(f) or {} self._item_registry = data.get("items", {}) # Load generic item templates generic_file = self.data_path / "generic_items.yaml" if generic_file.exists(): with open(generic_file) as f: data = yaml.safe_load(f) or {} self._generic_templates = data.get("templates", {}) def resolve_item(self, item_grant: ItemGrant) -> Item: """ Resolve an ItemGrant to an actual Item instance. For existing items (by item_id), looks up from item registry. For generic items (by name/type), creates a new Item. Args: item_grant: The ItemGrant from AI response Returns: Resolved Item instance Raises: ItemValidationError: If item cannot be resolved """ if item_grant.is_existing_item(): return self._resolve_existing_item(item_grant) elif item_grant.is_generic_item(): return self._create_generic_item(item_grant) else: raise ItemValidationError( "ItemGrant has neither item_id nor name", item_grant, "INVALID_ITEM_GRANT" ) def _resolve_existing_item(self, item_grant: ItemGrant) -> Item: """ Look up an existing item by ID. Args: item_grant: ItemGrant with item_id set Returns: Item instance from registry Raises: ItemValidationError: If item not found """ item_id = item_grant.item_id if item_id not in self._item_registry: logger.warning( "Item not found in registry", item_id=item_id ) raise ItemValidationError( f"Unknown item_id: {item_id}", item_grant, "ITEM_NOT_FOUND" ) item_data = self._item_registry[item_id] # Convert to Item instance return Item.from_dict({ "item_id": item_id, **item_data }) def _create_generic_item(self, item_grant: ItemGrant) -> Item: """ Create a generic item from AI-provided details. Generic items are simple items with no special stats, suitable for mundane objects like torches, food, etc. Args: item_grant: ItemGrant with name, type, description Returns: New Item instance Raises: ItemValidationError: If item type is invalid """ # Validate item type item_type_str = (item_grant.item_type or "consumable").lower() if item_type_str not in self.TYPE_MAP: logger.warning( "Invalid item type from AI", item_type=item_type_str, item_name=item_grant.name ) # Default to consumable for unknown types item_type_str = "consumable" item_type = self.TYPE_MAP[item_type_str] # Generate unique ID for this item instance item_id = f"generic_{uuid.uuid4().hex[:8]}" # Check if we have a template for this item name template = self._find_template(item_grant.name or "") if template: # Use template values as defaults return Item( item_id=item_id, name=item_grant.name or template.get("name", "Unknown Item"), item_type=item_type, description=item_grant.description or template.get("description", ""), value=item_grant.value or template.get("value", 0), is_tradeable=template.get("is_tradeable", True), required_level=template.get("required_level", 1), ) else: # Create with provided values only return Item( item_id=item_id, name=item_grant.name or "Unknown Item", item_type=item_type, description=item_grant.description or "A simple item.", value=item_grant.value, is_tradeable=True, required_level=1, ) def _find_template(self, item_name: str) -> Optional[dict]: """ Find a generic item template by name. Uses case-insensitive partial matching. Args: item_name: Name of the item to find Returns: Template dict or None if not found """ name_lower = item_name.lower() # Exact match first if name_lower in self._generic_templates: return self._generic_templates[name_lower] # Partial match for template_name, template in self._generic_templates.items(): if template_name in name_lower or name_lower in template_name: return template return None def validate_item_for_character( self, item: Item, character: Character ) -> tuple[bool, Optional[str]]: """ Validate that a character can receive an item. Checks: - Level requirements - Class restrictions Args: item: The Item to validate character: The Character to receive the item Returns: Tuple of (is_valid, error_message) """ # Check level requirement if item.required_level > character.level: error_msg = ( f"Item '{item.name}' requires level {item.required_level}, " f"but character is level {character.level}" ) logger.warning( "Item validation failed: level requirement", item_name=item.name, required_level=item.required_level, character_level=character.level, character_name=character.name ) return False, error_msg # Check class restriction if item.required_class: character_class = character.player_class.class_id if item.required_class.lower() != character_class.lower(): error_msg = ( f"Item '{item.name}' requires class {item.required_class}, " f"but character is {character_class}" ) logger.warning( "Item validation failed: class restriction", item_name=item.name, required_class=item.required_class, character_class=character_class, character_name=character.name ) return False, error_msg return True, None def validate_and_resolve_item( self, item_grant: ItemGrant, character: Character ) -> tuple[Optional[Item], Optional[str]]: """ Resolve an item grant and validate it for a character. This is the main entry point for processing AI-granted items. Args: item_grant: The ItemGrant from AI response character: The Character to receive the item Returns: Tuple of (Item if valid else None, error_message if invalid else None) """ try: # Resolve the item item = self.resolve_item(item_grant) # Validate for character is_valid, error_msg = self.validate_item_for_character(item, character) if not is_valid: return None, error_msg logger.info( "Item validated successfully", item_name=item.name, item_id=item.item_id, character_name=character.name ) return item, None except ItemValidationError as e: logger.warning( "Item resolution failed", error=e.message, reason=e.reason ) return None, e.message # Global instance for convenience _validator_instance: Optional[ItemValidator] = None def get_item_validator() -> ItemValidator: """ Get or create the global ItemValidator instance. Returns: ItemValidator singleton instance """ global _validator_instance if _validator_instance is None: _validator_instance = ItemValidator() return _validator_instance