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>
424 lines
14 KiB
Python
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
|