Files
Code_of_Conquest/api/docs/QUEST_SYSTEM.md
2025-11-24 23:10:55 -06:00

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:

  1. Metadata - Name, description, difficulty, quest giver
  2. Objectives - Specific goals to complete (ordered or unordered)
  3. Rewards - Gold, XP, items awarded upon completion
  4. Offering Triggers - Context and location requirements
  5. 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


Document Version: 1.0 Created: November 16, 2025 Last Updated: November 16, 2025