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>
This commit is contained in:
423
api/app/services/quest_service.py
Normal file
423
api/app/services/quest_service.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user