Files
Code_of_Conquest/api/app/services/quest_service.py
Phillip Tarrant df26abd207 feat: Implement Phase 5 Quest System (100% complete)
Add YAML-driven quest system with context-aware offering:

Core Implementation:
- Quest data models (Quest, QuestObjective, QuestReward, QuestTriggers)
- QuestService for YAML loading and caching
- QuestEligibilityService with level, location, and probability filtering
- LoreService stub (MockLoreService) ready for Phase 6 Weaviate integration

Quest Content:
- 5 example quests across difficulty tiers (2 easy, 2 medium, 1 hard)
- Quest-centric design: quests define their NPC givers
- Location-based probability weights for natural quest offering

AI Integration:
- Quest offering section in npc_dialogue.j2 template
- Response parser extracts [QUEST_OFFER:quest_id] markers
- AI naturally weaves quest offers into NPC conversations

API Endpoints:
- POST /api/v1/quests/accept - Accept quest offer
- POST /api/v1/quests/decline - Decline quest offer
- POST /api/v1/quests/progress - Update objective progress
- POST /api/v1/quests/complete - Complete quest, claim rewards
- POST /api/v1/quests/abandon - Abandon active quest
- GET /api/v1/characters/{id}/quests - List character quests
- GET /api/v1/quests/{quest_id} - Get quest details

Frontend:
- Quest tracker sidebar with HTMX integration
- Quest offer modal for accept/decline flow
- Quest detail modal for viewing progress
- Combat service integration for kill objective tracking

Testing:
- Unit tests for Quest models and serialization
- Integration tests for full quest lifecycle
- Comprehensive test coverage for eligibility service

Documentation:
- Reorganized docs into /docs/phases/ structure
- Added Phase 5-12 planning documents
- Updated ROADMAP.md with new structure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 15:42:55 -06:00

424 lines
14 KiB
Python

"""
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