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:
2025-11-29 15:42:55 -06:00
parent e7e329e6ed
commit df26abd207
42 changed files with 8421 additions and 2227 deletions

View 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