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:
374
api/app/services/lore_service.py
Normal file
374
api/app/services/lore_service.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
LoreService for retrieving contextual lore for NPC conversations.
|
||||
|
||||
This module provides an interface for lore retrieval that will be
|
||||
implemented with Weaviate in Phase 6. For now, it provides a mock
|
||||
implementation that returns embedded lore from quest definitions.
|
||||
|
||||
The service follows a three-tier knowledge hierarchy:
|
||||
1. World Lore - Global history, mythology, kingdoms
|
||||
2. Regional Lore - Local history, landmarks, rumors
|
||||
3. NPC Persona - Individual NPC knowledge (already in NPC YAML)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional, Protocol
|
||||
import structlog
|
||||
|
||||
from app.models.quest import Quest, QuestLoreContext
|
||||
from app.models.npc import NPC
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoreEntry:
|
||||
"""
|
||||
A single piece of lore information.
|
||||
|
||||
Attributes:
|
||||
content: The lore text content
|
||||
title: Optional title for the lore entry
|
||||
knowledge_type: Type of knowledge (common, academic, secret)
|
||||
source: Where this lore came from (world, regional, quest)
|
||||
metadata: Additional metadata for filtering
|
||||
"""
|
||||
|
||||
content: str
|
||||
title: str = ""
|
||||
knowledge_type: str = "common"
|
||||
source: str = "quest"
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary."""
|
||||
return {
|
||||
"content": self.content,
|
||||
"title": self.title,
|
||||
"knowledge_type": self.knowledge_type,
|
||||
"source": self.source,
|
||||
"metadata": self.metadata,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoreContext:
|
||||
"""
|
||||
Aggregated lore context for AI prompts.
|
||||
|
||||
Contains lore from multiple sources, filtered for the current
|
||||
NPC and conversation context.
|
||||
|
||||
Attributes:
|
||||
world_lore: Global world knowledge entries
|
||||
regional_lore: Local/regional knowledge entries
|
||||
quest_lore: Quest-specific lore entries
|
||||
"""
|
||||
|
||||
world_lore: List[LoreEntry] = field(default_factory=list)
|
||||
regional_lore: List[LoreEntry] = field(default_factory=list)
|
||||
quest_lore: List[LoreEntry] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to dictionary for AI prompt injection."""
|
||||
return {
|
||||
"world": [entry.to_dict() for entry in self.world_lore],
|
||||
"regional": [entry.to_dict() for entry in self.regional_lore],
|
||||
"quest": [entry.to_dict() for entry in self.quest_lore],
|
||||
}
|
||||
|
||||
def has_content(self) -> bool:
|
||||
"""Check if any lore content is available."""
|
||||
return bool(self.world_lore or self.regional_lore or self.quest_lore)
|
||||
|
||||
|
||||
class LoreServiceInterface(ABC):
|
||||
"""
|
||||
Abstract interface for lore services.
|
||||
|
||||
This interface allows swapping between MockLoreService (current)
|
||||
and WeaviateLoreService (Phase 6) without changing calling code.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_lore_context(
|
||||
self,
|
||||
npc: NPC,
|
||||
quest: Optional[Quest] = None,
|
||||
topic: str = "",
|
||||
region_id: str = "",
|
||||
) -> LoreContext:
|
||||
"""
|
||||
Get lore context for an NPC conversation.
|
||||
|
||||
Args:
|
||||
npc: The NPC in the conversation
|
||||
quest: Optional quest being discussed
|
||||
topic: Topic of conversation for semantic search
|
||||
region_id: Region to filter lore by
|
||||
|
||||
Returns:
|
||||
LoreContext with relevant lore entries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def filter_lore_for_npc(
|
||||
self,
|
||||
lore_entries: List[LoreEntry],
|
||||
npc: NPC,
|
||||
) -> List[LoreEntry]:
|
||||
"""
|
||||
Filter lore entries based on what an NPC would know.
|
||||
|
||||
Args:
|
||||
lore_entries: Raw lore entries
|
||||
npc: The NPC to filter for
|
||||
|
||||
Returns:
|
||||
Filtered list of lore entries
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class MockLoreService(LoreServiceInterface):
|
||||
"""
|
||||
Mock implementation of LoreService using embedded quest lore.
|
||||
|
||||
This implementation returns lore directly from quest YAML files
|
||||
until Weaviate is implemented in Phase 6. It provides a working
|
||||
lore system without external dependencies.
|
||||
"""
|
||||
|
||||
# NPC roles that have access to academic knowledge
|
||||
ACADEMIC_ROLES = ["scholar", "wizard", "sage", "librarian", "priest", "mage"]
|
||||
|
||||
# NPC roles that might know secrets
|
||||
SECRET_KEEPER_ROLES = ["mayor", "noble", "spy", "elder", "priest"]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the mock lore service."""
|
||||
logger.info("MockLoreService initialized")
|
||||
|
||||
def get_lore_context(
|
||||
self,
|
||||
npc: NPC,
|
||||
quest: Optional[Quest] = None,
|
||||
topic: str = "",
|
||||
region_id: str = "",
|
||||
) -> LoreContext:
|
||||
"""
|
||||
Get lore context for an NPC conversation.
|
||||
|
||||
For the mock implementation, this primarily returns quest
|
||||
embedded lore and some static regional/world hints.
|
||||
|
||||
Args:
|
||||
npc: The NPC in the conversation
|
||||
quest: Optional quest being discussed
|
||||
topic: Topic of conversation (unused in mock)
|
||||
region_id: Region to filter by
|
||||
|
||||
Returns:
|
||||
LoreContext with available lore
|
||||
"""
|
||||
context = LoreContext()
|
||||
|
||||
# Add quest-specific lore if a quest is provided
|
||||
if quest and quest.lore_context:
|
||||
quest_entries = self._extract_quest_lore(quest)
|
||||
context.quest_lore = self.filter_lore_for_npc(quest_entries, npc)
|
||||
|
||||
# Add some mock regional lore based on NPC location
|
||||
regional_entries = self._get_mock_regional_lore(npc.location_id, region_id)
|
||||
context.regional_lore = self.filter_lore_for_npc(regional_entries, npc)
|
||||
|
||||
# Add world lore if NPC is knowledgeable
|
||||
if npc.role in self.ACADEMIC_ROLES:
|
||||
world_entries = self._get_mock_world_lore()
|
||||
context.world_lore = self.filter_lore_for_npc(world_entries, npc)
|
||||
|
||||
logger.debug(
|
||||
"Lore context built",
|
||||
npc_id=npc.npc_id,
|
||||
quest_lore_count=len(context.quest_lore),
|
||||
regional_lore_count=len(context.regional_lore),
|
||||
world_lore_count=len(context.world_lore),
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
def filter_lore_for_npc(
|
||||
self,
|
||||
lore_entries: List[LoreEntry],
|
||||
npc: NPC,
|
||||
) -> List[LoreEntry]:
|
||||
"""
|
||||
Filter lore entries based on what an NPC would know.
|
||||
|
||||
Args:
|
||||
lore_entries: Raw lore entries
|
||||
npc: The NPC to filter for
|
||||
|
||||
Returns:
|
||||
Filtered list of lore entries
|
||||
"""
|
||||
filtered = []
|
||||
|
||||
for entry in lore_entries:
|
||||
knowledge_type = entry.knowledge_type
|
||||
|
||||
# Academic knowledge requires appropriate role
|
||||
if knowledge_type == "academic":
|
||||
if npc.role not in self.ACADEMIC_ROLES:
|
||||
continue
|
||||
|
||||
# Secret knowledge requires special role or tag
|
||||
if knowledge_type == "secret":
|
||||
if npc.role not in self.SECRET_KEEPER_ROLES:
|
||||
if "secret_keeper" not in npc.tags:
|
||||
continue
|
||||
|
||||
filtered.append(entry)
|
||||
|
||||
return filtered
|
||||
|
||||
def _extract_quest_lore(self, quest: Quest) -> List[LoreEntry]:
|
||||
"""
|
||||
Extract lore entries from a quest's embedded lore context.
|
||||
|
||||
Args:
|
||||
quest: The quest to extract lore from
|
||||
|
||||
Returns:
|
||||
List of LoreEntry objects
|
||||
"""
|
||||
entries = []
|
||||
lore = quest.lore_context
|
||||
|
||||
# Add backstory as a lore entry
|
||||
if lore.backstory:
|
||||
entries.append(LoreEntry(
|
||||
content=lore.backstory,
|
||||
title=f"Background: {quest.name}",
|
||||
knowledge_type="common",
|
||||
source="quest",
|
||||
metadata={"quest_id": quest.quest_id},
|
||||
))
|
||||
|
||||
# Add world connections
|
||||
for connection in lore.world_connections:
|
||||
entries.append(LoreEntry(
|
||||
content=connection,
|
||||
title="World Connection",
|
||||
knowledge_type="common",
|
||||
source="quest",
|
||||
metadata={"quest_id": quest.quest_id},
|
||||
))
|
||||
|
||||
# Add regional hints
|
||||
for hint in lore.regional_hints:
|
||||
entries.append(LoreEntry(
|
||||
content=hint,
|
||||
title="Local Knowledge",
|
||||
knowledge_type="common",
|
||||
source="quest",
|
||||
metadata={"quest_id": quest.quest_id},
|
||||
))
|
||||
|
||||
return entries
|
||||
|
||||
def _get_mock_regional_lore(
|
||||
self,
|
||||
location_id: str,
|
||||
region_id: str,
|
||||
) -> List[LoreEntry]:
|
||||
"""
|
||||
Get mock regional lore for a location.
|
||||
|
||||
This provides basic regional context until Weaviate is implemented.
|
||||
|
||||
Args:
|
||||
location_id: Specific location
|
||||
region_id: Region identifier
|
||||
|
||||
Returns:
|
||||
List of LoreEntry objects
|
||||
"""
|
||||
entries = []
|
||||
|
||||
# Crossville regional lore
|
||||
if "crossville" in location_id.lower() or region_id == "crossville":
|
||||
entries.extend([
|
||||
LoreEntry(
|
||||
content="Crossville was founded two hundred years ago as a trading post.",
|
||||
title="Crossville History",
|
||||
knowledge_type="common",
|
||||
source="regional",
|
||||
),
|
||||
LoreEntry(
|
||||
content="The Old Mines were sealed fifty years ago after a tragic accident.",
|
||||
title="The Old Mines",
|
||||
knowledge_type="common",
|
||||
source="regional",
|
||||
),
|
||||
LoreEntry(
|
||||
content="The Thornwood family has led the village for three generations.",
|
||||
title="Village Leadership",
|
||||
knowledge_type="common",
|
||||
source="regional",
|
||||
),
|
||||
])
|
||||
|
||||
return entries
|
||||
|
||||
def _get_mock_world_lore(self) -> List[LoreEntry]:
|
||||
"""
|
||||
Get mock world-level lore.
|
||||
|
||||
This provides basic world context until Weaviate is implemented.
|
||||
|
||||
Returns:
|
||||
List of LoreEntry objects
|
||||
"""
|
||||
return [
|
||||
LoreEntry(
|
||||
content="The Five Kingdoms united against the Shadow Empire two hundred years ago in a great war.",
|
||||
title="The Great War",
|
||||
knowledge_type="academic",
|
||||
source="world",
|
||||
),
|
||||
LoreEntry(
|
||||
content="Magic flows from the ley lines that crisscross the land, concentrated at ancient sites.",
|
||||
title="The Nature of Magic",
|
||||
knowledge_type="academic",
|
||||
source="world",
|
||||
),
|
||||
LoreEntry(
|
||||
content="The ancient ruins predate human settlement and are believed to be from the First Age.",
|
||||
title="First Age Ruins",
|
||||
knowledge_type="academic",
|
||||
source="world",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
_service_instance: Optional[LoreServiceInterface] = None
|
||||
|
||||
|
||||
def get_lore_service() -> LoreServiceInterface:
|
||||
"""
|
||||
Get the global LoreService instance.
|
||||
|
||||
Currently returns MockLoreService. In Phase 6, this will
|
||||
be updated to return WeaviateLoreService.
|
||||
|
||||
Returns:
|
||||
Singleton LoreService instance
|
||||
"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = MockLoreService()
|
||||
return _service_instance
|
||||
Reference in New Issue
Block a user