Files
Code_of_Conquest/api/app/models/quest.py
Phillip Tarrant df26abd207 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>
2025-11-29 15:42:55 -06:00

650 lines
23 KiB
Python

"""
Quest data models for the quest system.
This module defines Quest and related dataclasses for the YAML-driven quest
system. Quests are loaded from YAML files and define their own NPC givers,
objectives, rewards, and offering conditions.
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional
from enum import Enum
class ObjectiveType(Enum):
"""Types of quest objectives."""
KILL = "kill"
COLLECT = "collect"
TRAVEL = "travel"
INTERACT = "interact"
DISCOVER = "discover"
class QuestDifficulty(Enum):
"""Quest difficulty levels."""
EASY = "easy"
MEDIUM = "medium"
HARD = "hard"
EPIC = "epic"
class QuestStatus(Enum):
"""Status of a quest for a character."""
AVAILABLE = "available"
ACTIVE = "active"
COMPLETED = "completed"
FAILED = "failed"
@dataclass
class QuestObjective:
"""
A single objective within a quest.
Objectives track player progress toward quest completion. Each objective
has a type (kill, collect, etc.) and a required progress amount.
Attributes:
objective_id: Unique identifier within the quest
description: Player-facing description (e.g., "Kill 10 giant rats")
objective_type: Type of objective (kill, collect, travel, etc.)
required_progress: Target amount to complete (e.g., 10 for 10 kills)
current_progress: Current progress (tracked on character's quest state)
target_enemy_type: For kill objectives - enemy type to kill
target_item_id: For collect objectives - item to collect
target_location_id: For travel/discover objectives - location to reach
target_npc_id: For interact objectives - NPC to interact with
"""
objective_id: str
description: str
objective_type: ObjectiveType
required_progress: int = 1
current_progress: int = 0
target_enemy_type: Optional[str] = None
target_item_id: Optional[str] = None
target_location_id: Optional[str] = None
target_npc_id: Optional[str] = None
@property
def is_complete(self) -> bool:
"""Check if this objective is complete."""
return self.current_progress >= self.required_progress
@property
def progress_text(self) -> str:
"""Get formatted progress text (e.g., '5/10')."""
return f"{self.current_progress}/{self.required_progress}"
def to_dict(self) -> Dict[str, Any]:
"""Serialize objective to dictionary."""
return {
"objective_id": self.objective_id,
"description": self.description,
"objective_type": self.objective_type.value,
"required_progress": self.required_progress,
"current_progress": self.current_progress,
"target_enemy_type": self.target_enemy_type,
"target_item_id": self.target_item_id,
"target_location_id": self.target_location_id,
"target_npc_id": self.target_npc_id,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective':
"""Deserialize objective from dictionary."""
return cls(
objective_id=data["objective_id"],
description=data["description"],
objective_type=ObjectiveType(data["objective_type"]),
required_progress=data.get("required_progress", 1),
current_progress=data.get("current_progress", 0),
target_enemy_type=data.get("target_enemy_type"),
target_item_id=data.get("target_item_id"),
target_location_id=data.get("target_location_id"),
target_npc_id=data.get("target_npc_id"),
)
@dataclass
class QuestReward:
"""
Rewards granted upon quest completion.
Attributes:
gold: Gold amount
experience: XP amount
items: List of item IDs to grant
relationship_bonuses: NPC relationship increases {npc_id: amount}
unlocks_quests: Quest IDs that become available after completion
reveals_locations: Location IDs revealed upon completion
"""
gold: int = 0
experience: int = 0
items: List[str] = field(default_factory=list)
relationship_bonuses: Dict[str, int] = field(default_factory=dict)
unlocks_quests: List[str] = field(default_factory=list)
reveals_locations: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize rewards to dictionary."""
return {
"gold": self.gold,
"experience": self.experience,
"items": self.items,
"relationship_bonuses": self.relationship_bonuses,
"unlocks_quests": self.unlocks_quests,
"reveals_locations": self.reveals_locations,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward':
"""Deserialize rewards from dictionary."""
return cls(
gold=data.get("gold", 0),
experience=data.get("experience", 0),
items=data.get("items", []),
relationship_bonuses=data.get("relationship_bonuses", {}),
unlocks_quests=data.get("unlocks_quests", []),
reveals_locations=data.get("reveals_locations", []),
)
@dataclass
class QuestOfferConditions:
"""
Conditions required for an NPC to offer this quest.
These are checked per-NPC to determine if they can offer the quest
to a specific character based on relationship and flags.
Attributes:
min_relationship: Minimum relationship level required (0-100)
required_flags: Custom flags that must be set on character's NPC interaction
forbidden_flags: Custom flags that must NOT be set (e.g., "refused_this_quest")
"""
min_relationship: int = 0
required_flags: List[str] = field(default_factory=list)
forbidden_flags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize conditions to dictionary."""
return {
"min_relationship": self.min_relationship,
"required_flags": self.required_flags,
"forbidden_flags": self.forbidden_flags,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferConditions':
"""Deserialize conditions from dictionary."""
return cls(
min_relationship=data.get("min_relationship", 0),
required_flags=data.get("required_flags", []),
forbidden_flags=data.get("forbidden_flags", []),
)
@dataclass
class NPCOfferDialogue:
"""
NPC-specific dialogue for offering a quest.
Each NPC can have custom dialogue and conditions for offering a quest,
allowing different NPCs to present the same quest differently.
Attributes:
dialogue: The custom offer dialogue for this NPC
conditions: Conditions for this NPC to offer the quest
"""
dialogue: str
conditions: QuestOfferConditions = field(default_factory=QuestOfferConditions)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"dialogue": self.dialogue,
"conditions": self.conditions.to_dict(),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'NPCOfferDialogue':
"""Deserialize from dictionary."""
conditions = QuestOfferConditions()
if data.get("conditions"):
conditions = QuestOfferConditions.from_dict(data["conditions"])
return cls(
dialogue=data.get("dialogue", ""),
conditions=conditions,
)
@dataclass
class QuestOfferingTriggers:
"""
Conditions for when a quest can be offered.
Controls the probability and eligibility for quest offering based on
location, character level, and prerequisite quests.
Attributes:
location_types: Location types where quest can be offered (e.g., ["tavern", "town"])
specific_locations: Specific location IDs (if more restrictive than types)
min_character_level: Minimum character level to receive quest
max_character_level: Maximum character level (optional upper bound)
required_quests_completed: Quest IDs that must be completed first
probability_weights: Probability by location type (e.g., {"tavern": 0.35})
"""
location_types: List[str] = field(default_factory=list)
specific_locations: List[str] = field(default_factory=list)
min_character_level: int = 1
max_character_level: int = 100
required_quests_completed: List[str] = field(default_factory=list)
probability_weights: Dict[str, float] = field(default_factory=dict)
def get_probability(self, location_type: str) -> float:
"""
Get the offering probability for a location type.
Args:
location_type: Type of location (e.g., "tavern", "town", "wilderness")
Returns:
Probability between 0.0 and 1.0
"""
return self.probability_weights.get(location_type, 0.0)
def to_dict(self) -> Dict[str, Any]:
"""Serialize triggers to dictionary."""
return {
"location_types": self.location_types,
"specific_locations": self.specific_locations,
"min_character_level": self.min_character_level,
"max_character_level": self.max_character_level,
"required_quests_completed": self.required_quests_completed,
"probability_weights": self.probability_weights,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestOfferingTriggers':
"""Deserialize triggers from dictionary."""
return cls(
location_types=data.get("location_types", []),
specific_locations=data.get("specific_locations", []),
min_character_level=data.get("min_character_level", 1),
max_character_level=data.get("max_character_level", 100),
required_quests_completed=data.get("required_quests_completed", []),
probability_weights=data.get("probability_weights", {}),
)
@dataclass
class QuestDialogueTemplates:
"""
Dialogue templates for natural quest offering.
Provides narrative hooks and hints that the AI can use to naturally
weave quest offers into conversation.
Attributes:
narrative_hooks: Phrases the NPC might mention (e.g., "mentions scratching sounds")
ambient_hints: Environmental descriptions (e.g., "You notice scratch marks")
"""
narrative_hooks: List[str] = field(default_factory=list)
ambient_hints: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"narrative_hooks": self.narrative_hooks,
"ambient_hints": self.ambient_hints,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestDialogueTemplates':
"""Deserialize from dictionary."""
return cls(
narrative_hooks=data.get("narrative_hooks", []),
ambient_hints=data.get("ambient_hints", []),
)
@dataclass
class QuestLoreContext:
"""
Embedded lore context for quest offering.
Provides backstory and world context that the AI can reference when
discussing the quest with players. This serves as a stub until the
Weaviate vector database is implemented in Phase 6.
Attributes:
backstory: Background story for this quest
world_connections: How this quest connects to world events
regional_hints: Local/regional information related to the quest
"""
backstory: str = ""
world_connections: List[str] = field(default_factory=list)
regional_hints: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"backstory": self.backstory,
"world_connections": self.world_connections,
"regional_hints": self.regional_hints,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestLoreContext':
"""Deserialize from dictionary."""
return cls(
backstory=data.get("backstory", ""),
world_connections=data.get("world_connections", []),
regional_hints=data.get("regional_hints", []),
)
@dataclass
class Quest:
"""
Complete quest definition loaded from YAML.
Quests are the central data structure for the quest system. They define
their own NPC givers (quest-centric design), objectives, rewards, and
offering conditions.
Attributes:
quest_id: Unique identifier (e.g., "quest_cellar_rats")
name: Display name (e.g., "Rat Problem in the Cellar")
description: Full quest description
difficulty: Quest difficulty level
quest_giver_npc_ids: NPCs who can offer this quest
quest_giver_name: Display name fallback for quest giver
location_id: Primary location for this quest
region_id: Region this quest belongs to
objectives: List of objectives to complete
rewards: Rewards for completion
offering_triggers: When/where quest can be offered
npc_offer_dialogues: NPC-specific offer dialogue {npc_id: NPCOfferDialogue}
npc_quest_knowledge: What NPCs know about this quest {npc_id: [facts]}
lore_context: Embedded lore for AI context
dialogue_templates: Narrative hooks for natural offering
completion_dialogue: NPC dialogue upon completion {npc_id: dialogue}
tags: Metadata tags for filtering
"""
quest_id: str
name: str
description: str
difficulty: QuestDifficulty
quest_giver_npc_ids: List[str]
quest_giver_name: str = ""
location_id: str = ""
region_id: str = ""
objectives: List[QuestObjective] = field(default_factory=list)
rewards: QuestReward = field(default_factory=QuestReward)
offering_triggers: QuestOfferingTriggers = field(default_factory=QuestOfferingTriggers)
npc_offer_dialogues: Dict[str, NPCOfferDialogue] = field(default_factory=dict)
npc_quest_knowledge: Dict[str, List[str]] = field(default_factory=dict)
lore_context: QuestLoreContext = field(default_factory=QuestLoreContext)
dialogue_templates: QuestDialogueTemplates = field(default_factory=QuestDialogueTemplates)
completion_dialogue: Dict[str, str] = field(default_factory=dict)
tags: List[str] = field(default_factory=list)
@property
def is_complete(self) -> bool:
"""Check if all objectives are complete."""
return all(obj.is_complete for obj in self.objectives)
def get_offer_dialogue(self, npc_id: str) -> str:
"""
Get the offer dialogue for a specific NPC.
Args:
npc_id: The NPC's identifier
Returns:
Custom dialogue if defined, otherwise empty string
"""
if npc_id in self.npc_offer_dialogues:
return self.npc_offer_dialogues[npc_id].dialogue
return ""
def get_offer_conditions(self, npc_id: str) -> QuestOfferConditions:
"""
Get the offer conditions for a specific NPC.
Args:
npc_id: The NPC's identifier
Returns:
Conditions if defined, otherwise default conditions
"""
if npc_id in self.npc_offer_dialogues:
return self.npc_offer_dialogues[npc_id].conditions
return QuestOfferConditions()
def get_npc_knowledge(self, npc_id: str) -> List[str]:
"""
Get what an NPC knows about this quest.
Args:
npc_id: The NPC's identifier
Returns:
List of facts the NPC knows about the quest
"""
return self.npc_quest_knowledge.get(npc_id, [])
def get_completion_dialogue(self, npc_id: str) -> str:
"""
Get the completion dialogue for a specific NPC.
Args:
npc_id: The NPC's identifier
Returns:
Custom completion dialogue if defined, otherwise empty string
"""
return self.completion_dialogue.get(npc_id, "")
def can_npc_offer(self, npc_id: str) -> bool:
"""
Check if an NPC can offer this quest.
Args:
npc_id: The NPC's identifier
Returns:
True if NPC is in quest_giver_npc_ids
"""
return npc_id in self.quest_giver_npc_ids
def to_dict(self) -> Dict[str, Any]:
"""Serialize quest to dictionary."""
return {
"quest_id": self.quest_id,
"name": self.name,
"description": self.description,
"difficulty": self.difficulty.value,
"quest_giver_npc_ids": self.quest_giver_npc_ids,
"quest_giver_name": self.quest_giver_name,
"location_id": self.location_id,
"region_id": self.region_id,
"objectives": [obj.to_dict() for obj in self.objectives],
"rewards": self.rewards.to_dict(),
"offering_triggers": self.offering_triggers.to_dict(),
"npc_offer_dialogues": {
npc_id: dialogue.to_dict()
for npc_id, dialogue in self.npc_offer_dialogues.items()
},
"npc_quest_knowledge": self.npc_quest_knowledge,
"lore_context": self.lore_context.to_dict(),
"dialogue_templates": self.dialogue_templates.to_dict(),
"completion_dialogue": self.completion_dialogue,
"tags": self.tags,
}
def to_offer_dict(self) -> Dict[str, Any]:
"""
Serialize quest for offering UI display.
Returns a trimmed version suitable for displaying to players
when a quest is being offered.
Returns:
Dictionary with quest offer display data
"""
return {
"quest_id": self.quest_id,
"name": self.name,
"description": self.description,
"difficulty": self.difficulty.value,
"quest_giver_name": self.quest_giver_name,
"objectives": [
{"description": obj.description, "progress_text": obj.progress_text}
for obj in self.objectives
],
"rewards": {
"gold": self.rewards.gold,
"experience": self.rewards.experience,
"items": self.rewards.items,
},
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Quest':
"""Deserialize quest from dictionary."""
# Parse objectives
objectives = [
QuestObjective.from_dict(obj)
for obj in data.get("objectives", [])
]
# Parse rewards
rewards = QuestReward()
if data.get("rewards"):
rewards = QuestReward.from_dict(data["rewards"])
# Parse offering triggers
offering_triggers = QuestOfferingTriggers()
if data.get("offering_triggers"):
offering_triggers = QuestOfferingTriggers.from_dict(data["offering_triggers"])
# Parse NPC offer dialogues
npc_offer_dialogues = {}
for npc_id, dialogue_data in data.get("npc_offer_dialogues", {}).items():
npc_offer_dialogues[npc_id] = NPCOfferDialogue.from_dict(dialogue_data)
# Parse lore context
lore_context = QuestLoreContext()
if data.get("lore_context"):
lore_context = QuestLoreContext.from_dict(data["lore_context"])
# Parse dialogue templates
dialogue_templates = QuestDialogueTemplates()
if data.get("dialogue_templates"):
dialogue_templates = QuestDialogueTemplates.from_dict(data["dialogue_templates"])
return cls(
quest_id=data["quest_id"],
name=data["name"],
description=data.get("description", ""),
difficulty=QuestDifficulty(data.get("difficulty", "easy")),
quest_giver_npc_ids=data.get("quest_giver_npc_ids", []),
quest_giver_name=data.get("quest_giver_name", ""),
location_id=data.get("location_id", ""),
region_id=data.get("region_id", ""),
objectives=objectives,
rewards=rewards,
offering_triggers=offering_triggers,
npc_offer_dialogues=npc_offer_dialogues,
npc_quest_knowledge=data.get("npc_quest_knowledge", {}),
lore_context=lore_context,
dialogue_templates=dialogue_templates,
completion_dialogue=data.get("completion_dialogue", {}),
tags=data.get("tags", []),
)
def __repr__(self) -> str:
"""String representation of the quest."""
return f"Quest({self.quest_id}, {self.name}, {self.difficulty.value})"
@dataclass
class CharacterQuestState:
"""
Tracks a character's progress on an active quest.
Stored in character's quest tracking data to persist progress
across sessions.
Attributes:
quest_id: The quest being tracked
status: Current quest status
accepted_at: ISO timestamp when quest was accepted
objectives_progress: Progress on each objective {objective_id: current_progress}
completed_at: ISO timestamp when quest was completed (if completed)
"""
quest_id: str
status: QuestStatus = QuestStatus.ACTIVE
accepted_at: str = ""
objectives_progress: Dict[str, int] = field(default_factory=dict)
completed_at: Optional[str] = None
def update_progress(self, objective_id: str, amount: int = 1) -> None:
"""
Update progress on an objective.
Args:
objective_id: The objective to update
amount: Amount to add to progress
"""
current = self.objectives_progress.get(objective_id, 0)
self.objectives_progress[objective_id] = current + amount
def get_progress(self, objective_id: str) -> int:
"""
Get current progress on an objective.
Args:
objective_id: The objective to check
Returns:
Current progress amount
"""
return self.objectives_progress.get(objective_id, 0)
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary."""
return {
"quest_id": self.quest_id,
"status": self.status.value,
"accepted_at": self.accepted_at,
"objectives_progress": self.objectives_progress,
"completed_at": self.completed_at,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CharacterQuestState':
"""Deserialize from dictionary."""
return cls(
quest_id=data["quest_id"],
status=QuestStatus(data.get("status", "active")),
accepted_at=data.get("accepted_at", ""),
objectives_progress=data.get("objectives_progress", {}),
completed_at=data.get("completed_at"),
)
def __repr__(self) -> str:
"""String representation."""
return f"CharacterQuestState({self.quest_id}, {self.status.value})"