implementation patterns

Guide for implementation patterns

Implementation Patterns

Core Functions

start_quest

Initializes a new quest in the player's active quest list.
pub fn start_quest(
    quest_id: &str,
    active_quests: &mut ActiveQuests,
    quest_db: &QuestDatabase,
) -> Result<(), String> {
    // 1. Check if already active
    if active_quests.active.iter().any(|q| q.quest_id == quest_id) {
        return Err("Quest already active".to_string());
    }
    
    // 2. Verify quest exists
    let quest = quest_db.quests.get(quest_id)
        .ok_or("Quest not found in database")?;
    
    // 3. Find initial step (step "0")
    if !quest.steps.contains_key("0") {
        return Err("Quest has no initial step".to_string());
    }
    
    // 4. Add to active quests
    active_quests.active.push(QuestProgress {
        quest_id: quest_id.to_string(),
        current_step: "0".to_string(),
    });
    
    Ok(())
}
Usage:
start_quest("the_lost_scroll", &mut active_quests, &quest_db)?;

advance_quest_step

Moves quest to the next step in its progression.
pub fn advance_quest_step(
    quest_id: &str,
    active_quests: &mut ActiveQuests,
    quest_db: &QuestDatabase,
) -> Result<bool, String> {
    // 1. Find active quest
    let progress = active_quests.active.iter_mut()
        .find(|p| p.quest_id == quest_id)
        .ok_or("Quest not active")?;
    
    // 2. Get quest definition
    let quest = quest_db.quests.get(quest_id)
        .ok_or("Quest not found")?;
    
    // 3. Get current step
    let current_step = quest.steps.get(&progress.current_step)
        .ok_or("Current step not found")?;
    
    // 4. Check if quest is finished
    if current_step.finish.unwrap_or(false) {
        return Ok(true);
    }
    
    // 5. Get next step
    let next_step_id = match current_step.next_step {
        Some(id) => id.to_string(),
        None => return Ok(true), // No next step = finished
    };
    
    // 6. Verify next step exists
    if !quest.steps.contains_key(&next_step_id) {
        return Err(format!("Next step {} not found", next_step_id));
    }
    
    // 7. Update progress
    progress.current_step = next_step_id;
    
    Ok(false) // Not finished yet
}
Usage:
let finished = advance_quest_step("the_lost_scroll", &mut active_quests, &quest_db)?;
if finished {
    complete_quest("the_lost_scroll", player_entity, &mut active_quests, &quest_db)?;
}

complete_quest

Finalizes quest, grants rewards, moves to completed list.
pub fn complete_quest(
    quest_id: &str,
    player: Entity,
    active_quests: &mut ActiveQuests,
    quest_db: &QuestDatabase,
) -> Result<QuestRewards, String> {
    // 1. Remove from active
    let idx = active_quests.active.iter()
        .position(|p| p.quest_id == quest_id)
        .ok_or("Quest not active")?;
    active_quests.active.remove(idx);
    
    // 2. Add to completed
    if !active_quests.completed.contains(&quest_id.to_string()) {
        active_quests.completed.push(quest_id.to_string());
    }
    
    // 3. Get rewards
    let quest = quest_db.quests.get(quest_id)
        .ok_or("Quest not found")?;
    
    let rewards = quest.rewards.clone()
        .unwrap_or_default();
    
    // 4. Grant rewards (caller's responsibility to send events)
    Ok(rewards)
}
Usage:
let rewards = complete_quest("the_lost_scroll", player_entity, &mut active_quests, &quest_db)?;

// Grant XP
if let Some(xp) = rewards.xp {
    xp_writer.send(XpGainEvent {
        entity: player_entity,
        skill: SkillType::Quest, // Or specific skill
        amount: xp as f32,
    });
}

// Grant items
for item_reward in rewards.items.unwrap_or_default() {
    add_item_writer.send(AddItemEvent {
        entity: player_entity,
        item: ItemId(string_to_id(&item_reward.item_id)),
        quantity: item_reward.quantity,
    });
}

Quest Listener Patterns

Dialogue Listener

Tracks NPC interactions for dialogue steps.
pub fn track_quest_dialogue(
    mut npc_events: EventReader<NPCInteractionEvent>,
    mut active_quests: ResMut<ActiveQuests>,
    quest_db: Res<QuestDatabase>,
    mut step_events: EventWriter<StepCompletedEvent>,
    mut quest_complete_events: EventWriter<QuestCompletedEvent>,
) {
    for event in npc_events.read() {
        // Clone to avoid borrow issues
        let active_quest_ids: Vec<String> = active_quests.active.iter()
            .map(|p| p.quest_id.clone())
            .collect();
        
        for quest_id in active_quest_ids {
            let Some(quest) = quest_db.quests.get(&quest_id) else { continue };
            
            // Get current progress
            let Some(progress) = active_quests.active.iter()
                .find(|p| p.quest_id == quest_id) else { continue };
            
            let Some(step) = quest.steps.get(&progress.current_step) else { continue };
            
            // Check if this step matches the interaction
            if step.step_type.as_deref() == Some("dialogue") 
                && step.target_npc.as_deref() == Some(&event.npc_id) {
                
                // Advance quest
                match advance_quest_step(&quest_id, &mut active_quests, &quest_db) {
                    Ok(finished) => {
                        step_events.send(StepCompletedEvent {
                            quest_id: quest_id.clone(),
                            step_id: progress.current_step.clone(),
                            player: event.player,
                        });
                        
                        if finished {
                            quest_complete_events.send(QuestCompletedEvent {
                                quest_id: quest_id.clone(),
                                player: event.player,
                            });
                        }
                    },
                    Err(e) => warn!("Failed to advance quest {}: {}", quest_id, e),
                }
                
                break; // Only advance one quest per interaction
            }
        }
    }
}

Kill Listener

Tracks creature defeats for kill steps.
pub fn track_quest_kills(
    mut kill_events: EventReader<EnemyDefeatedEvent>,
    mut active_quests: ResMut<ActiveQuests>,
    quest_db: Res<QuestDatabase>,
    creatures: Query<&Creature>,
    mut step_events: EventWriter<StepCompletedEvent>,
) {
    for event in kill_events.read() {
        // Get creature type
        let Ok(creature) = creatures.get(event.enemy) else { continue };
        
        for progress in &mut 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 kill step for this creature
            if step.step_type.as_deref() == Some("kill")
                && step.target_creature.as_deref() == Some(&creature.creature_id) {
                
                // Note: Kill count tracking would need additional state
                // For now, simplified to single kill
                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.killer,
                });
                
                break;
            }
        }
    }
}

UI Integration Patterns

Quest HUD

Displays 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| {
            ui.visuals_mut().window_fill = egui::Color32::from_black_alpha(200);
            
            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);
                    }
                }
            } else {
                ui.label("No active quest");
            }
        });
}

Quest Journal (Full UI)

pub fn render_quest_journal(
    ui: &mut egui::Ui,
    active_quests: &ActiveQuests,
    quest_db: &QuestDatabase,
) {
    ui.heading("Active Quests");
    ui.separator();
    
    for progress in &active_quests.active {
        if let Some(quest) = quest_db.quests.get(&progress.quest_id) {
            ui.group(|ui| {
                ui.heading(&quest.name);
                ui.label(&quest.description);
                ui.separator();
                
                // Show all steps with progress indicator
                for (step_id, step) in &quest.steps {
                    let is_current = step_id == &progress.current_step;
                    let icon = if is_current { "▶" } else { "○" };
                    
                    ui.horizontal(|ui| {
                        ui.label(icon);
                        ui.label(&step.description);
                    });
                }
            });
        }
    }
    
    ui.separator();
    ui.heading("Completed Quests");
    ui.separator();
    
    for quest_id in &active_quests.completed {
        if let Some(quest) = quest_db.quests.get(quest_id) {
            ui.label(format!("✓ {}", quest.name));
        }
    }
}

Testing Patterns

Setup Helper

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 Patterns

#[test]
fn test_quest_lifecycle() {
    let db = setup_test_db();
    let mut active = ActiveQuests::default();
    
    // Start
    assert!(start_quest("test_quest", &mut active, &db).is_ok());
    assert_eq!(active.active.len(), 1);
    assert_eq!(active.active[0].current_step, "0");
    
    // Advance
    let finished = advance_quest_step("test_quest", &mut active, &db).unwrap();
    assert!(!finished);
    assert_eq!(active.active[0].current_step, "10");
    
    // Complete
    let finished = advance_quest_step("test_quest", &mut active, &db).unwrap();
    assert!(finished);
    
    let entity = Entity::PLACEHOLDER;
    complete_quest("test_quest", entity, &mut active, &db).unwrap();
    assert_eq!(active.active.len(), 0);
    assert!(active.completed.contains(&"test_quest".to_string()));
}

Common Pitfalls

1. Forgetting to Clone quest_id

// ❌ Wrong - borrow conflict
for progress in &active_quests.active {
    advance_quest_step(&progress.quest_id, &mut active_quests, &quest_db);
}

// ✅ Correct - clone IDs first
let quest_ids: Vec<String> = active_quests.active.iter()
    .map(|p| p.quest_id.clone())
    .collect();
for quest_id in quest_ids {
    advance_quest_step(&quest_id, &mut active_quests, &quest_db);
}

2. Not Checking Step Existence

// ❌ Wrong - may panic
let step = quest.steps.get(&progress.current_step).unwrap();

// ✅ Correct - handle missing steps
let Some(step) = quest.steps.get(&progress.current_step) else {
    warn!("Step {} not found for quest {}", progress.current_step, quest_id);
    continue;
};

3. Forgetting Terminal Step Check

// ❌ Wrong - infinite loop if next_step points to self
progress.current_step = current_step.next_step.unwrap().to_string();

// ✅ Correct - check for finish conditions
if current_step.finish.unwrap_or(false) || current_step.next_step.is_none() {
    return Ok(true);
}