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,408 @@
"""
QuestEligibilityService for determining quest offering eligibility.
This service handles the core logic for determining which quests an NPC
can offer to a specific character, including:
- Finding quests where the NPC is a quest giver
- Filtering by character level and prerequisites
- Checking relationship and flag conditions
- Applying probability rolls based on location
"""
import random
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
import structlog
from app.models.quest import Quest, QuestLoreContext
from app.models.character import Character
from app.services.quest_service import get_quest_service
logger = structlog.get_logger(__name__)
@dataclass
class QuestOfferContext:
"""
Context for offering a specific quest during NPC conversation.
Contains all the information needed to inject quest offering
context into the AI prompt for natural dialogue generation.
Attributes:
quest: The quest being offered
offer_dialogue: NPC-specific dialogue for offering this quest
npc_quest_knowledge: Facts the NPC knows about this quest
lore_context: Embedded lore for AI context
narrative_hooks: Hints for natural conversation weaving
"""
quest: Quest
offer_dialogue: str
npc_quest_knowledge: List[str]
lore_context: QuestLoreContext
narrative_hooks: List[str]
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary for AI prompt injection."""
return {
"quest_id": self.quest.quest_id,
"quest_name": self.quest.name,
"quest_description": self.quest.description,
"offer_dialogue": self.offer_dialogue,
"npc_quest_knowledge": self.npc_quest_knowledge,
"lore_backstory": self.lore_context.backstory,
"narrative_hooks": self.narrative_hooks,
"rewards": {
"gold": self.quest.rewards.gold,
"experience": self.quest.rewards.experience,
},
}
@dataclass
class QuestEligibilityResult:
"""
Result of checking quest eligibility for an NPC conversation.
Attributes:
eligible_quests: Quests that can be offered
should_offer_quest: Whether to actually offer a quest (after probability roll)
selected_quest_context: Context for the quest to offer (if should_offer)
blocking_reasons: Why certain quests were filtered out
"""
eligible_quests: List[Quest] = field(default_factory=list)
should_offer_quest: bool = False
selected_quest_context: Optional[QuestOfferContext] = None
blocking_reasons: Dict[str, str] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"eligible_quest_count": len(self.eligible_quests),
"should_offer_quest": self.should_offer_quest,
"selected_quest": (
self.selected_quest_context.to_dict()
if self.selected_quest_context
else None
),
}
class QuestEligibilityService:
"""
Service for determining quest offering eligibility.
This is the core service that implements the quest-centric design:
given an NPC and a character, determine which quests can be offered
and whether an offer should be made based on probability.
"""
# Default probability weights by location type
DEFAULT_PROBABILITY_WEIGHTS = {
"tavern": 0.35,
"town": 0.25,
"shop": 0.20,
"wilderness": 0.05,
"dungeon": 0.10,
"road": 0.10,
}
# Maximum active quests a character can have
MAX_ACTIVE_QUESTS = 2
def __init__(self):
"""Initialize the eligibility service."""
self.quest_service = get_quest_service()
logger.info("QuestEligibilityService initialized")
def check_eligibility(
self,
npc_id: str,
character: Character,
location_type: str = "town",
location_id: str = "",
force_probability: Optional[float] = None,
) -> QuestEligibilityResult:
"""
Check which quests an NPC can offer to a character.
This is the main entry point for quest eligibility checking.
It performs the full pipeline:
1. Find quests where NPC is quest_giver
2. Filter by character eligibility
3. Apply probability roll
4. Build offer context if successful
Args:
npc_id: The NPC's identifier
character: The player character
location_type: Type of current location (for probability)
location_id: Specific location ID
force_probability: Override probability roll (for testing)
Returns:
QuestEligibilityResult with eligible quests and offer context
"""
result = QuestEligibilityResult()
# Check if character already has max quests
if len(character.active_quests) >= self.MAX_ACTIVE_QUESTS:
logger.debug(
"Character at max active quests",
character_id=character.character_id,
active_quests=len(character.active_quests),
)
result.blocking_reasons["max_quests"] = "Character already has maximum active quests"
return result
# Get quests this NPC can offer
npc_quests = self.quest_service.get_quests_for_npc(npc_id)
if not npc_quests:
logger.debug("NPC has no quests to offer", npc_id=npc_id)
return result
# Filter quests by eligibility
for quest in npc_quests:
eligible, reason = self._check_quest_eligibility(
quest, character, npc_id, location_id
)
if eligible:
result.eligible_quests.append(quest)
else:
result.blocking_reasons[quest.quest_id] = reason
if not result.eligible_quests:
logger.debug(
"No eligible quests after filtering",
npc_id=npc_id,
character_id=character.character_id,
)
return result
# Apply probability roll
probability = self._get_offer_probability(
result.eligible_quests, location_type, force_probability
)
roll = random.random()
if roll > probability:
logger.debug(
"Probability roll failed",
probability=probability,
roll=roll,
location_type=location_type,
)
return result
# Select a quest to offer
selected_quest = self._select_quest_to_offer(result.eligible_quests)
if not selected_quest:
return result
# Build offer context
result.should_offer_quest = True
result.selected_quest_context = self._build_offer_context(
selected_quest, npc_id
)
logger.info(
"Quest offer prepared",
quest_id=selected_quest.quest_id,
npc_id=npc_id,
character_id=character.character_id,
)
return result
def _check_quest_eligibility(
self,
quest: Quest,
character: Character,
npc_id: str,
location_id: str,
) -> tuple[bool, str]:
"""
Check if a specific quest is eligible for a character.
Args:
quest: The quest to check
character: The player character
npc_id: The NPC offering the quest
location_id: Current location ID
Returns:
Tuple of (is_eligible, reason_if_not)
"""
triggers = quest.offering_triggers
# Check character level
if character.level < triggers.min_character_level:
return False, f"Character level too low (need {triggers.min_character_level})"
if character.level > triggers.max_character_level:
return False, f"Character level too high (max {triggers.max_character_level})"
# Check prerequisite quests
# Note: We need to check completed_quests on character
# For now, check active_quests doesn't contain prereqs (they should be completed)
completed_quests = getattr(character, 'completed_quests', [])
for prereq_id in triggers.required_quests_completed:
if prereq_id not in completed_quests:
return False, f"Prerequisite quest not completed: {prereq_id}"
# Check quest is not already active
if quest.quest_id in character.active_quests:
return False, "Quest already active"
# Check quest is not already completed
if quest.quest_id in completed_quests:
return False, "Quest already completed"
# Check NPC-specific conditions
npc_conditions = quest.get_offer_conditions(npc_id)
npc_interaction = character.npc_interactions.get(npc_id, {})
# Check relationship level
relationship = npc_interaction.get("relationship_level", 50)
if relationship < npc_conditions.min_relationship:
return False, f"Relationship too low (need {npc_conditions.min_relationship})"
# Check required flags
custom_flags = npc_interaction.get("custom_flags", {})
for required_flag in npc_conditions.required_flags:
if not custom_flags.get(required_flag):
return False, f"Required flag not set: {required_flag}"
# Check forbidden flags
for forbidden_flag in npc_conditions.forbidden_flags:
if custom_flags.get(forbidden_flag):
return False, f"Forbidden flag is set: {forbidden_flag}"
return True, ""
def _get_offer_probability(
self,
eligible_quests: List[Quest],
location_type: str,
force_probability: Optional[float] = None,
) -> float:
"""
Calculate the probability of offering a quest.
Uses the highest probability weight among eligible quests
for the given location type.
Args:
eligible_quests: Quests that passed eligibility check
location_type: Type of current location
force_probability: Override value (for testing)
Returns:
Probability between 0.0 and 1.0
"""
if force_probability is not None:
return force_probability
# Find the highest probability weight for this location
max_probability = 0.0
for quest in eligible_quests:
quest_prob = quest.offering_triggers.get_probability(location_type)
if quest_prob > max_probability:
max_probability = quest_prob
# Fall back to default if no quest defines a probability
if max_probability == 0.0:
max_probability = self.DEFAULT_PROBABILITY_WEIGHTS.get(location_type, 0.10)
return max_probability
def _select_quest_to_offer(self, eligible_quests: List[Quest]) -> Optional[Quest]:
"""
Select which quest to offer from eligible quests.
Currently uses simple random selection, but could be enhanced
to use AI selection or priority weights.
Args:
eligible_quests: Quests that can be offered
Returns:
Selected quest or None
"""
if not eligible_quests:
return None
# Simple random selection for now
# TODO: Could use AI selection or difficulty-based priority
return random.choice(eligible_quests)
def _build_offer_context(
self,
quest: Quest,
npc_id: str,
) -> QuestOfferContext:
"""
Build the context for offering a quest.
Args:
quest: The quest being offered
npc_id: The NPC offering it
Returns:
QuestOfferContext with all necessary data for AI prompt
"""
return QuestOfferContext(
quest=quest,
offer_dialogue=quest.get_offer_dialogue(npc_id),
npc_quest_knowledge=quest.get_npc_knowledge(npc_id),
lore_context=quest.lore_context,
narrative_hooks=quest.dialogue_templates.narrative_hooks,
)
def get_available_quests_for_display(
self,
npc_id: str,
character: Character,
) -> List[Dict[str, Any]]:
"""
Get a list of available quests for UI display.
This is a simpler check that just returns eligible quests
without probability rolls, for showing in a quest list UI.
Args:
npc_id: The NPC's identifier
character: The player character
Returns:
List of quest display data
"""
result = self.check_eligibility(
npc_id=npc_id,
character=character,
force_probability=1.0, # Always include eligible quests
)
return [
quest.to_offer_dict()
for quest in result.eligible_quests
]
# Global singleton instance
_service_instance: Optional[QuestEligibilityService] = None
def get_quest_eligibility_service() -> QuestEligibilityService:
"""
Get the global QuestEligibilityService instance.
Returns:
Singleton QuestEligibilityService instance
"""
global _service_instance
if _service_instance is None:
_service_instance = QuestEligibilityService()
return _service_instance