""" QuestEligibilityService for determining quest offering eligibility. This service handles the core logic for determining which quests an NPC can offer to a specific character, including: - Finding quests where the NPC is a quest giver - Filtering by character level and prerequisites - Checking relationship and flag conditions - Applying probability rolls based on location """ import random from dataclasses import dataclass, field from typing import Dict, List, Optional, Any import structlog from app.models.quest import Quest, QuestLoreContext from app.models.character import Character from app.services.quest_service import get_quest_service logger = structlog.get_logger(__name__) @dataclass class QuestOfferContext: """ Context for offering a specific quest during NPC conversation. Contains all the information needed to inject quest offering context into the AI prompt for natural dialogue generation. Attributes: quest: The quest being offered offer_dialogue: NPC-specific dialogue for offering this quest npc_quest_knowledge: Facts the NPC knows about this quest lore_context: Embedded lore for AI context narrative_hooks: Hints for natural conversation weaving """ quest: Quest offer_dialogue: str npc_quest_knowledge: List[str] lore_context: QuestLoreContext narrative_hooks: List[str] def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary for AI prompt injection.""" return { "quest_id": self.quest.quest_id, "quest_name": self.quest.name, "quest_description": self.quest.description, "offer_dialogue": self.offer_dialogue, "npc_quest_knowledge": self.npc_quest_knowledge, "lore_backstory": self.lore_context.backstory, "narrative_hooks": self.narrative_hooks, "rewards": { "gold": self.quest.rewards.gold, "experience": self.quest.rewards.experience, }, } @dataclass class QuestEligibilityResult: """ Result of checking quest eligibility for an NPC conversation. Attributes: eligible_quests: Quests that can be offered should_offer_quest: Whether to actually offer a quest (after probability roll) selected_quest_context: Context for the quest to offer (if should_offer) blocking_reasons: Why certain quests were filtered out """ eligible_quests: List[Quest] = field(default_factory=list) should_offer_quest: bool = False selected_quest_context: Optional[QuestOfferContext] = None blocking_reasons: Dict[str, str] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary.""" return { "eligible_quest_count": len(self.eligible_quests), "should_offer_quest": self.should_offer_quest, "selected_quest": ( self.selected_quest_context.to_dict() if self.selected_quest_context else None ), } class QuestEligibilityService: """ Service for determining quest offering eligibility. This is the core service that implements the quest-centric design: given an NPC and a character, determine which quests can be offered and whether an offer should be made based on probability. """ # Default probability weights by location type DEFAULT_PROBABILITY_WEIGHTS = { "tavern": 0.35, "town": 0.25, "shop": 0.20, "wilderness": 0.05, "dungeon": 0.10, "road": 0.10, } # Maximum active quests a character can have MAX_ACTIVE_QUESTS = 2 def __init__(self): """Initialize the eligibility service.""" self.quest_service = get_quest_service() logger.info("QuestEligibilityService initialized") def check_eligibility( self, npc_id: str, character: Character, location_type: str = "town", location_id: str = "", force_probability: Optional[float] = None, ) -> QuestEligibilityResult: """ Check which quests an NPC can offer to a character. This is the main entry point for quest eligibility checking. It performs the full pipeline: 1. Find quests where NPC is quest_giver 2. Filter by character eligibility 3. Apply probability roll 4. Build offer context if successful Args: npc_id: The NPC's identifier character: The player character location_type: Type of current location (for probability) location_id: Specific location ID force_probability: Override probability roll (for testing) Returns: QuestEligibilityResult with eligible quests and offer context """ result = QuestEligibilityResult() # Check if character already has max quests if len(character.active_quests) >= self.MAX_ACTIVE_QUESTS: logger.debug( "Character at max active quests", character_id=character.character_id, active_quests=len(character.active_quests), ) result.blocking_reasons["max_quests"] = "Character already has maximum active quests" return result # Get quests this NPC can offer npc_quests = self.quest_service.get_quests_for_npc(npc_id) if not npc_quests: logger.debug("NPC has no quests to offer", npc_id=npc_id) return result # Filter quests by eligibility for quest in npc_quests: eligible, reason = self._check_quest_eligibility( quest, character, npc_id, location_id ) if eligible: result.eligible_quests.append(quest) else: result.blocking_reasons[quest.quest_id] = reason if not result.eligible_quests: logger.debug( "No eligible quests after filtering", npc_id=npc_id, character_id=character.character_id, ) return result # Apply probability roll probability = self._get_offer_probability( result.eligible_quests, location_type, force_probability ) roll = random.random() if roll > probability: logger.debug( "Probability roll failed", probability=probability, roll=roll, location_type=location_type, ) return result # Select a quest to offer selected_quest = self._select_quest_to_offer(result.eligible_quests) if not selected_quest: return result # Build offer context result.should_offer_quest = True result.selected_quest_context = self._build_offer_context( selected_quest, npc_id ) logger.info( "Quest offer prepared", quest_id=selected_quest.quest_id, npc_id=npc_id, character_id=character.character_id, ) return result def _check_quest_eligibility( self, quest: Quest, character: Character, npc_id: str, location_id: str, ) -> tuple[bool, str]: """ Check if a specific quest is eligible for a character. Args: quest: The quest to check character: The player character npc_id: The NPC offering the quest location_id: Current location ID Returns: Tuple of (is_eligible, reason_if_not) """ triggers = quest.offering_triggers # Check character level if character.level < triggers.min_character_level: return False, f"Character level too low (need {triggers.min_character_level})" if character.level > triggers.max_character_level: return False, f"Character level too high (max {triggers.max_character_level})" # Check prerequisite quests # Note: We need to check completed_quests on character # For now, check active_quests doesn't contain prereqs (they should be completed) completed_quests = getattr(character, 'completed_quests', []) for prereq_id in triggers.required_quests_completed: if prereq_id not in completed_quests: return False, f"Prerequisite quest not completed: {prereq_id}" # Check quest is not already active if quest.quest_id in character.active_quests: return False, "Quest already active" # Check quest is not already completed if quest.quest_id in completed_quests: return False, "Quest already completed" # Check NPC-specific conditions npc_conditions = quest.get_offer_conditions(npc_id) npc_interaction = character.npc_interactions.get(npc_id, {}) # Check relationship level relationship = npc_interaction.get("relationship_level", 50) if relationship < npc_conditions.min_relationship: return False, f"Relationship too low (need {npc_conditions.min_relationship})" # Check required flags custom_flags = npc_interaction.get("custom_flags", {}) for required_flag in npc_conditions.required_flags: if not custom_flags.get(required_flag): return False, f"Required flag not set: {required_flag}" # Check forbidden flags for forbidden_flag in npc_conditions.forbidden_flags: if custom_flags.get(forbidden_flag): return False, f"Forbidden flag is set: {forbidden_flag}" return True, "" def _get_offer_probability( self, eligible_quests: List[Quest], location_type: str, force_probability: Optional[float] = None, ) -> float: """ Calculate the probability of offering a quest. Uses the highest probability weight among eligible quests for the given location type. Args: eligible_quests: Quests that passed eligibility check location_type: Type of current location force_probability: Override value (for testing) Returns: Probability between 0.0 and 1.0 """ if force_probability is not None: return force_probability # Find the highest probability weight for this location max_probability = 0.0 for quest in eligible_quests: quest_prob = quest.offering_triggers.get_probability(location_type) if quest_prob > max_probability: max_probability = quest_prob # Fall back to default if no quest defines a probability if max_probability == 0.0: max_probability = self.DEFAULT_PROBABILITY_WEIGHTS.get(location_type, 0.10) return max_probability def _select_quest_to_offer(self, eligible_quests: List[Quest]) -> Optional[Quest]: """ Select which quest to offer from eligible quests. Currently uses simple random selection, but could be enhanced to use AI selection or priority weights. Args: eligible_quests: Quests that can be offered Returns: Selected quest or None """ if not eligible_quests: return None # Simple random selection for now # TODO: Could use AI selection or difficulty-based priority return random.choice(eligible_quests) def _build_offer_context( self, quest: Quest, npc_id: str, ) -> QuestOfferContext: """ Build the context for offering a quest. Args: quest: The quest being offered npc_id: The NPC offering it Returns: QuestOfferContext with all necessary data for AI prompt """ return QuestOfferContext( quest=quest, offer_dialogue=quest.get_offer_dialogue(npc_id), npc_quest_knowledge=quest.get_npc_knowledge(npc_id), lore_context=quest.lore_context, narrative_hooks=quest.dialogue_templates.narrative_hooks, ) def get_available_quests_for_display( self, npc_id: str, character: Character, ) -> List[Dict[str, Any]]: """ Get a list of available quests for UI display. This is a simpler check that just returns eligible quests without probability rolls, for showing in a quest list UI. Args: npc_id: The NPC's identifier character: The player character Returns: List of quest display data """ result = self.check_eligibility( npc_id=npc_id, character=character, force_probability=1.0, # Always include eligible quests ) return [ quest.to_offer_dict() for quest in result.eligible_quests ] # Global singleton instance _service_instance: Optional[QuestEligibilityService] = None def get_quest_eligibility_service() -> QuestEligibilityService: """ Get the global QuestEligibilityService instance. Returns: Singleton QuestEligibilityService instance """ global _service_instance if _service_instance is None: _service_instance = QuestEligibilityService() return _service_instance