overview

Guide for overview

Quest System Step-Based Refactor

Overview

This document describes the complete refactoring of the Legends of Hastinapur quest system from an objective-based progression model to a step-based progression model. The refactor aligns the Rust code with the JSON data schema and introduces a more flexible, data-driven quest design.

Problem Statement

Original Issues

  1. Schema Mismatch: quest_system.rs used Vec<Quest> structure while quests.json used HashMap<String, QuestDefinition>
  2. Objective vs Steps: Code tracked objective_progress: Vec<u32> while JSON defined named steps with IDs
  3. Inflexible Progression: Numeric indices made it difficult to branch or skip quest steps
  4. Event Mismatch: ObjectiveCompletedEvent didn't align with step-based design

Solution Architecture

Data Schema (quests.json)

{
  "quest_id": {
    "id": "quest_id",
    "name": "Quest Name",
    "description": "Quest description",
    "category": "tutorial",
    "start_npc": "npc_id",
    "rewards": {
      "xp": 100,
      "items": [{"item_id": "item", "quantity": 1}]
    },
    "steps": {
      "0": {
        "description": "First step description",
        "type": "dialogue",
        "target_npc": "npc_id",
        "next_step": 10
      },
      "10": {
        "description": "Second step description",
        "type": "interaction",
        "target_object": "object_id",
        "next_step": null,
        "finish": true
      }
    }
  }
}
Key Features:
  • Steps identified by String IDs (e.g., "0", "10", "20")
  • Each step defines its successor via next_step
  • Step types: dialogue, interaction, kill
  • Terminal steps have next_step: null or finish: true

Code Structure

QuestDefinition (mod.rs)

pub struct QuestDefinition {
    pub id: String,
    pub name: String,
    pub description: String,
    pub category: Option<String>,
    pub difficulty: Option<String>,
    pub steps: HashMap<String, QuestStep>,
    pub rewards: Option<QuestRewards>,
}

pub struct QuestStep {
    pub description: String,
    pub step_type: Option<String>,
    pub target_npc: Option<String>,
    pub target_object: Option<String>,
    pub target_creature: Option<String>,
    pub required_count: Option<i32>,
    pub next_step: Option<i32>,
    pub finish: Option<bool>,
}

QuestProgress (quest_system.rs)

pub struct QuestProgress {
    pub quest_id: String,
    pub current_step: String,  // Changed from Vec<u32> objective_progress
}

pub struct ActiveQuests {
    pub active: Vec<QuestProgress>,
    pub completed: Vec<String>,
}

Key Changes

1. Quest Database Loading

Before:
// Expected Vec<Quest>
let quests: Vec<Quest> = serde_json::from_str(&contents)?;
After:
// Loads HashMap<String, QuestDefinition>
let quests: HashMap<String, QuestDefinition> = serde_json::from_str(&contents)?;
db.quests = quests;

2. Quest Progression

Before:
progress.objective_progress[idx] += 1;
After:
pub fn advance_quest_step(
    quest_id: &str,
    active_quests: &mut ActiveQuests,
    quest_db: &QuestDatabase,
) -> Result<bool, String> {
    // Find quest progress
    let progress = active_quests.active.iter_mut()
        .find(|p| p.quest_id == quest_id)
        .ok_or("Quest not active")?;
    
    // Get current step definition
    let quest = quest_db.quests.get(quest_id)
        .ok_or("Quest not found")?;
    let current_step = quest.steps.get(&progress.current_step)
        .ok_or("Step not found")?;
    
    // Check if finished
    if current_step.finish.unwrap_or(false) || current_step.next_step.is_none() {
        return Ok(true); // Quest complete
    }
    
    // Advance to next step
    let next_step_id = current_step.next_step.unwrap().to_string();
    progress.current_step = next_step_id;
    Ok(false)
}

3. Event System

Before:
pub struct ObjectiveCompletedEvent {
    pub quest_id: String,
    pub objective_index: usize,
    pub player: Entity,
}
After:
pub struct StepCompletedEvent {
    pub quest_id: String,
    pub step_id: String,
    pub player: Entity,
}

4. Quest Listeners Refactor

Dialogue Listener (quest_listeners.rs):
pub fn track_quest_dialogue(
    mut npc_events: EventReader<NPCInteractionEvent>,
    mut active_quests: ResMut<ActiveQuests>,
    quest_db: Res<QuestDatabase>,
    mut step_events: EventWriter<StepCompletedEvent>,
) {
    for event in npc_events.read() {
        for progress in &active_quests.active {
            let Some(quest) = quest_db.quests.get(&progress.quest_id) else { continue };
            let Some(step) = quest.steps.get(&progress.current_step) else { continue };
            
            // Check if this is a dialogue step targeting this NPC
            if step.step_type.as_deref() == Some("dialogue") 
                && step.target_npc.as_deref() == Some(&event.npc_id) {
                
                let finished = advance_quest_step(&progress.quest_id, &mut active_quests, &quest_db);
                
                step_events.send(StepCompletedEvent {
                    quest_id: progress.quest_id.clone(),
                    step_id: progress.current_step.clone(),
                    player: event.player,
                });
                break;
            }
        }
    }
}

Quest HUD Implementation

Created src/systems/ui/quest_hud.rs to display current quest objective:
pub fn render_quest_hud(
    mut contexts: EguiContexts,
    active_quests: Res<ActiveQuests>,
    quest_db: Res<QuestDatabase>,
) {
    let ctx = contexts.ctx_mut();
    
    egui::Area::new("quest_hud")
        .fixed_pos(egui::pos2(10.0, 10.0))
        .show(ctx, |ui| {
            if let Some(progress) = active_quests.active.first() {
                if let Some(quest) = quest_db.quests.get(&progress.quest_id) {
                    if let Some(step) = quest.steps.get(&progress.current_step) {
                        ui.heading(&quest.name);
                        ui.label(&step.description);
                    }
                }
            }
        });
}

Plugin API Integration

Updated src/plugin_api/bevy_plugin.rs to expose quest data to Lua plugins:
// Quest Progress
for progress in &quests.active {
    snapshot.active_quests.insert(
        progress.quest_id.clone(), 
        QuestProgressSnapshot {
            quest_id: progress.quest_id.clone(),
            stage_id: progress.current_step.parse::<i32>().unwrap_or(0),
            state: 1,
        }
    );
}

// Quest Definitions
for (id, quest) in &db.quests {
    let steps = quest.steps.iter()
        .map(|(step_id, s)| format!("[{}] {}: {}", 
            step_id, 
            s.step_type.as_deref().unwrap_or("?"), 
            s.description))
        .collect();
    
    snapshot.quest_definitions.insert(id.clone(), QuestDefinitionSnapshot {
        id: id.clone(),
        name: quest.name.clone(),
        description: quest.description.clone(),
        steps,
    });
}

Testing

Unit Tests (quest_system.rs)

#[cfg(test)]
mod tests {
    use super::*;
    
    fn setup_test_db() -> QuestDatabase {
        let mut db = QuestDatabase::default();
        let mut steps = HashMap::new();
        
        steps.insert("0".to_string(), QuestStep {
            description: "Start".to_string(),
            step_type: Some("dialogue".to_string()),
            target_npc: Some("npc_1".to_string()),
            next_step: Some(10),
        });
        
        steps.insert("10".to_string(), QuestStep {
            description: "Middle".to_string(),
            step_type: Some("interaction".to_string()),
            target_npc: Some("obj_1".to_string()),
            next_step: None,
        });
        
        db.quests.insert("test_quest".to_string(), QuestDefinition {
            id: "test_quest".to_string(),
            name: "Test Quest".to_string(),
            description: "A test quest".to_string(),
            category: None,
            difficulty: None,
            steps,
            rewards: None,
        });
        
        db
    }
    
    #[test]
    fn test_start_quest() {
        let db = setup_test_db();
        let mut active = ActiveQuests::default();
        
        let res = start_quest("test_quest", &mut active, &db);
        assert!(res.is_ok());
        assert_eq!(active.active.len(), 1);
        assert_eq!(active.active[0].current_step, "0");
    }
    
    #[test]
    fn test_advance_quest_step() {
        let db = setup_test_db();
        let mut active = ActiveQuests::default();
        start_quest("test_quest", &mut active, &db).unwrap();
        
        let finished = advance_quest_step("test_quest", &mut active, &db);
        assert_eq!(finished.unwrap(), false);
        assert_eq!(active.active[0].current_step, "10");
        
        let finished2 = advance_quest_step("test_quest", &mut active, &db);
        assert_eq!(finished2.unwrap(), true);
    }
}
Test Results: All tests pass ✓

Example Quest: "The Lost Scroll"

Added to data/quests.json and data/npcs.json:
{
  "the_lost_scroll": {
    "id": "the_lost_scroll",
    "name": "The Lost Scroll",
    "category": "tutorial",
    "description": "Scholar Vyasa seeks an ancient scroll lost in the palace library.",
    "start_npc": "npc_vyasa",
    "rewards": {
      "xp": 100,
      "items": [{"item_id": "ancient_scroll", "quantity": 1}]
    },
    "steps": {
      "0": {
        "description": "Speak to Scholar Vyasa at the Palace.",
        "type": "dialogue",
        "target_npc": "npc_vyasa",
        "next_step": 10
      },
      "10": {
        "description": "Search the Ancient Bookshelf for the scroll.",
        "type": "interaction",
        "target_object": "obj_bookshelf_ancient",
        "next_step": 20
      },
      "20": {
        "description": "Return the scroll to Scholar Vyasa.",
        "type": "dialogue",
        "target_npc": "npc_vyasa",
        "finish": true
      }
    }
  }
}

World Integration

Spawned quest entities in src/systems/world/city.rs:
// Scholar Vyasa (Near Palace)
commands.spawn((
    PbrBundle {
        mesh: meshes.add(Capsule3d::new(0.4, 1.8)),
        material: materials.add(StandardMaterial { 
            base_color: Color::srgb(0.0, 0.0, 1.0), 
            ..default() 
        }),
        transform: Transform::from_translation(Vec3::new(-10.0, 1.0, -25.0)),
        ..default()
    },
    Name::new("Scholar Vyasa"),
    crate::systems::world::npc_placement::NPC {
        npc_id: "npc_vyasa".to_string(),
        dialogue_id: "dialogue_vyasa_intro".to_string(),
    },
    crate::systems::quests::quest_listeners::QuestTarget {
        target_name: "npc_vyasa".to_string(),
    },
    // ... collider, etc.
));

// Ancient Bookshelf
commands.spawn((
    // Similar setup with target_name: "obj_bookshelf_ancient"
));

Migration Guide

For Existing Quests

  1. Convert objective arrays to step maps
  2. Assign String IDs to steps (e.g., "0", "10", "20")
  3. Define next_step links
  4. Mark terminal steps with finish: true

For New Quests

  1. Define quest in quests.json with step map
  2. Create NPCs/objects in npcs.json or world spawning
  3. Add QuestTarget component to interactable entities
  4. Quest listeners automatically track progress

Benefits

  1. Flexibility: Steps can branch, skip, or loop
  2. Clarity: Named steps are self-documenting
  3. Maintainability: JSON schema matches code structure
  4. Extensibility: Easy to add new step types
  5. Testability: Unit tests verify core logic

Known Limitations

  1. step_id in StepCompletedEvent currently set to "unknown" in some listeners (needs refinement)
  2. target_object field not explicitly used in all listeners (currently reusing target_npc)
  3. No support for parallel quest objectives (single current_step)

Future Enhancements

  • Conditional step progression (requirements)
  • Parallel objective tracking
  • Quest branching based on player choices
  • Step rewards (not just quest completion rewards)
  • Quest state persistence (save/load)