""" Enemy Loader Service - YAML-based enemy template loading. This service loads enemy definitions from YAML files, providing a data-driven approach to defining monsters and enemies for combat encounters. """ from pathlib import Path from typing import Dict, List, Optional import yaml from app.models.enemy import EnemyTemplate, EnemyDifficulty from app.utils.logging import get_logger logger = get_logger(__file__) class EnemyLoader: """ Loads enemy templates from YAML configuration files. This allows game designers to define enemies without touching code. Enemy files are organized by difficulty in subdirectories. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the enemy loader. Args: data_dir: Path to directory containing enemy YAML files Defaults to /app/data/enemies/ """ if data_dir is None: # Default to app/data/enemies relative to this file current_file = Path(__file__) app_dir = current_file.parent.parent # Go up to /app data_dir = str(app_dir / "data" / "enemies") self.data_dir = Path(data_dir) self._enemy_cache: Dict[str, EnemyTemplate] = {} self._loaded = False logger.info("EnemyLoader initialized", data_dir=str(self.data_dir)) def load_enemy(self, enemy_id: str) -> Optional[EnemyTemplate]: """ Load a single enemy template by ID. Args: enemy_id: Unique enemy identifier Returns: EnemyTemplate instance or None if not found """ # Check cache first if enemy_id in self._enemy_cache: return self._enemy_cache[enemy_id] # If not cached, try loading all enemies first if not self._loaded: self.load_all_enemies() if enemy_id in self._enemy_cache: return self._enemy_cache[enemy_id] # Try loading from specific YAML file yaml_file = self.data_dir / f"{enemy_id}.yaml" if yaml_file.exists(): return self._load_from_file(yaml_file) # Search in subdirectories for subdir in self.data_dir.iterdir(): if subdir.is_dir(): yaml_file = subdir / f"{enemy_id}.yaml" if yaml_file.exists(): return self._load_from_file(yaml_file) logger.warning("Enemy not found", enemy_id=enemy_id) return None def _load_from_file(self, yaml_file: Path) -> Optional[EnemyTemplate]: """ Load an enemy template from a specific YAML file. Args: yaml_file: Path to the YAML file Returns: EnemyTemplate instance or None on error """ try: with open(yaml_file, 'r') as f: data = yaml.safe_load(f) enemy = EnemyTemplate.from_dict(data) self._enemy_cache[enemy.enemy_id] = enemy logger.debug("Enemy loaded", enemy_id=enemy.enemy_id, file=str(yaml_file)) return enemy except Exception as e: logger.error("Failed to load enemy file", file=str(yaml_file), error=str(e)) return None def load_all_enemies(self) -> Dict[str, EnemyTemplate]: """ Load all enemy templates from the data directory. Searches both the root directory and subdirectories for YAML files. Returns: Dictionary mapping enemy_id to EnemyTemplate instance """ if not self.data_dir.exists(): logger.warning("Enemy data directory not found", path=str(self.data_dir)) return {} enemies = {} # Load from root directory for yaml_file in self.data_dir.glob("*.yaml"): enemy = self._load_from_file(yaml_file) if enemy: enemies[enemy.enemy_id] = enemy # Load from subdirectories (organized by difficulty) for subdir in self.data_dir.iterdir(): if subdir.is_dir(): for yaml_file in subdir.glob("*.yaml"): enemy = self._load_from_file(yaml_file) if enemy: enemies[enemy.enemy_id] = enemy self._loaded = True logger.info("All enemies loaded", count=len(enemies)) return enemies def get_enemies_by_difficulty( self, difficulty: EnemyDifficulty ) -> List[EnemyTemplate]: """ Get all enemies matching a difficulty level. Args: difficulty: Difficulty level to filter by Returns: List of EnemyTemplate instances """ if not self._loaded: self.load_all_enemies() return [ enemy for enemy in self._enemy_cache.values() if enemy.difficulty == difficulty ] def get_enemies_by_tag(self, tag: str) -> List[EnemyTemplate]: """ Get all enemies with a specific tag. Args: tag: Tag to filter by (e.g., "undead", "beast", "humanoid") Returns: List of EnemyTemplate instances with that tag """ if not self._loaded: self.load_all_enemies() return [ enemy for enemy in self._enemy_cache.values() if enemy.has_tag(tag) ] def get_enemies_by_location( self, location_type: str, difficulty: Optional[EnemyDifficulty] = None ) -> List[EnemyTemplate]: """ Get all enemies that can appear at a specific location type. This is used by the encounter generator to find location-appropriate enemies for random encounters. Args: location_type: Location type to filter by (e.g., "forest", "dungeon", "town", "wilderness", "crypt", "ruins", "road") difficulty: Optional difficulty filter to narrow results Returns: List of EnemyTemplate instances that can appear at the location """ if not self._loaded: self.load_all_enemies() candidates = [ enemy for enemy in self._enemy_cache.values() if enemy.has_location_tag(location_type) ] # Apply difficulty filter if specified if difficulty is not None: candidates = [e for e in candidates if e.difficulty == difficulty] logger.debug( "Enemies found for location", location_type=location_type, difficulty=difficulty.value if difficulty else None, count=len(candidates) ) return candidates def get_random_enemies( self, count: int = 1, difficulty: Optional[EnemyDifficulty] = None, tag: Optional[str] = None, exclude_bosses: bool = True ) -> List[EnemyTemplate]: """ Get random enemies for encounter generation. Args: count: Number of enemies to select difficulty: Optional difficulty filter tag: Optional tag filter exclude_bosses: Whether to exclude boss enemies Returns: List of randomly selected EnemyTemplate instances """ import random if not self._loaded: self.load_all_enemies() # Build candidate list candidates = list(self._enemy_cache.values()) # Apply filters if difficulty: candidates = [e for e in candidates if e.difficulty == difficulty] if tag: candidates = [e for e in candidates if e.has_tag(tag)] if exclude_bosses: candidates = [e for e in candidates if not e.is_boss()] if not candidates: logger.warning("No enemies match filters", difficulty=difficulty.value if difficulty else None, tag=tag) return [] # Select random enemies (with replacement if needed) if len(candidates) >= count: return random.sample(candidates, count) else: # Not enough unique enemies, allow duplicates return random.choices(candidates, k=count) def clear_cache(self) -> None: """Clear the enemy cache, forcing reload on next access.""" self._enemy_cache.clear() self._loaded = False logger.debug("Enemy cache cleared") def get_all_cached(self) -> Dict[str, EnemyTemplate]: """ Get all cached enemies. Returns: Dictionary of cached enemy templates """ if not self._loaded: self.load_all_enemies() return self._enemy_cache.copy() # Global instance for convenience _loader_instance: Optional[EnemyLoader] = None def get_enemy_loader() -> EnemyLoader: """ Get the global EnemyLoader instance. Returns: Singleton EnemyLoader instance """ global _loader_instance if _loader_instance is None: _loader_instance = EnemyLoader() return _loader_instance