""" QuestService for loading quest definitions from YAML files. This service reads quest configuration files and converts them into Quest dataclass instances, providing caching for performance. Quests are organized by difficulty subdirectories (easy, medium, hard, epic). Follows the same pattern as NPCLoader for consistency. """ import yaml from pathlib import Path from typing import Dict, List, Optional import structlog from app.models.quest import ( Quest, QuestObjective, QuestReward, QuestOfferingTriggers, QuestOfferConditions, NPCOfferDialogue, QuestLoreContext, QuestDialogueTemplates, QuestDifficulty, ObjectiveType, ) logger = structlog.get_logger(__name__) class QuestService: """ Loads quest definitions from YAML configuration files. Quests are organized in difficulty subdirectories: /app/data/quests/ easy/ cellar_rats.yaml missing_item.yaml medium/ bandit_threat.yaml hard/ dungeon_depths.yaml epic/ ancient_evil.yaml This allows game designers to define quests without touching code. """ def __init__(self, data_dir: Optional[str] = None): """ Initialize the quest service. Args: data_dir: Path to directory containing quest YAML files. Defaults to /app/data/quests/ """ if data_dir is None: # Default to app/data/quests relative to this file current_file = Path(__file__) app_dir = current_file.parent.parent # Go up to /app data_dir = str(app_dir / "data" / "quests") self.data_dir = Path(data_dir) self._quest_cache: Dict[str, Quest] = {} self._npc_quest_cache: Dict[str, List[str]] = {} # npc_id -> [quest_ids] self._difficulty_cache: Dict[str, List[str]] = {} # difficulty -> [quest_ids] logger.info("QuestService initialized", data_dir=str(self.data_dir)) def load_quest(self, quest_id: str) -> Optional[Quest]: """ Load a single quest by ID. Searches all difficulty subdirectories for the quest file. Args: quest_id: Unique quest identifier (e.g., "quest_cellar_rats") Returns: Quest instance or None if not found """ # Check cache first if quest_id in self._quest_cache: logger.debug("Quest loaded from cache", quest_id=quest_id) return self._quest_cache[quest_id] # Search in difficulty subdirectories if not self.data_dir.exists(): logger.error("Quest data directory does not exist", data_dir=str(self.data_dir)) return None for difficulty_dir in self.data_dir.iterdir(): if not difficulty_dir.is_dir(): continue # Try both with and without quest_ prefix for filename in [f"{quest_id}.yaml", f"{quest_id.replace('quest_', '')}.yaml"]: file_path = difficulty_dir / filename if file_path.exists(): return self._load_quest_file(file_path) logger.warning("Quest not found", quest_id=quest_id) return None def _load_quest_file(self, file_path: Path) -> Optional[Quest]: """ Load a quest from a specific file. Args: file_path: Path to the YAML file Returns: Quest instance or None if loading fails """ try: with open(file_path, 'r') as f: data = yaml.safe_load(f) quest = self._parse_quest_data(data) self._quest_cache[quest.quest_id] = quest # Update NPC-to-quest cache for npc_id in quest.quest_giver_npc_ids: if npc_id not in self._npc_quest_cache: self._npc_quest_cache[npc_id] = [] if quest.quest_id not in self._npc_quest_cache[npc_id]: self._npc_quest_cache[npc_id].append(quest.quest_id) # Update difficulty cache difficulty = quest.difficulty.value if difficulty not in self._difficulty_cache: self._difficulty_cache[difficulty] = [] if quest.quest_id not in self._difficulty_cache[difficulty]: self._difficulty_cache[difficulty].append(quest.quest_id) logger.info("Quest loaded successfully", quest_id=quest.quest_id) return quest except Exception as e: logger.error("Failed to load quest", file=str(file_path), error=str(e)) return None def _parse_quest_data(self, data: Dict) -> Quest: """ Parse YAML data into a Quest dataclass. Args: data: Dictionary loaded from YAML file Returns: Quest instance Raises: ValueError: If data is invalid or missing required fields """ # Validate required fields required_fields = ["quest_id", "name"] for field in required_fields: if field not in data: raise ValueError(f"Missing required field: {field}") # Parse objectives objectives = [] for obj_data in data.get("objectives", []): objectives.append(QuestObjective( objective_id=obj_data["objective_id"], description=obj_data["description"], objective_type=ObjectiveType(obj_data.get("objective_type", "kill")), required_progress=obj_data.get("required_progress", 1), current_progress=0, target_enemy_type=obj_data.get("target_enemy_type"), target_item_id=obj_data.get("target_item_id"), target_location_id=obj_data.get("target_location_id"), target_npc_id=obj_data.get("target_npc_id"), )) # Parse rewards rewards_data = data.get("rewards", {}) rewards = QuestReward( gold=rewards_data.get("gold", 0), experience=rewards_data.get("experience", 0), items=rewards_data.get("items", []), relationship_bonuses=rewards_data.get("relationship_bonuses", {}), unlocks_quests=rewards_data.get("unlocks_quests", []), reveals_locations=rewards_data.get("reveals_locations", []), ) # Parse offering triggers triggers_data = data.get("offering_triggers", {}) offering_triggers = QuestOfferingTriggers( location_types=triggers_data.get("location_types", []), specific_locations=triggers_data.get("specific_locations", []), min_character_level=triggers_data.get("min_character_level", 1), max_character_level=triggers_data.get("max_character_level", 100), required_quests_completed=triggers_data.get("required_quests_completed", []), probability_weights=triggers_data.get("probability_weights", {}), ) # Parse NPC offer dialogues npc_offer_dialogues = {} for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items(): conditions_data = dialogue_data.get("conditions", {}) conditions = QuestOfferConditions( min_relationship=conditions_data.get("min_relationship", 0), required_flags=conditions_data.get("required_flags", []), forbidden_flags=conditions_data.get("forbidden_flags", []), ) npc_offer_dialogues[npc_id] = NPCOfferDialogue( dialogue=dialogue_data.get("dialogue", ""), conditions=conditions, ) # Parse lore context lore_data = data.get("lore_context", {}) lore_context = QuestLoreContext( backstory=lore_data.get("backstory", ""), world_connections=lore_data.get("world_connections", []), regional_hints=lore_data.get("regional_hints", []), ) # Parse dialogue templates templates_data = data.get("dialogue_templates", {}) dialogue_templates = QuestDialogueTemplates( narrative_hooks=templates_data.get("narrative_hooks", []), ambient_hints=templates_data.get("ambient_hints", []), ) return Quest( quest_id=data["quest_id"], name=data["name"], description=data.get("description", ""), difficulty=QuestDifficulty(data.get("difficulty", "easy")), quest_giver_npc_ids=data.get("quest_giver_npc_ids", []), quest_giver_name=data.get("quest_giver_name", ""), location_id=data.get("location_id", ""), region_id=data.get("region_id", ""), objectives=objectives, rewards=rewards, offering_triggers=offering_triggers, npc_offer_dialogues=npc_offer_dialogues, npc_quest_knowledge=data.get("npc_quest_knowledge", {}), lore_context=lore_context, dialogue_templates=dialogue_templates, completion_dialogue=data.get("completion_dialogue", {}), tags=data.get("tags", []), ) def load_all_quests(self) -> List[Quest]: """ Load all quests from all difficulty directories. Returns: List of Quest instances """ quests = [] if not self.data_dir.exists(): logger.error("Quest data directory does not exist", data_dir=str(self.data_dir)) return quests for difficulty_dir in self.data_dir.iterdir(): if not difficulty_dir.is_dir(): continue for file_path in difficulty_dir.glob("*.yaml"): quest = self._load_quest_file(file_path) if quest: quests.append(quest) logger.info("All quests loaded", count=len(quests)) return quests def get_quests_for_npc(self, npc_id: str) -> List[Quest]: """ Get all quests that a specific NPC can offer. This is the primary method for the quest-centric design: quests define which NPCs can offer them, and this method finds all quests for a given NPC. Args: npc_id: NPC identifier Returns: List of Quest instances that this NPC can offer """ # Ensure all quests are loaded if not self._quest_cache: self.load_all_quests() quest_ids = self._npc_quest_cache.get(npc_id, []) return [ self._quest_cache[quest_id] for quest_id in quest_ids if quest_id in self._quest_cache ] def get_quests_by_difficulty(self, difficulty: str) -> List[Quest]: """ Get all quests of a specific difficulty. Args: difficulty: Difficulty level ("easy", "medium", "hard", "epic") Returns: List of Quest instances with this difficulty """ # Ensure all quests are loaded if not self._quest_cache: self.load_all_quests() quest_ids = self._difficulty_cache.get(difficulty, []) return [ self._quest_cache[quest_id] for quest_id in quest_ids if quest_id in self._quest_cache ] def get_quests_for_location(self, location_id: str, location_type: str = "") -> List[Quest]: """ Get quests that can be offered at a specific location. Args: location_id: Location identifier location_type: Type of location (e.g., "tavern", "town") Returns: List of Quest instances that can be offered here """ # Ensure all quests are loaded if not self._quest_cache: self.load_all_quests() matching_quests = [] for quest in self._quest_cache.values(): triggers = quest.offering_triggers # Check specific locations if location_id in triggers.specific_locations: matching_quests.append(quest) continue # Check location types if location_type and location_type in triggers.location_types: matching_quests.append(quest) continue return matching_quests def get_all_quest_ids(self) -> List[str]: """ Get a list of all available quest IDs. Returns: List of quest IDs """ # Ensure all quests are loaded if not self._quest_cache: self.load_all_quests() return list(self._quest_cache.keys()) def reload_quest(self, quest_id: str) -> Optional[Quest]: """ Force reload a quest from disk, bypassing cache. Useful for development/testing when quest definitions change. Args: quest_id: Unique quest identifier Returns: Quest instance or None if not found """ # Remove from caches if present if quest_id in self._quest_cache: old_quest = self._quest_cache[quest_id] # Remove from NPC cache for npc_id in old_quest.quest_giver_npc_ids: if npc_id in self._npc_quest_cache: self._npc_quest_cache[npc_id] = [ qid for qid in self._npc_quest_cache[npc_id] if qid != quest_id ] # Remove from difficulty cache difficulty = old_quest.difficulty.value if difficulty in self._difficulty_cache: self._difficulty_cache[difficulty] = [ qid for qid in self._difficulty_cache[difficulty] if qid != quest_id ] del self._quest_cache[quest_id] return self.load_quest(quest_id) def clear_cache(self) -> None: """Clear all cached data. Useful for testing.""" self._quest_cache.clear() self._npc_quest_cache.clear() self._difficulty_cache.clear() logger.info("Quest cache cleared") # Global singleton instance _service_instance: Optional[QuestService] = None def get_quest_service() -> QuestService: """ Get the global QuestService instance. Returns: Singleton QuestService instance """ global _service_instance if _service_instance is None: _service_instance = QuestService() return _service_instance