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>
375 lines
11 KiB
Python
375 lines
11 KiB
Python
"""
|
|
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
|