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
- Schema Mismatch:
quest_system.rsusedVec<Quest>structure whilequests.jsonusedHashMap<String, QuestDefinition> - Objective vs Steps: Code tracked
objective_progress: Vec<u32>while JSON defined named steps with IDs - Inflexible Progression: Numeric indices made it difficult to branch or skip quest steps
- Event Mismatch:
ObjectiveCompletedEventdidn'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: nullorfinish: 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
- Convert objective arrays to step maps
- Assign String IDs to steps (e.g., "0", "10", "20")
- Define
next_steplinks - Mark terminal steps with
finish: true
For New Quests
- Define quest in
quests.jsonwith step map - Create NPCs/objects in
npcs.jsonor world spawning - Add
QuestTargetcomponent to interactable entities - Quest listeners automatically track progress
Benefits
- Flexibility: Steps can branch, skip, or loop
- Clarity: Named steps are self-documenting
- Maintainability: JSON schema matches code structure
- Extensibility: Easy to add new step types
- Testability: Unit tests verify core logic
Known Limitations
step_idinStepCompletedEventcurrently set to "unknown" in some listeners (needs refinement)target_objectfield not explicitly used in all listeners (currently reusingtarget_npc)- 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)