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>
409 lines
13 KiB
Python
409 lines
13 KiB
Python
"""
|
|
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
|