31 KiB
Quest System
Status: Planned Phase: 4 (AI Integration + Story Progression) Timeline: Week 9 of Phase 4 (Days 13-14) Last Updated: November 16, 2025
Overview
The Quest System provides structured objectives and rewards for players during their solo story progression sessions. Quests are defined in YAML files and offered to players by the AI Dungeon Master based on context-aware triggers and location-based probability.
Key Principles:
- YAML-driven design - Quests defined in data files, no code changes needed
- Context-aware offering - AI analyzes narrative context to offer relevant quests
- Location-based triggers - Different areas have different quest probabilities
- Max 2 active quests - Prevents player overwhelm
- Random but meaningful - Quest offering feels natural, not forced
- Rewarding progression - Quests provide gold, XP, and items
Quest Structure
Quest Components
A quest consists of:
- Metadata - Name, description, difficulty, quest giver
- Objectives - Specific goals to complete (ordered or unordered)
- Rewards - Gold, XP, items awarded upon completion
- Offering Triggers - Context and location requirements
- Narrative Hooks - Story fragments for AI to use in offering
Data Models
Quest Dataclass
@dataclass
class Quest:
"""Represents a quest with objectives and rewards."""
quest_id: str # Unique identifier (e.g., "quest_rats_tavern")
name: str # Display name (e.g., "Rat Problem")
description: str # Full quest description
quest_giver: str # NPC or source (e.g., "Tavern Keeper")
difficulty: str # "easy", "medium", "hard", "epic"
objectives: List[QuestObjective] # List of objectives to complete
rewards: QuestReward # Rewards for completion
offering_triggers: QuestTriggers # When/where quest can be offered
narrative_hooks: List[str] # Story snippets for AI to use
status: str = "available" # "available", "active", "completed", "failed"
progress: Dict[str, Any] = field(default_factory=dict) # Objective progress tracking
def is_complete(self) -> bool:
"""Check if all objectives are completed."""
return all(obj.completed for obj in self.objectives)
def get_next_objective(self) -> Optional[QuestObjective]:
"""Get the next incomplete objective."""
for obj in self.objectives:
if not obj.completed:
return obj
return None
def update_progress(self, objective_id: str, progress_value: int) -> None:
"""Update progress for a specific objective."""
for obj in self.objectives:
if obj.objective_id == objective_id:
obj.current_progress = min(progress_value, obj.required_progress)
if obj.current_progress >= obj.required_progress:
obj.completed = True
break
def to_dict(self) -> Dict[str, Any]:
"""Serialize to dictionary for JSON storage."""
return {
"quest_id": self.quest_id,
"name": self.name,
"description": self.description,
"quest_giver": self.quest_giver,
"difficulty": self.difficulty,
"objectives": [obj.to_dict() for obj in self.objectives],
"rewards": self.rewards.to_dict(),
"offering_triggers": self.offering_triggers.to_dict(),
"narrative_hooks": self.narrative_hooks,
"status": self.status,
"progress": self.progress
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Quest':
"""Deserialize from dictionary."""
return cls(
quest_id=data["quest_id"],
name=data["name"],
description=data["description"],
quest_giver=data["quest_giver"],
difficulty=data["difficulty"],
objectives=[QuestObjective.from_dict(obj) for obj in data["objectives"]],
rewards=QuestReward.from_dict(data["rewards"]),
offering_triggers=QuestTriggers.from_dict(data["offering_triggers"]),
narrative_hooks=data["narrative_hooks"],
status=data.get("status", "available"),
progress=data.get("progress", {})
)
QuestObjective Dataclass
@dataclass
class QuestObjective:
"""Represents a single objective within a quest."""
objective_id: str # Unique ID (e.g., "kill_rats")
description: str # Player-facing description
objective_type: str # "kill", "collect", "travel", "interact", "discover"
required_progress: int # Target value (e.g., 10 rats)
current_progress: int = 0 # Current value (e.g., 5 rats killed)
completed: bool = False # Objective completion status
def to_dict(self) -> Dict[str, Any]:
return {
"objective_id": self.objective_id,
"description": self.description,
"objective_type": self.objective_type,
"required_progress": self.required_progress,
"current_progress": self.current_progress,
"completed": self.completed
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestObjective':
return cls(
objective_id=data["objective_id"],
description=data["description"],
objective_type=data["objective_type"],
required_progress=data["required_progress"],
current_progress=data.get("current_progress", 0),
completed=data.get("completed", False)
)
QuestReward Dataclass
@dataclass
class QuestReward:
"""Rewards granted upon quest completion."""
gold: int = 0 # Gold reward
experience: int = 0 # XP reward
items: List[str] = field(default_factory=list) # Item IDs to grant
reputation: Optional[str] = None # Reputation faction (future feature)
def to_dict(self) -> Dict[str, Any]:
return {
"gold": self.gold,
"experience": self.experience,
"items": self.items,
"reputation": self.reputation
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'QuestReward':
return cls(
gold=data.get("gold", 0),
experience=data.get("experience", 0),
items=data.get("items", []),
reputation=data.get("reputation")
)
QuestTriggers Dataclass
@dataclass
class QuestTriggers:
"""Defines when and where a quest can be offered."""
location_types: List[str] # ["town", "wilderness", "dungeon"] or ["any"]
specific_locations: List[str] # Specific location IDs or empty for any
min_character_level: int = 1 # Minimum level required
max_character_level: int = 100 # Maximum level (for scaling)
required_quests_completed: List[str] = field(default_factory=list) # Quest prerequisites
probability_weights: Dict[str, float] = field(default_factory=dict) # Location-specific chances
def get_offer_probability(self, location_type: str) -> float:
"""Get the probability of offering this quest at location type."""
return self.probability_weights.get(location_type, 0.0)
def can_offer(self, character_level: int, location: str, location_type: str, completed_quests: List[str]) -> bool:
"""Check if quest can be offered to this character at this location."""
# Check level requirements
if character_level < self.min_character_level or character_level > self.max_character_level:
return False
# Check quest prerequisites
for required_quest in self.required_quests_completed:
if required_quest not in completed_quests:
return False
# Check location type
if "any" not in self.location_types and location_type not in self.location_types:
return False
# Check specific location (if specified)
if self.specific_locations and location not in self.specific_locations:
return False
return True
def to_dict(self) -> Dict[str, Any]:
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]) -> 'QuestTriggers':
return cls(
location_types=data["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", {})
)
YAML Quest Definitions
Quests are stored in /app/data/quests/ organized by difficulty:
/app/data/quests/
├── easy/
│ ├── rat_problem.yaml
│ ├── delivery_run.yaml
│ └── missing_cat.yaml
├── medium/
│ ├── bandit_camp.yaml
│ ├── haunted_ruins.yaml
│ └── merchant_escort.yaml
├── hard/
│ ├── dragon_lair.yaml
│ ├── necromancer_tower.yaml
│ └── lost_artifact.yaml
└── epic/
├── demon_invasion.yaml
└── ancient_prophecy.yaml
Example Quest YAML: Rat Problem (Easy)
File: /app/data/quests/easy/rat_problem.yaml
quest_id: "quest_rats_tavern"
name: "Rat Problem"
description: "The local tavern is overrun with giant rats. The tavern keeper needs someone to clear them out before they scare away all the customers."
quest_giver: "Tavern Keeper"
difficulty: "easy"
objectives:
- objective_id: "kill_rats"
description: "Kill 10 giant rats in the tavern basement"
objective_type: "kill"
required_progress: 10
rewards:
gold: 50
experience: 100
items: []
offering_triggers:
location_types: ["town"]
specific_locations: [] # Any town
min_character_level: 1
max_character_level: 3
required_quests_completed: []
probability_weights:
town: 0.30 # 30% chance in towns
wilderness: 0.0 # 0% chance in wilderness
dungeon: 0.0 # 0% chance in dungeons
narrative_hooks:
- "The tavern keeper frantically waves you over, mentioning strange noises from the basement."
- "You overhear patrons complaining about rat infestations ruining the food supplies."
- "A desperate-looking innkeeper approaches you, begging for help with a pest problem."
Example Quest YAML: Bandit Camp (Medium)
File: /app/data/quests/medium/bandit_camp.yaml
quest_id: "quest_bandit_camp"
name: "Clear the Bandit Camp"
description: "A group of bandits has been raiding merchant caravans along the main road. The town guard wants someone to clear out their camp in the nearby woods."
quest_giver: "Captain of the Guard"
difficulty: "medium"
objectives:
- objective_id: "find_camp"
description: "Locate the bandit camp in the forest"
objective_type: "discover"
required_progress: 1
- objective_id: "defeat_bandits"
description: "Defeat the bandit leader and their gang"
objective_type: "kill"
required_progress: 1
- objective_id: "return_goods"
description: "Return stolen goods to the town"
objective_type: "interact"
required_progress: 1
rewards:
gold: 200
experience: 500
items: ["iron_sword", "leather_armor"]
offering_triggers:
location_types: ["town"]
specific_locations: []
min_character_level: 3
max_character_level: 7
required_quests_completed: []
probability_weights:
town: 0.20
wilderness: 0.05
dungeon: 0.0
narrative_hooks:
- "The captain of the guard summons you, speaking urgently about increased bandit activity."
- "A merchant tells you tales of lost caravans and a hidden camp somewhere in the eastern woods."
- "Wanted posters line the town walls, offering rewards for dealing with the bandit menace."
Example Quest YAML: Dragon's Lair (Hard)
File: /app/data/quests/hard/dragon_lair.yaml
quest_id: "quest_dragon_lair"
name: "The Dragon's Lair"
description: "An ancient red dragon has awakened in the mountains and is terrorizing the region. The kingdom offers a substantial reward for anyone brave enough to slay the beast."
quest_giver: "Royal Herald"
difficulty: "hard"
objectives:
- objective_id: "gather_info"
description: "Gather information about the dragon's lair"
objective_type: "interact"
required_progress: 3
- objective_id: "find_lair"
description: "Locate the dragon's lair in the mountains"
objective_type: "discover"
required_progress: 1
- objective_id: "slay_dragon"
description: "Defeat the ancient red dragon"
objective_type: "kill"
required_progress: 1
- objective_id: "claim_hoard"
description: "Claim a portion of the dragon's hoard"
objective_type: "collect"
required_progress: 1
rewards:
gold: 5000
experience: 10000
items: ["dragon_scale_armor", "flaming_longsword", "ring_of_fire_resistance"]
offering_triggers:
location_types: ["town"]
specific_locations: ["capital_city", "mountain_fortress"]
min_character_level: 10
max_character_level: 100
required_quests_completed: []
probability_weights:
town: 0.10
wilderness: 0.02
dungeon: 0.0
narrative_hooks:
- "A royal herald announces a call to arms - a dragon threatens the kingdom!"
- "You hear tales of entire villages razed by dragonfire in the northern mountains."
- "The king's messenger seeks experienced adventurers for a quest of utmost danger."
Quest Offering Logic
When Quests Are Offered
Quests are checked for offering after each story turn action, using a two-stage process:
Stage 1: Location-Based Probability Roll
def roll_for_quest_offering(location_type: str) -> bool:
"""Roll to see if any quest should be offered this turn."""
base_probabilities = {
"town": 0.30, # 30% chance in towns/cities
"tavern": 0.35, # 35% chance in taverns (special location)
"wilderness": 0.05, # 5% chance in wilderness
"dungeon": 0.10, # 10% chance in dungeons
}
chance = base_probabilities.get(location_type, 0.05)
return random.random() < chance
Stage 2: Context-Aware Selection
If the roll succeeds, the AI Dungeon Master analyzes the recent narrative context to select an appropriate quest:
def select_quest_from_context(
available_quests: List[Quest],
character: Character,
session: GameSession,
location: str,
location_type: str
) -> Optional[Quest]:
"""Use AI to select a contextually appropriate quest."""
# Filter quests by eligibility
eligible_quests = [
q for q in available_quests
if q.offering_triggers.can_offer(
character.level,
location,
location_type,
character.completed_quests
)
]
if not eligible_quests:
return None
# Build context for AI decision
context = {
"character_name": character.name,
"character_level": character.level,
"location": location,
"recent_actions": session.conversation_history[-3:],
"available_quests": [
{
"quest_id": q.quest_id,
"name": q.name,
"narrative_hooks": q.narrative_hooks,
"difficulty": q.difficulty
}
for q in eligible_quests
]
}
# Ask AI to select most fitting quest (or none)
prompt = render_quest_selection_prompt(context)
ai_response = call_ai_api(prompt)
selected_quest_id = parse_quest_selection(ai_response)
if selected_quest_id:
return next(q for q in eligible_quests if q.quest_id == selected_quest_id)
return None
Quest Offering Flow Diagram
After Story Turn Action
│
▼
┌─────────────────────────┐
│ Check Active Quests │
│ If >= 2, skip offering │
└────────┬────────────────┘
│
▼
┌─────────────────────────┐
│ Location-Based Roll │
│ - Town: 30% │
│ - Tavern: 35% │
│ - Wilderness: 5% │
│ - Dungeon: 10% │
└────────┬────────────────┘
│
▼
Roll Success?
│
┌────┴────┐
No Yes
│ │
│ ▼
│ ┌─────────────────────────┐
│ │ Filter Eligible Quests │
│ │ - Level requirements │
│ │ - Location match │
│ │ - Prerequisites met │
│ └────────┬────────────────┘
│ │
│ ▼
│ Any Eligible?
│ │
│ ┌────┴────┐
│ No Yes
│ │ │
│ │ ▼
│ │ ┌─────────────────────────┐
│ │ │ AI Context Analysis │
│ │ │ Select fitting quest │
│ │ │ from narrative context │
│ │ └────────┬────────────────┘
│ │ │
│ │ ▼
│ │ Quest Selected?
│ │ │
│ │ ┌────┴────┐
│ │ No Yes
│ │ │ │
│ │ │ ▼
│ │ │ ┌─────────────────────────┐
│ │ │ │ Offer Quest to Player │
│ │ │ │ Display narrative hook │
│ │ │ └─────────────────────────┘
│ │ │
└────────┴────────┴──► No Quest Offered
Quest Tracking and Completion
Accepting a Quest
Endpoint: POST /api/v1/quests/accept
Request:
{
"character_id": "char_abc123",
"quest_id": "quest_rats_tavern"
}
Response:
{
"success": true,
"quest": {
"quest_id": "quest_rats_tavern",
"name": "Rat Problem",
"description": "The local tavern is overrun with giant rats...",
"objectives": [
{
"objective_id": "kill_rats",
"description": "Kill 10 giant rats in the tavern basement",
"current_progress": 0,
"required_progress": 10,
"completed": false
}
],
"status": "active"
}
}
Updating Quest Progress
Quest progress is updated automatically during combat or story actions:
def update_quest_progress(character: Character, event_type: str, event_data: Dict) -> List[str]:
"""Update quest progress based on game events."""
updated_quests = []
for quest_id in character.active_quests:
quest = load_quest(quest_id)
for objective in quest.objectives:
if objective.completed:
continue
# Match event to objective type
if objective.objective_type == "kill" and event_type == "enemy_killed":
if matches_objective(objective, event_data):
quest.update_progress(objective.objective_id, objective.current_progress + 1)
updated_quests.append(quest_id)
elif objective.objective_type == "collect" and event_type == "item_obtained":
if matches_objective(objective, event_data):
quest.update_progress(objective.objective_id, objective.current_progress + 1)
updated_quests.append(quest_id)
elif objective.objective_type == "discover" and event_type == "location_discovered":
if matches_objective(objective, event_data):
quest.update_progress(objective.objective_id, 1)
updated_quests.append(quest_id)
elif objective.objective_type == "interact" and event_type == "npc_interaction":
if matches_objective(objective, event_data):
quest.update_progress(objective.objective_id, objective.current_progress + 1)
updated_quests.append(quest_id)
# Check if quest is complete
if quest.is_complete():
complete_quest(character, quest)
return updated_quests
Completing a Quest
Endpoint: POST /api/v1/quests/complete
Request:
{
"character_id": "char_abc123",
"quest_id": "quest_rats_tavern"
}
Response:
{
"success": true,
"quest_completed": true,
"rewards": {
"gold": 50,
"experience": 100,
"items": [],
"level_up": false
},
"message": "You have completed the Rat Problem quest! The tavern keeper thanks you profusely."
}
Quest Service
QuestService Class
class QuestService:
"""Service for managing quests."""
def __init__(self):
self.quest_cache: Dict[str, Quest] = {}
self._load_all_quests()
def _load_all_quests(self) -> None:
"""Load all quests from YAML files."""
quest_dirs = [
"app/data/quests/easy/",
"app/data/quests/medium/",
"app/data/quests/hard/",
"app/data/quests/epic/"
]
for quest_dir in quest_dirs:
for yaml_file in glob.glob(f"{quest_dir}*.yaml"):
quest = self._load_quest_from_yaml(yaml_file)
self.quest_cache[quest.quest_id] = quest
def _load_quest_from_yaml(self, filepath: str) -> Quest:
"""Load a single quest from YAML file."""
with open(filepath, 'r') as f:
data = yaml.safe_load(f)
return Quest.from_dict(data)
def get_quest(self, quest_id: str) -> Optional[Quest]:
"""Get quest by ID."""
return self.quest_cache.get(quest_id)
def get_available_quests(self, character: Character, location: str, location_type: str) -> List[Quest]:
"""Get all quests available to character at location."""
return [
quest for quest in self.quest_cache.values()
if quest.offering_triggers.can_offer(
character.level,
location,
location_type,
character.completed_quests
)
]
def accept_quest(self, character_id: str, quest_id: str) -> bool:
"""Accept a quest for a character."""
character = get_character(character_id)
# Check quest limit
if len(character.active_quests) >= 2:
raise QuestLimitExceeded("You can only have 2 active quests at a time")
# Add quest to active quests
if quest_id not in character.active_quests:
character.active_quests.append(quest_id)
update_character(character)
return True
return False
def complete_quest(self, character_id: str, quest_id: str) -> QuestReward:
"""Complete a quest and grant rewards."""
character = get_character(character_id)
quest = self.get_quest(quest_id)
if not quest or not quest.is_complete():
raise QuestNotComplete("Quest objectives not complete")
# Grant rewards
character.gold += quest.rewards.gold
character.experience += quest.rewards.experience
for item_id in quest.rewards.items:
item = load_item(item_id)
character.inventory.append(item)
# Move quest to completed
character.active_quests.remove(quest_id)
character.completed_quests.append(quest_id)
# Check for level up
leveled_up = check_level_up(character)
update_character(character)
return quest.rewards
UI Integration
Quest Tracker (Sidebar)
┌─────────────────────────┐
│ 🎯 Active Quests (1/2) │
├─────────────────────────┤
│ │
│ Rat Problem │
│ ─────────────── │
│ ✅ Kill 10 giant rats │
│ (7/10) │
│ │
│ [View Details] │
│ │
└─────────────────────────┘
Quest Offering Modal
When a quest is offered during a story turn:
┌─────────────────────────────────────────┐
│ Quest Offered! │
├─────────────────────────────────────────┤
│ │
│ 🎯 Rat Problem │
│ │
│ The tavern keeper frantically waves │
│ you over, mentioning strange noises │
│ from the basement. │
│ │
│ "Please, you must help! Giant rats │
│ have overrun my cellar. I'll pay you │
│ well to clear them out!" │
│ │
│ Difficulty: Easy │
│ Rewards: 50 gold, 100 XP │
│ │
│ Objectives: │
│ • Kill 10 giant rats │
│ │
│ [Accept Quest] [Decline] │
│ │
└─────────────────────────────────────────┘
Quest Detail View
┌─────────────────────────────────────────┐
│ Rat Problem [Close] │
├─────────────────────────────────────────┤
│ │
│ Quest Giver: Tavern Keeper │
│ Difficulty: Easy │
│ │
│ Description: │
│ The local tavern is overrun with giant │
│ rats. The tavern keeper needs someone │
│ to clear them out before they scare │
│ away all the customers. │
│ │
│ Objectives: │
│ ✅ Kill 10 giant rats (7/10) │
│ │
│ Rewards: │
│ 💰 50 gold │
│ ⭐ 100 XP │
│ │
│ [Abandon Quest] │
│ │
└─────────────────────────────────────────┘
Implementation Timeline
Week 9 of Phase 4 (Days 13-14)
Day 13: Quest Data Models & Service
- Create Quest, QuestObjective, QuestReward, QuestTriggers dataclasses
- Create QuestService (load from YAML, manage state)
- Write 5-10 example quest YAML files (2 easy, 2 medium, 1 hard)
- Unit tests for quest loading and progression logic
Day 14: Quest Offering & Integration
- Implement quest offering logic (context-aware + location-based)
- Integrate quest offering into story turn flow
- Quest acceptance/completion API endpoints
- Quest progress tracking during combat/story events
- Quest UI components (tracker, offering modal, detail view)
- Integration testing
Testing Criteria
Unit Tests
- ✅ Quest loading from YAML
- ✅ Quest.is_complete() logic
- ✅ Quest.update_progress() logic
- ✅ QuestTriggers.can_offer() filtering
- ✅ QuestService.get_available_quests() filtering
Integration Tests
- ✅ Quest offered during story turn
- ✅ Accept quest (add to active_quests)
- ✅ Quest limit enforced (max 2 active)
- ✅ Quest progress updates during combat
- ✅ Complete quest and receive rewards
- ✅ Level up from quest XP
- ✅ Abandon quest
Manual Testing
- ✅ Full quest flow (offer → accept → progress → complete)
- ✅ Multiple quests active simultaneously
- ✅ Quest offering feels natural in narrative
- ✅ Context-aware quest selection works
- ✅ Location-based probabilities feel right
Success Criteria
- ✅ Quest data models implemented and tested
- ✅ QuestService loads quests from YAML files
- ✅ Quest offering logic integrated into story turns
- ✅ Context-aware quest selection working
- ✅ Location-based probability rolls functioning
- ✅ Max 2 active quests enforced
- ✅ Quest acceptance and tracking functional
- ✅ Quest progress updates automatically
- ✅ Quest completion and rewards granted
- ✅ Quest UI components implemented
- ✅ At least 5 example quests defined in YAML
Future Enhancements (Post-MVP)
Phase 13+
- Quest chains: Multi-quest storylines with prerequisites
- Repeatable quests: Daily/weekly quests for ongoing rewards
- Dynamic quest generation: AI creates custom quests on-the-fly
- Quest sharing: Multiplayer party quests
- Quest journal: Full quest log with completed quest history
- Quest dialogue trees: Branching conversations with quest givers
- Time-limited quests: Quests that expire after X turns
- Reputation system: Quest completion affects faction standing
- Quest difficulty scaling: Rewards scale with character level
Related Documentation
- STORY_PROGRESSION.md - Turn-based story gameplay system
- DATA_MODELS.md - Quest data model specifications
- GAME_SYSTEMS.md - Combat integration with quests
- API_REFERENCE.md - Quest API endpoints
- ROADMAP.md - Phase 4 timeline
Document Version: 1.0 Created: November 16, 2025 Last Updated: November 16, 2025