""" NPCLoader service for loading NPC definitions from YAML files. This service reads NPC configuration files and converts them into NPC dataclass instances, providing caching for performance. NPCs are organized by region subdirectories. """ import yaml from pathlib import Path from typing import Dict, List, Optional import structlog from app.models.npc import ( NPC, NPCPersonality, NPCAppearance, NPCKnowledge, NPCKnowledgeCondition, NPCRelationship, NPCInventoryItem, NPCDialogueHooks, ) logger = structlog.get_logger(__name__) class NPCLoader: """ Loads NPC definitions from YAML configuration files. NPCs are organized in region subdirectories: /app/data/npcs/ crossville/ npc_grom_001.yaml npc_mira_001.yaml This allows game designers to define NPCs without touching code. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the NPC loader. Args: data_dir: Path to directory containing NPC YAML files. Defaults to /app/data/npcs/ """ if data_dir is None: # Default to app/data/npcs relative to this file current_file = Path(__file__) app_dir = current_file.parent.parent # Go up to /app data_dir = str(app_dir / "data" / "npcs") self.data_dir = Path(data_dir) self._npc_cache: Dict[str, NPC] = {} self._location_npc_cache: Dict[str, List[str]] = {} logger.info("NPCLoader initialized", data_dir=str(self.data_dir)) def load_npc(self, npc_id: str) -> Optional[NPC]: """ Load a single NPC by ID. Searches all region subdirectories for the NPC file. Args: npc_id: Unique NPC identifier (e.g., "npc_grom_001") Returns: NPC instance or None if not found """ # Check cache first if npc_id in self._npc_cache: logger.debug("NPC loaded from cache", npc_id=npc_id) return self._npc_cache[npc_id] # Search in region subdirectories if not self.data_dir.exists(): logger.error("NPC data directory does not exist", data_dir=str(self.data_dir)) return None for region_dir in self.data_dir.iterdir(): if not region_dir.is_dir(): continue file_path = region_dir / f"{npc_id}.yaml" if file_path.exists(): return self._load_npc_file(file_path) logger.warning("NPC not found", npc_id=npc_id) return None def _load_npc_file(self, file_path: Path) -> Optional[NPC]: """ Load an NPC from a specific file. Args: file_path: Path to the YAML file Returns: NPC instance or None if loading fails """ try: with open(file_path, 'r') as f: data = yaml.safe_load(f) npc = self._parse_npc_data(data) self._npc_cache[npc.npc_id] = npc # Update location cache if npc.location_id not in self._location_npc_cache: self._location_npc_cache[npc.location_id] = [] if npc.npc_id not in self._location_npc_cache[npc.location_id]: self._location_npc_cache[npc.location_id].append(npc.npc_id) logger.info("NPC loaded successfully", npc_id=npc.npc_id) return npc except Exception as e: logger.error("Failed to load NPC", file=str(file_path), error=str(e)) return None def _parse_npc_data(self, data: Dict) -> NPC: """ Parse YAML data into an NPC dataclass. Args: data: Dictionary loaded from YAML file Returns: NPC instance Raises: ValueError: If data is invalid or missing required fields """ # Validate required fields required_fields = ["npc_id", "name", "role", "location_id"] for field in required_fields: if field not in data: raise ValueError(f"Missing required field: {field}") # Parse personality personality_data = data.get("personality", {}) personality = NPCPersonality( traits=personality_data.get("traits", []), speech_style=personality_data.get("speech_style", ""), quirks=personality_data.get("quirks", []), ) # Parse appearance appearance_data = data.get("appearance", {}) if isinstance(appearance_data, str): appearance = NPCAppearance(brief=appearance_data) else: appearance = NPCAppearance( brief=appearance_data.get("brief", ""), detailed=appearance_data.get("detailed"), ) # Parse knowledge (optional) knowledge = None if data.get("knowledge"): knowledge_data = data["knowledge"] conditions = [ NPCKnowledgeCondition( condition=c.get("condition", ""), reveals=c.get("reveals", ""), ) for c in knowledge_data.get("will_share_if", []) ] knowledge = NPCKnowledge( public=knowledge_data.get("public", []), secret=knowledge_data.get("secret", []), will_share_if=conditions, ) # Parse relationships relationships = [ NPCRelationship( npc_id=r["npc_id"], attitude=r["attitude"], reason=r.get("reason"), ) for r in data.get("relationships", []) ] # Parse inventory inventory = [] for item_data in data.get("inventory_for_sale", []): # Handle shorthand format: { item: "ale", price: 2 } item_id = item_data.get("item_id") or item_data.get("item", "") inventory.append(NPCInventoryItem( item_id=item_id, price=item_data.get("price", 0), quantity=item_data.get("quantity"), )) # Parse dialogue hooks (optional) dialogue_hooks = None if data.get("dialogue_hooks"): hooks_data = data["dialogue_hooks"] dialogue_hooks = NPCDialogueHooks( greeting=hooks_data.get("greeting"), farewell=hooks_data.get("farewell"), busy=hooks_data.get("busy"), quest_complete=hooks_data.get("quest_complete"), ) return NPC( npc_id=data["npc_id"], name=data["name"], role=data["role"], location_id=data["location_id"], personality=personality, appearance=appearance, knowledge=knowledge, relationships=relationships, inventory_for_sale=inventory, dialogue_hooks=dialogue_hooks, quest_giver_for=data.get("quest_giver_for", []), reveals_locations=data.get("reveals_locations", []), tags=data.get("tags", []), ) def load_all_npcs(self) -> List[NPC]: """ Load all NPCs from all region directories. Returns: List of NPC instances """ npcs = [] if not self.data_dir.exists(): logger.error("NPC data directory does not exist", data_dir=str(self.data_dir)) return npcs for region_dir in self.data_dir.iterdir(): if not region_dir.is_dir(): continue for file_path in region_dir.glob("*.yaml"): npc = self._load_npc_file(file_path) if npc: npcs.append(npc) logger.info("All NPCs loaded", count=len(npcs)) return npcs def get_npcs_at_location(self, location_id: str) -> List[NPC]: """ Get all NPCs at a specific location. Args: location_id: Location identifier Returns: List of NPC instances at this location """ # Ensure all NPCs are loaded if not self._npc_cache: self.load_all_npcs() npc_ids = self._location_npc_cache.get(location_id, []) return [ self._npc_cache[npc_id] for npc_id in npc_ids if npc_id in self._npc_cache ] def get_npc_ids_at_location(self, location_id: str) -> List[str]: """ Get NPC IDs at a specific location. Args: location_id: Location identifier Returns: List of NPC IDs at this location """ # Ensure all NPCs are loaded if not self._npc_cache: self.load_all_npcs() return self._location_npc_cache.get(location_id, []) def get_npcs_by_tag(self, tag: str) -> List[NPC]: """ Get all NPCs with a specific tag. Args: tag: Tag to filter by (e.g., "merchant", "quest_giver") Returns: List of NPC instances with this tag """ # Ensure all NPCs are loaded if not self._npc_cache: self.load_all_npcs() return [ npc for npc in self._npc_cache.values() if tag in npc.tags ] def get_quest_givers(self, quest_id: str) -> List[NPC]: """ Get all NPCs that can give a specific quest. Args: quest_id: Quest identifier Returns: List of NPC instances that give this quest """ # Ensure all NPCs are loaded if not self._npc_cache: self.load_all_npcs() return [ npc for npc in self._npc_cache.values() if quest_id in npc.quest_giver_for ] def get_all_npc_ids(self) -> List[str]: """ Get a list of all available NPC IDs. Returns: List of NPC IDs """ # Ensure all NPCs are loaded if not self._npc_cache: self.load_all_npcs() return list(self._npc_cache.keys()) def reload_npc(self, npc_id: str) -> Optional[NPC]: """ Force reload an NPC from disk, bypassing cache. Useful for development/testing when NPC definitions change. Args: npc_id: Unique NPC identifier Returns: NPC instance or None if not found """ # Remove from caches if present if npc_id in self._npc_cache: old_npc = self._npc_cache[npc_id] # Remove from location cache if old_npc.location_id in self._location_npc_cache: self._location_npc_cache[old_npc.location_id] = [ n for n in self._location_npc_cache[old_npc.location_id] if n != npc_id ] del self._npc_cache[npc_id] return self.load_npc(npc_id) def clear_cache(self) -> None: """Clear all cached data. Useful for testing.""" self._npc_cache.clear() self._location_npc_cache.clear() logger.info("NPC cache cleared") # Global singleton instance _loader_instance: Optional[NPCLoader] = None def get_npc_loader() -> NPCLoader: """ Get the global NPCLoader instance. Returns: Singleton NPCLoader instance """ global _loader_instance if _loader_instance is None: _loader_instance = NPCLoader() return _loader_instance