""" Quest data models for the quest system. This module defines Quest and related dataclasses for the YAML-driven quest system. Quests are loaded from YAML files and define their own NPC givers, objectives, rewards, and offering conditions. """ from dataclasses import dataclass, field from typing import Dict, List, Any, Optional from enum import Enum class ObjectiveType(Enum): """Types of quest objectives.""" KILL = "kill" COLLECT = "collect" TRAVEL = "travel" INTERACT = "interact" DISCOVER = "discover" class QuestDifficulty(Enum): """Quest difficulty levels.""" EASY = "easy" MEDIUM = "medium" HARD = "hard" EPIC = "epic" class QuestStatus(Enum): """Status of a quest for a character.""" AVAILABLE = "available" ACTIVE = "active" COMPLETED = "completed" FAILED = "failed" @dataclass class QuestObjective: """ A single objective within a quest. Objectives track player progress toward quest completion. Each objective has a type (kill, collect, etc.) and a required progress amount. Attributes: objective_id: Unique identifier within the quest description: Player-facing description (e.g., "Kill 10 giant rats") objective_type: Type of objective (kill, collect, travel, etc.) required_progress: Target amount to complete (e.g., 10 for 10 kills) current_progress: Current progress (tracked on character's quest state) target_enemy_type: For kill objectives - enemy type to kill target_item_id: For collect objectives - item to collect target_location_id: For travel/discover objectives - location to reach target_npc_id: For interact objectives - NPC to interact with """ objective_id: str description: str objective_type: ObjectiveType required_progress: int = 1 current_progress: int = 0 target_enemy_type: Optional[str] = None target_item_id: Optional[str] = None target_location_id: Optional[str] = None target_npc_id: Optional[str] = None @property def is_complete(self) -> bool: """Check if this objective is complete.""" return self.current_progress >= self.required_progress @property def progress_text(self) -> str: """Get formatted progress text (e.g., '5/10').""" return f"{self.current_progress}/{self.required_progress}" def to_dict(self) -> Dict[str, Any]: """Serialize objective to dictionary.""" return { "objective_id": self.objective_id, "description": self.description, "objective_type": self.objective_type.value, "required_progress": self.required_progress, "current_progress": self.current_progress, "target_enemy_type": self.target_enemy_type, "target_item_id": self.target_item_id, "target_location_id": self.target_location_id, "target_npc_id": self.target_npc_id, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective': """Deserialize objective from dictionary.""" return cls( objective_id=data["objective_id"], description=data["description"], objective_type=ObjectiveType(data["objective_type"]), required_progress=data.get("required_progress", 1), current_progress=data.get("current_progress", 0), target_enemy_type=data.get("target_enemy_type"), target_item_id=data.get("target_item_id"), target_location_id=data.get("target_location_id"), target_npc_id=data.get("target_npc_id"), ) @dataclass class QuestReward: """ Rewards granted upon quest completion. Attributes: gold: Gold amount experience: XP amount items: List of item IDs to grant relationship_bonuses: NPC relationship increases {npc_id: amount} unlocks_quests: Quest IDs that become available after completion reveals_locations: Location IDs revealed upon completion """ gold: int = 0 experience: int = 0 items: List[str] = field(default_factory=list) relationship_bonuses: Dict[str, int] = field(default_factory=dict) unlocks_quests: List[str] = field(default_factory=list) reveals_locations: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """Serialize rewards to dictionary.""" return { "gold": self.gold, "experience": self.experience, "items": self.items, "relationship_bonuses": self.relationship_bonuses, "unlocks_quests": self.unlocks_quests, "reveals_locations": self.reveals_locations, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward': """Deserialize rewards from dictionary.""" return cls( gold=data.get("gold", 0), experience=data.get("experience", 0), items=data.get("items", []), relationship_bonuses=data.get("relationship_bonuses", {}), unlocks_quests=data.get("unlocks_quests", []), reveals_locations=data.get("reveals_locations", []), ) @dataclass class QuestOfferConditions: """ Conditions required for an NPC to offer this quest. These are checked per-NPC to determine if they can offer the quest to a specific character based on relationship and flags. Attributes: min_relationship: Minimum relationship level required (0-100) required_flags: Custom flags that must be set on character's NPC interaction forbidden_flags: Custom flags that must NOT be set (e.g., "refused_this_quest") """ min_relationship: int = 0 required_flags: List[str] = field(default_factory=list) forbidden_flags: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """Serialize conditions to dictionary.""" return { "min_relationship": self.min_relationship, "required_flags": self.required_flags, "forbidden_flags": self.forbidden_flags, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferConditions': """Deserialize conditions from dictionary.""" return cls( min_relationship=data.get("min_relationship", 0), required_flags=data.get("required_flags", []), forbidden_flags=data.get("forbidden_flags", []), ) @dataclass class NPCOfferDialogue: """ NPC-specific dialogue for offering a quest. Each NPC can have custom dialogue and conditions for offering a quest, allowing different NPCs to present the same quest differently. Attributes: dialogue: The custom offer dialogue for this NPC conditions: Conditions for this NPC to offer the quest """ dialogue: str conditions: QuestOfferConditions = field(default_factory=QuestOfferConditions) def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary.""" return { "dialogue": self.dialogue, "conditions": self.conditions.to_dict(), } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'NPCOfferDialogue': """Deserialize from dictionary.""" conditions = QuestOfferConditions() if data.get("conditions"): conditions = QuestOfferConditions.from_dict(data["conditions"]) return cls( dialogue=data.get("dialogue", ""), conditions=conditions, ) @dataclass class QuestOfferingTriggers: """ Conditions for when a quest can be offered. Controls the probability and eligibility for quest offering based on location, character level, and prerequisite quests. Attributes: location_types: Location types where quest can be offered (e.g., ["tavern", "town"]) specific_locations: Specific location IDs (if more restrictive than types) min_character_level: Minimum character level to receive quest max_character_level: Maximum character level (optional upper bound) required_quests_completed: Quest IDs that must be completed first probability_weights: Probability by location type (e.g., {"tavern": 0.35}) """ location_types: List[str] = field(default_factory=list) specific_locations: List[str] = field(default_factory=list) min_character_level: int = 1 max_character_level: int = 100 required_quests_completed: List[str] = field(default_factory=list) probability_weights: Dict[str, float] = field(default_factory=dict) def get_probability(self, location_type: str) -> float: """ Get the offering probability for a location type. Args: location_type: Type of location (e.g., "tavern", "town", "wilderness") Returns: Probability between 0.0 and 1.0 """ return self.probability_weights.get(location_type, 0.0) def to_dict(self) -> Dict[str, Any]: """Serialize triggers to dictionary.""" return { "location_types": self.location_types, "specific_locations": self.specific_locations, "min_character_level": self.min_character_level, "max_character_level": self.max_character_level, "required_quests_completed": self.required_quests_completed, "probability_weights": self.probability_weights, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferingTriggers': """Deserialize triggers from dictionary.""" return cls( location_types=data.get("location_types", []), specific_locations=data.get("specific_locations", []), min_character_level=data.get("min_character_level", 1), max_character_level=data.get("max_character_level", 100), required_quests_completed=data.get("required_quests_completed", []), probability_weights=data.get("probability_weights", {}), ) @dataclass class QuestDialogueTemplates: """ Dialogue templates for natural quest offering. Provides narrative hooks and hints that the AI can use to naturally weave quest offers into conversation. Attributes: narrative_hooks: Phrases the NPC might mention (e.g., "mentions scratching sounds") ambient_hints: Environmental descriptions (e.g., "You notice scratch marks") """ narrative_hooks: List[str] = field(default_factory=list) ambient_hints: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary.""" return { "narrative_hooks": self.narrative_hooks, "ambient_hints": self.ambient_hints, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'QuestDialogueTemplates': """Deserialize from dictionary.""" return cls( narrative_hooks=data.get("narrative_hooks", []), ambient_hints=data.get("ambient_hints", []), ) @dataclass class QuestLoreContext: """ Embedded lore context for quest offering. Provides backstory and world context that the AI can reference when discussing the quest with players. This serves as a stub until the Weaviate vector database is implemented in Phase 6. Attributes: backstory: Background story for this quest world_connections: How this quest connects to world events regional_hints: Local/regional information related to the quest """ backstory: str = "" world_connections: List[str] = field(default_factory=list) regional_hints: List[str] = field(default_factory=list) def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary.""" return { "backstory": self.backstory, "world_connections": self.world_connections, "regional_hints": self.regional_hints, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'QuestLoreContext': """Deserialize from dictionary.""" return cls( backstory=data.get("backstory", ""), world_connections=data.get("world_connections", []), regional_hints=data.get("regional_hints", []), ) @dataclass class Quest: """ Complete quest definition loaded from YAML. Quests are the central data structure for the quest system. They define their own NPC givers (quest-centric design), objectives, rewards, and offering conditions. Attributes: quest_id: Unique identifier (e.g., "quest_cellar_rats") name: Display name (e.g., "Rat Problem in the Cellar") description: Full quest description difficulty: Quest difficulty level quest_giver_npc_ids: NPCs who can offer this quest quest_giver_name: Display name fallback for quest giver location_id: Primary location for this quest region_id: Region this quest belongs to objectives: List of objectives to complete rewards: Rewards for completion offering_triggers: When/where quest can be offered npc_offer_dialogues: NPC-specific offer dialogue {npc_id: NPCOfferDialogue} npc_quest_knowledge: What NPCs know about this quest {npc_id: [facts]} lore_context: Embedded lore for AI context dialogue_templates: Narrative hooks for natural offering completion_dialogue: NPC dialogue upon completion {npc_id: dialogue} tags: Metadata tags for filtering """ quest_id: str name: str description: str difficulty: QuestDifficulty quest_giver_npc_ids: List[str] quest_giver_name: str = "" location_id: str = "" region_id: str = "" objectives: List[QuestObjective] = field(default_factory=list) rewards: QuestReward = field(default_factory=QuestReward) offering_triggers: QuestOfferingTriggers = field(default_factory=QuestOfferingTriggers) npc_offer_dialogues: Dict[str, NPCOfferDialogue] = field(default_factory=dict) npc_quest_knowledge: Dict[str, List[str]] = field(default_factory=dict) lore_context: QuestLoreContext = field(default_factory=QuestLoreContext) dialogue_templates: QuestDialogueTemplates = field(default_factory=QuestDialogueTemplates) completion_dialogue: Dict[str, str] = field(default_factory=dict) tags: List[str] = field(default_factory=list) @property def is_complete(self) -> bool: """Check if all objectives are complete.""" return all(obj.is_complete for obj in self.objectives) def get_offer_dialogue(self, npc_id: str) -> str: """ Get the offer dialogue for a specific NPC. Args: npc_id: The NPC's identifier Returns: Custom dialogue if defined, otherwise empty string """ if npc_id in self.npc_offer_dialogues: return self.npc_offer_dialogues[npc_id].dialogue return "" def get_offer_conditions(self, npc_id: str) -> QuestOfferConditions: """ Get the offer conditions for a specific NPC. Args: npc_id: The NPC's identifier Returns: Conditions if defined, otherwise default conditions """ if npc_id in self.npc_offer_dialogues: return self.npc_offer_dialogues[npc_id].conditions return QuestOfferConditions() def get_npc_knowledge(self, npc_id: str) -> List[str]: """ Get what an NPC knows about this quest. Args: npc_id: The NPC's identifier Returns: List of facts the NPC knows about the quest """ return self.npc_quest_knowledge.get(npc_id, []) def get_completion_dialogue(self, npc_id: str) -> str: """ Get the completion dialogue for a specific NPC. Args: npc_id: The NPC's identifier Returns: Custom completion dialogue if defined, otherwise empty string """ return self.completion_dialogue.get(npc_id, "") def can_npc_offer(self, npc_id: str) -> bool: """ Check if an NPC can offer this quest. Args: npc_id: The NPC's identifier Returns: True if NPC is in quest_giver_npc_ids """ return npc_id in self.quest_giver_npc_ids def to_dict(self) -> Dict[str, Any]: """Serialize quest to dictionary.""" return { "quest_id": self.quest_id, "name": self.name, "description": self.description, "difficulty": self.difficulty.value, "quest_giver_npc_ids": self.quest_giver_npc_ids, "quest_giver_name": self.quest_giver_name, "location_id": self.location_id, "region_id": self.region_id, "objectives": [obj.to_dict() for obj in self.objectives], "rewards": self.rewards.to_dict(), "offering_triggers": self.offering_triggers.to_dict(), "npc_offer_dialogues": { npc_id: dialogue.to_dict() for npc_id, dialogue in self.npc_offer_dialogues.items() }, "npc_quest_knowledge": self.npc_quest_knowledge, "lore_context": self.lore_context.to_dict(), "dialogue_templates": self.dialogue_templates.to_dict(), "completion_dialogue": self.completion_dialogue, "tags": self.tags, } def to_offer_dict(self) -> Dict[str, Any]: """ Serialize quest for offering UI display. Returns a trimmed version suitable for displaying to players when a quest is being offered. Returns: Dictionary with quest offer display data """ return { "quest_id": self.quest_id, "name": self.name, "description": self.description, "difficulty": self.difficulty.value, "quest_giver_name": self.quest_giver_name, "objectives": [ {"description": obj.description, "progress_text": obj.progress_text} for obj in self.objectives ], "rewards": { "gold": self.rewards.gold, "experience": self.rewards.experience, "items": self.rewards.items, }, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'Quest': """Deserialize quest from dictionary.""" # Parse objectives objectives = [ QuestObjective.from_dict(obj) for obj in data.get("objectives", []) ] # Parse rewards rewards = QuestReward() if data.get("rewards"): rewards = QuestReward.from_dict(data["rewards"]) # Parse offering triggers offering_triggers = QuestOfferingTriggers() if data.get("offering_triggers"): offering_triggers = QuestOfferingTriggers.from_dict(data["offering_triggers"]) # Parse NPC offer dialogues npc_offer_dialogues = {} for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items(): npc_offer_dialogues[npc_id] = NPCOfferDialogue.from_dict(dialogue_data) # Parse lore context lore_context = QuestLoreContext() if data.get("lore_context"): lore_context = QuestLoreContext.from_dict(data["lore_context"]) # Parse dialogue templates dialogue_templates = QuestDialogueTemplates() if data.get("dialogue_templates"): dialogue_templates = QuestDialogueTemplates.from_dict(data["dialogue_templates"]) return cls( 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 __repr__(self) -> str: """String representation of the quest.""" return f"Quest({self.quest_id}, {self.name}, {self.difficulty.value})" @dataclass class CharacterQuestState: """ Tracks a character's progress on an active quest. Stored in character's quest tracking data to persist progress across sessions. Attributes: quest_id: The quest being tracked status: Current quest status accepted_at: ISO timestamp when quest was accepted objectives_progress: Progress on each objective {objective_id: current_progress} completed_at: ISO timestamp when quest was completed (if completed) """ quest_id: str status: QuestStatus = QuestStatus.ACTIVE accepted_at: str = "" objectives_progress: Dict[str, int] = field(default_factory=dict) completed_at: Optional[str] = None def update_progress(self, objective_id: str, amount: int = 1) -> None: """ Update progress on an objective. Args: objective_id: The objective to update amount: Amount to add to progress """ current = self.objectives_progress.get(objective_id, 0) self.objectives_progress[objective_id] = current + amount def get_progress(self, objective_id: str) -> int: """ Get current progress on an objective. Args: objective_id: The objective to check Returns: Current progress amount """ return self.objectives_progress.get(objective_id, 0) def to_dict(self) -> Dict[str, Any]: """Serialize to dictionary.""" return { "quest_id": self.quest_id, "status": self.status.value, "accepted_at": self.accepted_at, "objectives_progress": self.objectives_progress, "completed_at": self.completed_at, } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'CharacterQuestState': """Deserialize from dictionary.""" return cls( quest_id=data["quest_id"], status=QuestStatus(data.get("status", "active")), accepted_at=data.get("accepted_at", ""), objectives_progress=data.get("objectives_progress", {}), completed_at=data.get("completed_at"), ) def __repr__(self) -> str: """String representation.""" return f"CharacterQuestState({self.quest_id}, {self.status.value})"