use std::collections::{HashMap, HashSet, VecDeque};
use super::{
ObjectiveDef, ObjectiveId, ObjectiveState, ObjectiveType, QuestCategory,
QuestDatabase, QuestDef, QuestId, QuestPriority, PrerequisiteView,
QuestState, Reward,
};
#[derive(Debug, Clone)]
pub struct JournalNote {
pub timestamp: f32,
pub text: String,
pub auto_generated: bool,
}
impl JournalNote {
pub fn new(timestamp: f32, text: impl Into<String>) -> Self {
Self { timestamp, text: text.into(), auto_generated: false }
}
pub fn auto(timestamp: f32, text: impl Into<String>) -> Self {
Self { timestamp, text: text.into(), auto_generated: true }
}
}
#[derive(Debug, Clone)]
pub struct ObjectiveProgress {
pub def_id: ObjectiveId,
pub current: u32,
pub state: ObjectiveState,
pub completed_at: Option<f32>,
}
impl ObjectiveProgress {
fn new(def_id: ObjectiveId) -> Self {
Self { def_id, current: 0, state: ObjectiveState::Active, completed_at: None }
}
pub fn fraction(&self, target: u32) -> f32 {
if target == 0 { return 1.0; }
(self.current as f32 / target as f32).min(1.0)
}
pub fn is_done(&self) -> bool { self.state.is_done() }
}
#[derive(Debug, Clone)]
pub struct QuestProgress {
pub def_id: QuestId,
pub state: QuestState,
pub objectives: HashMap<ObjectiveId, ObjectiveProgress>,
pub started_at: Option<f32>,
pub completed_at: Option<f32>,
pub time_elapsed: f32,
pub attempt_count: u32,
pub notes: Vec<JournalNote>,
}
impl QuestProgress {
fn new(def: &QuestDef, time: f32) -> Self {
let mut objectives = HashMap::new();
for obj in &def.objectives {
objectives.insert(obj.id, ObjectiveProgress::new(obj.id));
}
Self {
def_id: def.id,
state: QuestState::Active,
objectives,
started_at: Some(time),
completed_at: None,
time_elapsed: 0.0,
attempt_count: 1,
notes: Vec::new(),
}
}
pub fn required_completed(&self, def: &QuestDef) -> usize {
def.objectives
.iter()
.filter(|o| !o.optional)
.filter(|o| {
self.objectives
.get(&o.id)
.map_or(false, |p| p.state == ObjectiveState::Completed)
})
.count()
}
pub fn required_total(&self, def: &QuestDef) -> usize {
def.objectives.iter().filter(|o| !o.optional).count()
}
pub fn all_required_complete(&self, def: &QuestDef) -> bool {
self.required_completed(def) == self.required_total(def)
}
pub fn completion_fraction(&self, def: &QuestDef) -> f32 {
let total = self.required_total(def);
if total == 0 { return 1.0; }
self.required_completed(def) as f32 / total as f32
}
pub fn is_timed_out(&self, def: &QuestDef) -> bool {
if let Some(limit) = def.time_limit {
self.time_elapsed >= limit
} else {
false
}
}
}
#[derive(Debug, Clone)]
pub struct JournalSummary {
pub active_count: usize,
pub completed_count: u32,
pub failed_count: u32,
pub main_quest_progress: Option<(QuestId, f32)>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ObjectiveAdvanceResult {
Progressed { new_value: u32, completed: bool },
AlreadyComplete,
NoEffect,
QuestComplete,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JournalError {
QuestNotFound,
AlreadyActive,
Prerequisites,
QuestFull,
ObjectiveNotFound,
NotActive,
AlreadyCompleted,
}
impl std::fmt::Display for JournalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
JournalError::QuestNotFound => "quest not found",
JournalError::AlreadyActive => "quest is already active",
JournalError::Prerequisites => "prerequisites not satisfied",
JournalError::QuestFull => "journal is full",
JournalError::ObjectiveNotFound => "objective not found",
JournalError::NotActive => "quest is not active",
JournalError::AlreadyCompleted => "quest is already completed",
};
write!(f, "JournalError: {}", msg)
}
}
#[derive(Debug, Clone)]
pub enum QuestEvent {
QuestAvailable(QuestId),
QuestStarted(QuestId),
ObjectiveUpdated { quest: QuestId, obj: ObjectiveId, progress: u32 },
ObjectiveComplete(QuestId, ObjectiveId),
QuestComplete(QuestId),
QuestFailed(QuestId),
QuestTimedOut(QuestId),
RewardGranted(QuestId, Reward),
}
const MAX_ACTIVE_QUESTS: usize = 64;
#[derive(Debug)]
pub struct QuestJournal {
pub quests: HashMap<QuestId, QuestProgress>,
pub completed_quests: Vec<QuestId>,
pub failed_quests: Vec<QuestId>,
pub flags: HashSet<String>,
pub player_level: u32,
pub completed_count: u32,
pub failed_count: u32,
game_time: f32,
event_queue: VecDeque<QuestEvent>,
}
impl QuestJournal {
pub fn new() -> Self {
Self {
quests: HashMap::new(),
completed_quests: Vec::new(),
failed_quests: Vec::new(),
flags: HashSet::new(),
player_level: 1,
completed_count: 0,
failed_count: 0,
game_time: 0.0,
event_queue: VecDeque::new(),
}
}
pub fn with_player_level(mut self, level: u32) -> Self {
self.player_level = level;
self
}
pub fn set_flag(&mut self, flag: impl Into<String>) {
self.flags.insert(flag.into());
}
pub fn has_flag(&self, flag: &str) -> bool {
self.flags.contains(flag)
}
pub fn clear_flag(&mut self, flag: &str) {
self.flags.remove(flag);
}
fn prerequisite_view(&self) -> PrerequisiteView {
PrerequisiteView::new(
self.player_level,
self.completed_quests.iter().cloned().collect(),
self.failed_quests.iter().cloned().collect(),
self.flags.clone(),
)
}
fn check_prerequisites(&self, def: &QuestDef) -> bool {
let view = self.prerequisite_view();
def.prerequisites.iter().all(|p| p.check(&view))
}
pub fn accept_quest(&mut self, def: &QuestDef, time: f32) -> Result<(), JournalError> {
if let Some(existing) = self.quests.get(&def.id) {
if existing.state == QuestState::Active {
return Err(JournalError::AlreadyActive);
}
if existing.state == QuestState::Completed && !def.repeatable {
return Err(JournalError::AlreadyCompleted);
}
}
if !self.check_prerequisites(def) {
return Err(JournalError::Prerequisites);
}
let active_count = self.quests.values().filter(|q| q.state == QuestState::Active).count();
if active_count >= MAX_ACTIVE_QUESTS {
return Err(JournalError::QuestFull);
}
let mut progress = QuestProgress::new(def, time);
if let Some(old) = self.quests.get(&def.id) {
progress.attempt_count = old.attempt_count + 1;
}
self.quests.insert(def.id, progress);
self.event_queue.push_back(QuestEvent::QuestStarted(def.id));
self.auto_note(def.id, format!("Quest started at t={:.1}", time));
Ok(())
}
pub fn complete_quest(
&mut self,
id: QuestId,
time: f32,
db: &QuestDatabase,
) -> Result<Reward, JournalError> {
let def = db.get(id).ok_or(JournalError::QuestNotFound)?;
{
let progress = self.quests.get_mut(&id).ok_or(JournalError::NotActive)?;
if progress.state != QuestState::Active {
return Err(JournalError::NotActive);
}
progress.state = QuestState::Completed;
progress.completed_at = Some(time);
}
self.completed_quests.push(id);
self.completed_count += 1;
self.event_queue.push_back(QuestEvent::QuestComplete(id));
let reward = def.reward.clone();
self.event_queue.push_back(QuestEvent::RewardGranted(id, reward.clone()));
self.auto_note(id, format!("Quest completed at t={:.1}", time));
Ok(reward)
}
pub fn fail_quest(&mut self, id: QuestId) -> Result<Reward, JournalError> {
{
let progress = self.quests.get_mut(&id).ok_or(JournalError::NotActive)?;
if progress.state != QuestState::Active {
return Err(JournalError::NotActive);
}
progress.state = QuestState::Failed;
progress.completed_at = Some(self.game_time);
}
self.failed_quests.push(id);
self.failed_count += 1;
self.event_queue.push_back(QuestEvent::QuestFailed(id));
self.auto_note(id, "Quest failed.".to_string());
Ok(Reward::default())
}
pub fn abandon_quest(&mut self, id: QuestId) {
if let Some(progress) = self.quests.get_mut(&id) {
if progress.state == QuestState::Active {
progress.state = QuestState::Abandoned;
progress.completed_at = Some(self.game_time);
self.auto_note(id, "Quest abandoned.".to_string());
}
}
}
pub fn get_progress(&self, id: QuestId) -> Option<&QuestProgress> {
self.quests.get(&id)
}
pub fn get_progress_mut(&mut self, id: QuestId) -> Option<&mut QuestProgress> {
self.quests.get_mut(&id)
}
pub fn is_quest_active(&self, id: QuestId) -> bool {
self.quests.get(&id).map_or(false, |p| p.state == QuestState::Active)
}
pub fn is_quest_complete(&self, id: QuestId) -> bool {
self.completed_quests.contains(&id)
}
pub fn active_quests(&self) -> Vec<&QuestProgress> {
self.quests.values().filter(|q| q.state == QuestState::Active).collect()
}
pub fn active_quest_ids(&self) -> Vec<QuestId> {
self.quests
.values()
.filter(|q| q.state == QuestState::Active)
.map(|q| q.def_id)
.collect()
}
pub fn available_quests<'a>(
&self,
db: &'a QuestDatabase,
level: u32,
) -> Vec<&'a QuestDef> {
db.all()
.filter(|def| {
if let Some(existing) = self.quests.get(&def.id) {
if existing.state == QuestState::Active { return false; }
if existing.state == QuestState::Completed && !def.repeatable { return false; }
}
if def.hidden_until_available && !self.check_prerequisites(def) { return false; }
let tmp_view = PrerequisiteView::new(
level,
self.completed_quests.iter().cloned().collect(),
self.failed_quests.iter().cloned().collect(),
self.flags.clone(),
);
def.prerequisites.iter().all(|p| p.check(&tmp_view))
})
.collect()
}
pub fn advance_objective(
&mut self,
quest_id: QuestId,
obj_id: ObjectiveId,
amount: u32,
db: &QuestDatabase,
) -> ObjectiveAdvanceResult {
if amount == 0 { return ObjectiveAdvanceResult::NoEffect; }
let def = match db.get(quest_id) {
Some(d) => d,
None => return ObjectiveAdvanceResult::NoEffect,
};
let obj_def = match def.objectives.iter().find(|o| o.id == obj_id) {
Some(o) => o,
None => return ObjectiveAdvanceResult::NoEffect,
};
let target = obj_def.target_count;
let (new_value, just_completed) = {
let progress = match self.quests.get_mut(&quest_id) {
Some(p) if p.state == QuestState::Active => p,
_ => return ObjectiveAdvanceResult::NoEffect,
};
let op = progress.objectives.entry(obj_id).or_insert_with(|| ObjectiveProgress::new(obj_id));
if op.state == ObjectiveState::Completed {
return ObjectiveAdvanceResult::AlreadyComplete;
}
op.current = (op.current + amount).min(target);
let completed_now = op.current >= target;
if completed_now {
op.state = ObjectiveState::Completed;
op.completed_at = Some(self.game_time);
}
(op.current, completed_now)
};
self.event_queue.push_back(QuestEvent::ObjectiveUpdated {
quest: quest_id,
obj: obj_id,
progress: new_value,
});
if just_completed {
self.event_queue.push_back(QuestEvent::ObjectiveComplete(quest_id, obj_id));
}
let all_done = {
let progress = self.quests.get(&quest_id).unwrap();
def.objectives
.iter()
.filter(|o| !o.optional)
.all(|o| {
progress
.objectives
.get(&o.id)
.map_or(false, |p| p.state == ObjectiveState::Completed)
})
};
if all_done {
let time = self.game_time;
if let Ok(_reward) = self.complete_quest(quest_id, time, db) {
return ObjectiveAdvanceResult::QuestComplete;
}
}
ObjectiveAdvanceResult::Progressed { new_value, completed: just_completed }
}
pub fn complete_objective(
&mut self,
quest_id: QuestId,
obj_id: ObjectiveId,
db: &QuestDatabase,
) -> Result<(), JournalError> {
let def = db.get(quest_id).ok_or(JournalError::QuestNotFound)?;
let obj_def = def
.objectives
.iter()
.find(|o| o.id == obj_id)
.ok_or(JournalError::ObjectiveNotFound)?;
{
let progress = self.quests.get_mut(&quest_id).ok_or(JournalError::NotActive)?;
if progress.state != QuestState::Active { return Err(JournalError::NotActive); }
let op = progress.objectives.entry(obj_id).or_insert_with(|| ObjectiveProgress::new(obj_id));
op.current = obj_def.target_count;
op.state = ObjectiveState::Completed;
op.completed_at = Some(self.game_time);
}
self.event_queue.push_back(QuestEvent::ObjectiveComplete(quest_id, obj_id));
let all_done = {
let progress = self.quests.get(&quest_id).unwrap();
def.objectives
.iter()
.filter(|o| !o.optional)
.all(|o| {
progress
.objectives
.get(&o.id)
.map_or(false, |p| p.state == ObjectiveState::Completed)
})
};
if all_done {
let time = self.game_time;
let _ = self.complete_quest(quest_id, time, db);
}
Ok(())
}
pub fn fail_objective(
&mut self,
quest_id: QuestId,
obj_id: ObjectiveId,
db: &QuestDatabase,
) {
let optional = db
.get(quest_id)
.and_then(|def| def.objectives.iter().find(|o| o.id == obj_id))
.map(|o| o.optional)
.unwrap_or(true);
if let Some(progress) = self.quests.get_mut(&quest_id) {
if let Some(op) = progress.objectives.get_mut(&obj_id) {
op.state = ObjectiveState::Failed;
}
}
if !optional {
let _ = self.fail_quest(quest_id);
}
}
pub fn tick(&mut self, delta: f32, db: &QuestDatabase) -> Vec<QuestEvent> {
self.game_time += delta;
let timed_out: Vec<QuestId> = self
.quests
.iter_mut()
.filter(|(_, p)| p.state == QuestState::Active)
.filter_map(|(id, p)| {
p.time_elapsed += delta;
if let Some(def) = db.get(*id) {
if p.is_timed_out(def) { Some(*id) } else { None }
} else {
None
}
})
.collect();
for id in timed_out {
self.event_queue.push_back(QuestEvent::QuestTimedOut(id));
let _ = self.fail_quest(id);
}
self.drain_events()
}
pub fn add_note(&mut self, quest_id: QuestId, text: impl Into<String>) {
if let Some(progress) = self.quests.get_mut(&quest_id) {
progress.notes.push(JournalNote::new(self.game_time, text));
}
}
pub fn auto_note(&mut self, quest_id: QuestId, text: impl Into<String>) {
if let Some(progress) = self.quests.get_mut(&quest_id) {
progress.notes.push(JournalNote::auto(self.game_time, text));
}
}
pub fn drain_events(&mut self) -> Vec<QuestEvent> {
self.event_queue.drain(..).collect()
}
pub fn summary(&self, db: &QuestDatabase) -> JournalSummary {
let active_count = self.quests.values().filter(|q| q.state == QuestState::Active).count();
let main_quest_progress = self
.quests
.values()
.filter(|q| q.state == QuestState::Active)
.find(|q| {
db.get(q.def_id)
.map_or(false, |d| d.category == QuestCategory::Main)
})
.and_then(|q| {
db.get(q.def_id).map(|def| {
let frac = q.completion_fraction(def);
(q.def_id, frac)
})
});
JournalSummary {
active_count,
completed_count: self.completed_count,
failed_count: self.failed_count,
main_quest_progress,
}
}
pub fn game_time(&self) -> f32 { self.game_time }
pub fn set_game_time(&mut self, t: f32) { self.game_time = t; }
}
impl Default for QuestJournal {
fn default() -> Self { Self::new() }
}
impl QuestJournal {
pub fn finished_quests(&self) -> Vec<&QuestProgress> {
self.quests.values().filter(|q| q.state.is_terminal()).collect()
}
pub fn active_by_category(&self, category: QuestCategory, db: &QuestDatabase) -> Vec<&QuestProgress> {
self.quests
.values()
.filter(|q| q.state == QuestState::Active)
.filter(|q| {
db.get(q.def_id).map_or(false, |def| def.category == category)
})
.collect()
}
pub fn active_by_priority(&self, priority: QuestPriority, db: &QuestDatabase) -> Vec<&QuestProgress> {
self.quests
.values()
.filter(|q| q.state == QuestState::Active)
.filter(|q| {
db.get(q.def_id).map_or(false, |def| def.priority == priority)
})
.collect()
}
pub fn highest_priority_active(&self, db: &QuestDatabase) -> Option<&QuestProgress> {
self.quests
.values()
.filter(|q| q.state == QuestState::Active)
.max_by_key(|q| {
db.get(q.def_id)
.map(|def| def.priority as u32)
.unwrap_or(0)
})
}
pub fn has_active_with_tag(&self, tag: &str, db: &QuestDatabase) -> bool {
self.quests
.values()
.filter(|q| q.state == QuestState::Active)
.any(|q| db.get(q.def_id).map_or(false, |def| def.has_tag(tag)))
}
pub fn active_count(&self) -> usize {
self.quests.values().filter(|q| q.state == QuestState::Active).count()
}
pub fn is_empty(&self) -> bool { self.active_count() == 0 }
pub fn objective_fraction(
&self,
quest_id: QuestId,
obj_id: ObjectiveId,
db: &QuestDatabase,
) -> Option<f32> {
let progress = self.quests.get(&quest_id)?;
let def = db.get(quest_id)?;
let obj_def = def.objectives.iter().find(|o| o.id == obj_id)?;
let op = progress.objectives.get(&obj_id)?;
Some(op.fraction(obj_def.target_count))
}
pub fn objective_current(&self, quest_id: QuestId, obj_id: ObjectiveId) -> Option<u32> {
self.quests
.get(&quest_id)?
.objectives
.get(&obj_id)
.map(|op| op.current)
}
pub fn is_objective_complete(&self, quest_id: QuestId, obj_id: ObjectiveId) -> bool {
self.quests
.get(&quest_id)
.and_then(|q| q.objectives.get(&obj_id))
.map_or(false, |op| op.state == ObjectiveState::Completed)
}
pub fn time_remaining(&self, quest_id: QuestId, db: &QuestDatabase) -> Option<f32> {
let progress = self.quests.get(&quest_id)?;
let def = db.get(quest_id)?;
let limit = def.time_limit?;
Some((limit - progress.time_elapsed).max(0.0))
}
pub fn time_fraction_elapsed(&self, quest_id: QuestId, db: &QuestDatabase) -> Option<f32> {
let progress = self.quests.get(&quest_id)?;
let def = db.get(quest_id)?;
let limit = def.time_limit?;
Some((progress.time_elapsed / limit).min(1.0))
}
pub fn set_flags(&mut self, flags: impl IntoIterator<Item = impl Into<String>>) {
for flag in flags { self.flags.insert(flag.into()); }
}
pub fn clear_flags_with_prefix(&mut self, prefix: &str) {
self.flags.retain(|f| !f.starts_with(prefix));
}
pub fn all_flags(&self) -> Vec<&str> {
self.flags.iter().map(|s| s.as_str()).collect()
}
pub fn attempt_count(&self, quest_id: QuestId) -> u32 {
self.quests.get(&quest_id).map_or(0, |q| q.attempt_count)
}
pub fn quest_elapsed(&self, quest_id: QuestId) -> Option<f32> {
self.quests.get(&quest_id).map(|q| q.time_elapsed)
}
pub fn quest_notes(&self, quest_id: QuestId) -> &[JournalNote] {
self.quests
.get(&quest_id)
.map_or(&[], |q| q.notes.as_slice())
}
pub fn compact(&mut self) {
self.quests.retain(|_, v| !v.state.is_terminal());
}
pub fn reset_progress(&mut self) {
self.quests.clear();
self.flags.clear();
self.event_queue.clear();
self.game_time = 0.0;
}
pub fn accept_all(&mut self, defs: &[QuestDef], time: f32) -> usize {
let mut count = 0;
for def in defs {
if self.accept_quest(def, time).is_ok() { count += 1; }
}
count
}
pub fn script_complete_all_objectives(
&mut self,
quest_id: QuestId,
db: &QuestDatabase,
) -> Result<(), JournalError> {
let def = db.get(quest_id).ok_or(JournalError::QuestNotFound)?.clone();
let ids: Vec<ObjectiveId> = def.objectives.iter().map(|o| o.id).collect();
for obj_id in ids {
let _ = self.complete_objective(quest_id, obj_id, db);
if !self.is_quest_active(quest_id) { break; }
}
Ok(())
}
pub fn objective_snapshot(&self, db: &QuestDatabase) -> Vec<(QuestId, ObjectiveId, u32, u32)> {
let mut out = Vec::new();
for (qid, progress) in &self.quests {
if progress.state != QuestState::Active { continue; }
if let Some(def) = db.get(*qid) {
for obj_def in &def.objectives {
let current = progress
.objectives
.get(&obj_def.id)
.map_or(0, |op| op.current);
out.push((*qid, obj_def.id, current, obj_def.target_count));
}
}
}
out
}
pub fn peek_events(&self) -> Vec<&QuestEvent> {
self.event_queue.iter().collect()
}
}
pub struct JournalTestHarness {
pub db: QuestDatabase,
pub journal: QuestJournal,
}
impl JournalTestHarness {
pub fn new() -> Self {
Self { db: QuestDatabase::new(), journal: QuestJournal::new() }
}
pub fn add_kill_quest(
&mut self,
quest_id: u32,
objective_id: u32,
enemy_type: &str,
count: u32,
xp: u32,
) {
let def = QuestDef::new(
QuestId(quest_id),
format!("Kill {} {}", count, enemy_type),
"A basic kill quest.",
QuestCategory::Combat,
QuestPriority::Normal,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(objective_id),
format!("Slay {} {}", count, enemy_type),
ObjectiveType::Kill { enemy_type: enemy_type.to_string() },
count,
))
.with_reward(Reward::new().with_experience(xp));
self.db.register(def);
}
pub fn accept(&mut self, quest_id: u32) {
let def = self.db.get(QuestId(quest_id)).unwrap().clone();
self.journal.accept_quest(&def, self.journal.game_time()).unwrap();
let _ = self.journal.drain_events();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_kill_quest() -> QuestDef {
QuestDef::new(
QuestId(1),
"Goblin Slayer",
"Kill 3 goblins.",
QuestCategory::Combat,
QuestPriority::Normal,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"Kill goblins (0/3)",
ObjectiveType::Kill { enemy_type: "goblin".into() },
3,
))
.with_reward(Reward::new().with_experience(150).with_gold(50))
}
fn two_objective_quest() -> QuestDef {
QuestDef::new(
QuestId(2),
"Collector",
"Kill a wolf and collect 2 pelts.",
QuestCategory::Side,
QuestPriority::Low,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"Kill wolf",
ObjectiveType::Kill { enemy_type: "wolf".into() },
1,
))
.with_objective(ObjectiveDef::new(
ObjectiveId(2),
"Collect wolf pelts",
ObjectiveType::Collect { item_id: 101, count: 2 },
2,
))
.with_reward(Reward::new().with_experience(200))
}
fn timed_quest(limit_secs: f32) -> QuestDef {
QuestDef::new(
QuestId(3),
"Timed Challenge",
"Survive the arena for 10 seconds.",
QuestCategory::Combat,
QuestPriority::High,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"Survive",
ObjectiveType::Survive { duration: limit_secs },
1,
))
.with_reward(Reward::new().with_experience(300))
.with_time_limit(limit_secs)
}
#[test]
fn accept_quest_sets_active_state() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
assert!(journal.is_quest_active(QuestId(1)));
let p = journal.get_progress(QuestId(1)).unwrap();
assert_eq!(p.state, QuestState::Active);
assert_eq!(p.attempt_count, 1);
}
#[test]
fn accept_quest_fires_started_event() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let events = journal.drain_events();
assert!(events.iter().any(|e| matches!(e, QuestEvent::QuestStarted(QuestId(1)))));
}
#[test]
fn accept_quest_twice_returns_already_active() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let err = journal.accept_quest(&def, 1.0).unwrap_err();
assert_eq!(err, JournalError::AlreadyActive);
}
#[test]
fn accept_fails_prerequisites() {
let def = QuestDef::new(
QuestId(10),
"Gated Quest",
"Requires level 10.",
QuestCategory::Main,
QuestPriority::High,
)
.with_prerequisite(Prerequisite::MinLevel(10));
let mut journal = QuestJournal::new(); let err = journal.accept_quest(&def, 0.0).unwrap_err();
assert_eq!(err, JournalError::Prerequisites);
}
#[test]
fn advance_objective_progress() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let result = journal.advance_objective(QuestId(1), ObjectiveId(1), 2, &db);
assert_eq!(result, ObjectiveAdvanceResult::Progressed { new_value: 2, completed: false });
let p = journal.get_progress(QuestId(1)).unwrap();
assert_eq!(p.objectives[&ObjectiveId(1)].current, 2);
}
#[test]
fn advance_objective_completes_quest() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let result = journal.advance_objective(QuestId(1), ObjectiveId(1), 3, &db);
assert_eq!(result, ObjectiveAdvanceResult::QuestComplete);
assert!(journal.is_quest_complete(QuestId(1)));
}
#[test]
fn advance_already_complete_objective() {
let mut db = QuestDatabase::new();
let def = two_objective_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.advance_objective(QuestId(2), ObjectiveId(1), 1, &db);
let result = journal.advance_objective(QuestId(2), ObjectiveId(1), 1, &db);
assert_eq!(result, ObjectiveAdvanceResult::AlreadyComplete);
}
#[test]
fn advance_objective_capped_at_target() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.advance_objective(QuestId(1), ObjectiveId(1), 100, &db);
assert!(journal.is_quest_complete(QuestId(1)));
}
#[test]
fn complete_quest_manually() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let reward = journal.complete_quest(QuestId(1), 5.0, &db).unwrap();
assert_eq!(reward.experience, 150);
assert_eq!(reward.gold, 50);
assert!(journal.is_quest_complete(QuestId(1)));
assert_eq!(journal.completed_count, 1);
}
#[test]
fn complete_quest_fires_events() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.complete_quest(QuestId(1), 5.0, &db).unwrap();
let events = journal.drain_events();
assert!(events.iter().any(|e| matches!(e, QuestEvent::QuestComplete(QuestId(1)))));
assert!(events.iter().any(|e| matches!(e, QuestEvent::RewardGranted(QuestId(1), _))));
}
#[test]
fn complete_inactive_quest_returns_error() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
let err = journal.complete_quest(QuestId(1), 0.0, &db).unwrap_err();
assert_eq!(err, JournalError::NotActive);
}
#[test]
fn fail_quest_increments_counter() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.fail_quest(QuestId(1)).unwrap();
assert_eq!(journal.failed_count, 1);
assert!(!journal.is_quest_active(QuestId(1)));
}
#[test]
fn quest_fails_on_timeout() {
let mut db = QuestDatabase::new();
let def = timed_quest(5.0);
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let events = journal.tick(4.0, &db);
assert!(!events.iter().any(|e| matches!(e, QuestEvent::QuestTimedOut(_))));
assert!(journal.is_quest_active(QuestId(3)));
let events = journal.tick(2.0, &db);
assert!(events.iter().any(|e| matches!(e, QuestEvent::QuestTimedOut(QuestId(3)))));
assert!(!journal.is_quest_active(QuestId(3)));
assert_eq!(journal.failed_count, 1);
}
#[test]
fn abandon_quest_state() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
journal.abandon_quest(QuestId(1));
let p = journal.get_progress(QuestId(1)).unwrap();
assert_eq!(p.state, QuestState::Abandoned);
}
#[test]
fn flag_set_has_clear() {
let mut journal = QuestJournal::new();
journal.set_flag("temple_cleared");
assert!(journal.has_flag("temple_cleared"));
journal.clear_flag("temple_cleared");
assert!(!journal.has_flag("temple_cleared"));
}
#[test]
fn flag_prerequisite_gates_accept() {
let def = QuestDef::new(
QuestId(20),
"Temple Quest",
"Only after clearing the temple.",
QuestCategory::Main,
QuestPriority::High,
)
.with_prerequisite(Prerequisite::HasFlag("temple_cleared".into()));
let mut journal = QuestJournal::new();
assert_eq!(
journal.accept_quest(&def, 0.0).unwrap_err(),
JournalError::Prerequisites
);
journal.set_flag("temple_cleared");
assert!(journal.accept_quest(&def, 0.0).is_ok());
}
#[test]
fn notes_are_stored() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
journal.add_note(QuestId(1), "Saw goblins near the bridge.");
let p = journal.get_progress(QuestId(1)).unwrap();
assert!(p.notes.iter().any(|n| n.text.contains("bridge")));
assert!(p.notes.iter().any(|n| n.auto_generated));
}
#[test]
fn summary_reflects_state() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let s = journal.summary(&db);
assert_eq!(s.active_count, 1);
assert_eq!(s.completed_count, 0);
}
#[test]
fn fail_required_objective_fails_quest() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.fail_objective(QuestId(1), ObjectiveId(1), &db);
assert!(!journal.is_quest_active(QuestId(1)));
assert_eq!(journal.failed_count, 1);
}
#[test]
fn two_objectives_both_needed() {
let mut db = QuestDatabase::new();
let def = two_objective_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let r1 = journal.advance_objective(QuestId(2), ObjectiveId(1), 1, &db);
assert!(!matches!(r1, ObjectiveAdvanceResult::QuestComplete));
assert!(journal.is_quest_active(QuestId(2)));
let r2 = journal.advance_objective(QuestId(2), ObjectiveId(2), 2, &db);
assert_eq!(r2, ObjectiveAdvanceResult::QuestComplete);
assert!(!journal.is_quest_active(QuestId(2)));
assert!(journal.is_quest_complete(QuestId(2)));
}
#[test]
fn complete_objective_scripted() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.complete_objective(QuestId(1), ObjectiveId(1), &db).unwrap();
assert!(journal.is_quest_complete(QuestId(1)));
}
#[test]
fn repeatable_quest_can_be_re_accepted() {
let mut db = QuestDatabase::new();
let def = QuestDef::new(
QuestId(50),
"Daily Bounty",
"Kill 5 skeletons daily.",
QuestCategory::Daily,
QuestPriority::Normal,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"Kill skeletons",
ObjectiveType::Kill { enemy_type: "skeleton".into() },
5,
))
.with_reward(Reward::new().with_gold(100))
.repeatable();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.complete_quest(QuestId(50), 1.0, &db).unwrap();
let _ = journal.drain_events();
let result = journal.accept_quest(&def, 2.0);
assert!(result.is_ok(), "repeatable quest should be re-acceptable");
assert_eq!(
journal.get_progress(QuestId(50)).unwrap().attempt_count,
2
);
}
#[test]
fn active_quests_returns_only_active() {
let def1 = simple_kill_quest();
let def2 = two_objective_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def1, 0.0).unwrap();
journal.accept_quest(&def2, 0.0).unwrap();
journal.abandon_quest(QuestId(1));
let active = journal.active_quests();
assert_eq!(active.len(), 1);
assert_eq!(active[0].def_id, QuestId(2));
}
#[test]
fn active_count_reflects_state() {
let def1 = simple_kill_quest();
let def2 = two_objective_quest();
let def3 = timed_quest(30.0);
let mut db = QuestDatabase::new();
db.register(def1.clone());
db.register(def2.clone());
db.register(def3.clone());
let mut journal = QuestJournal::new();
assert_eq!(journal.active_count(), 0);
assert!(journal.is_empty());
journal.accept_quest(&def1, 0.0).unwrap();
assert_eq!(journal.active_count(), 1);
assert!(!journal.is_empty());
journal.accept_quest(&def2, 0.0).unwrap();
journal.accept_quest(&def3, 0.0).unwrap();
assert_eq!(journal.active_count(), 3);
journal.fail_quest(QuestId(1)).unwrap();
assert_eq!(journal.active_count(), 2);
}
#[test]
fn active_by_category_filters_correctly() {
let mut db = QuestDatabase::new();
let combat = simple_kill_quest(); let side = two_objective_quest(); db.register(combat.clone());
db.register(side.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&combat, 0.0).unwrap();
journal.accept_quest(&side, 0.0).unwrap();
let combat_active = journal.active_by_category(QuestCategory::Combat, &db);
assert_eq!(combat_active.len(), 1);
assert_eq!(combat_active[0].def_id, QuestId(1));
let side_active = journal.active_by_category(QuestCategory::Side, &db);
assert_eq!(side_active.len(), 1);
assert_eq!(side_active[0].def_id, QuestId(2));
let exploration = journal.active_by_category(QuestCategory::Exploration, &db);
assert!(exploration.is_empty());
}
#[test]
fn highest_priority_active_returns_correct_quest() {
let mut db = QuestDatabase::new();
let low = QuestDef::new(
QuestId(10),
"Low Prio",
"desc",
QuestCategory::Side,
QuestPriority::Low,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"do something",
ObjectiveType::Custom { key: "x".into() },
1,
))
.with_reward(Reward::new());
let critical = QuestDef::new(
QuestId(11),
"Critical",
"desc",
QuestCategory::Main,
QuestPriority::Critical,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"do critical thing",
ObjectiveType::Custom { key: "y".into() },
1,
))
.with_reward(Reward::new());
db.register(low.clone());
db.register(critical.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&low, 0.0).unwrap();
journal.accept_quest(&critical, 0.0).unwrap();
let top = journal.highest_priority_active(&db).unwrap();
assert_eq!(top.def_id, QuestId(11));
}
#[test]
fn has_active_with_tag_works() {
let mut db = QuestDatabase::new();
let tagged = QuestDef::new(
QuestId(20),
"Tagged Quest",
"desc",
QuestCategory::Exploration,
QuestPriority::Normal,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"explore",
ObjectiveType::Reach { location: "forest".into() },
1,
))
.with_reward(Reward::new())
.with_tag("story_chapter_1");
db.register(tagged.clone());
let mut journal = QuestJournal::new();
assert!(!journal.has_active_with_tag("story_chapter_1", &db));
journal.accept_quest(&tagged, 0.0).unwrap();
assert!(journal.has_active_with_tag("story_chapter_1", &db));
assert!(!journal.has_active_with_tag("other_tag", &db));
}
#[test]
fn objective_fraction_is_computed() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest(); db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let frac = journal.objective_fraction(QuestId(1), ObjectiveId(1), &db);
assert!((frac.unwrap() - 0.0).abs() < f32::EPSILON);
journal.advance_objective(QuestId(1), ObjectiveId(1), 1, &db);
let frac = journal.objective_fraction(QuestId(1), ObjectiveId(1), &db).unwrap();
assert!((frac - 1.0 / 3.0).abs() < 0.001);
}
#[test]
fn objective_current_returns_value() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
assert_eq!(journal.objective_current(QuestId(1), ObjectiveId(1)), Some(0));
journal.advance_objective(QuestId(1), ObjectiveId(1), 2, &db);
assert_eq!(journal.objective_current(QuestId(1), ObjectiveId(1)), Some(2));
}
#[test]
fn is_objective_complete_checks_state() {
let mut db = QuestDatabase::new();
let def = two_objective_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
assert!(!journal.is_objective_complete(QuestId(2), ObjectiveId(1)));
journal.advance_objective(QuestId(2), ObjectiveId(1), 1, &db);
assert!(journal.is_objective_complete(QuestId(2), ObjectiveId(1)));
assert!(!journal.is_objective_complete(QuestId(2), ObjectiveId(2)));
}
#[test]
fn time_remaining_decreases_with_tick() {
let mut db = QuestDatabase::new();
let def = timed_quest(10.0);
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let remaining_start = journal.time_remaining(QuestId(3), &db).unwrap();
assert!((remaining_start - 10.0).abs() < f32::EPSILON);
journal.tick(3.0, &db);
let remaining_after = journal.time_remaining(QuestId(3), &db).unwrap();
assert!((remaining_after - 7.0).abs() < 0.01);
}
#[test]
fn time_fraction_elapsed_increases() {
let mut db = QuestDatabase::new();
let def = timed_quest(10.0);
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.tick(5.0, &db);
let frac = journal.time_fraction_elapsed(QuestId(3), &db).unwrap();
assert!((frac - 0.5).abs() < 0.01);
}
#[test]
fn set_flags_bulk() {
let mut journal = QuestJournal::new();
journal.set_flags(["flag_a", "flag_b", "flag_c"]);
assert!(journal.has_flag("flag_a"));
assert!(journal.has_flag("flag_b"));
assert!(journal.has_flag("flag_c"));
}
#[test]
fn clear_flags_with_prefix() {
let mut journal = QuestJournal::new();
journal.set_flags(["chapter_1_done", "chapter_2_done", "boss_dead"]);
journal.clear_flags_with_prefix("chapter_");
assert!(!journal.has_flag("chapter_1_done"));
assert!(!journal.has_flag("chapter_2_done"));
assert!(journal.has_flag("boss_dead"));
}
#[test]
fn all_flags_returns_set_flags() {
let mut journal = QuestJournal::new();
journal.set_flag("alpha");
journal.set_flag("beta");
let flags = journal.all_flags();
assert!(flags.contains(&"alpha"));
assert!(flags.contains(&"beta"));
assert_eq!(flags.len(), 2);
}
#[test]
fn compact_removes_terminal_quests() {
let def1 = simple_kill_quest();
let def2 = two_objective_quest();
let mut db = QuestDatabase::new();
db.register(def1.clone());
db.register(def2.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def1, 0.0).unwrap();
journal.accept_quest(&def2, 0.0).unwrap();
journal.complete_quest(QuestId(1), 1.0, &db).unwrap();
assert_eq!(journal.quests.len(), 2);
journal.compact();
assert_eq!(journal.quests.len(), 1); assert!(journal.quests.contains_key(&QuestId(2)));
}
#[test]
fn reset_progress_clears_state() {
let def = simple_kill_quest();
let mut db = QuestDatabase::new();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
journal.set_flag("test_flag");
journal.reset_progress();
assert!(journal.quests.is_empty());
assert!(!journal.has_flag("test_flag"));
}
#[test]
fn accept_all_counts_successes() {
let def1 = simple_kill_quest();
let def2 = two_objective_quest();
let def3 = timed_quest(20.0);
let mut db = QuestDatabase::new();
db.register(def1.clone());
db.register(def2.clone());
db.register(def3.clone());
let mut journal = QuestJournal::new();
let accepted = journal.accept_all(&[def1, def2, def3], 0.0);
assert_eq!(accepted, 3);
assert_eq!(journal.active_count(), 3);
}
#[test]
fn script_complete_all_objectives_completes_quest() {
let def = two_objective_quest();
let mut db = QuestDatabase::new();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.script_complete_all_objectives(QuestId(2), &db).unwrap();
assert!(journal.is_quest_complete(QuestId(2)));
}
#[test]
fn objective_snapshot_returns_all_active_objectives() {
let def = two_objective_quest();
let mut db = QuestDatabase::new();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let snap = journal.objective_snapshot(&db);
assert_eq!(snap.len(), 2);
for (_, _, current, target) in &snap {
assert_eq!(*current, 0);
assert!(*target > 0);
}
}
#[test]
fn peek_events_does_not_consume() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let peeked = journal.peek_events();
assert!(!peeked.is_empty());
let drained = journal.drain_events();
assert_eq!(drained.len(), peeked.len());
}
#[test]
fn quest_elapsed_tracks_time() {
let def = simple_kill_quest();
let mut db = QuestDatabase::new();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
assert_eq!(journal.quest_elapsed(QuestId(1)), Some(0.0));
journal.tick(7.5, &db);
let elapsed = journal.quest_elapsed(QuestId(1)).unwrap();
assert!((elapsed - 7.5).abs() < 0.01);
}
#[test]
fn attempt_count_increments_on_reattempt() {
let mut db = QuestDatabase::new();
let def = QuestDef::new(
QuestId(100),
"Retry Quest",
"desc",
QuestCategory::Side,
QuestPriority::Low,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"obj",
ObjectiveType::Custom { key: "ev".into() },
1,
))
.with_reward(Reward::new())
.repeatable();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
assert_eq!(journal.attempt_count(QuestId(100)), 1);
journal.fail_quest(QuestId(100)).unwrap();
journal.accept_quest(&def, 1.0).unwrap();
assert_eq!(journal.attempt_count(QuestId(100)), 2);
}
#[test]
fn quest_notes_are_accessible() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
journal.add_note(QuestId(1), "Manual note.");
journal.auto_note(QuestId(1), "Auto note.");
let notes = journal.quest_notes(QuestId(1));
assert!(notes.iter().any(|n| n.text == "Manual note." && !n.auto_generated));
assert!(notes.iter().any(|n| n.text == "Auto note." && n.auto_generated));
}
#[test]
fn finished_quests_includes_failed_and_abandoned() {
let def1 = simple_kill_quest();
let def2 = two_objective_quest();
let def3 = timed_quest(10.0);
let mut db = QuestDatabase::new();
db.register(def1.clone());
db.register(def2.clone());
db.register(def3.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def1, 0.0).unwrap();
journal.accept_quest(&def2, 0.0).unwrap();
journal.accept_quest(&def3, 0.0).unwrap();
let _ = journal.drain_events();
journal.fail_quest(QuestId(1)).unwrap();
journal.abandon_quest(QuestId(2));
let finished = journal.finished_quests();
assert_eq!(finished.len(), 2);
}
#[test]
fn active_by_priority_returns_correct_subset() {
let mut db = QuestDatabase::new();
let high = QuestDef::new(
QuestId(30),
"High Quest",
"desc",
QuestCategory::Main,
QuestPriority::High,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"obj",
ObjectiveType::Custom { key: "a".into() },
1,
))
.with_reward(Reward::new());
let low = QuestDef::new(
QuestId(31),
"Low Quest",
"desc",
QuestCategory::Side,
QuestPriority::Low,
)
.with_objective(ObjectiveDef::new(
ObjectiveId(1),
"obj",
ObjectiveType::Custom { key: "b".into() },
1,
))
.with_reward(Reward::new());
db.register(high.clone());
db.register(low.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&high, 0.0).unwrap();
journal.accept_quest(&low, 0.0).unwrap();
let high_active = journal.active_by_priority(QuestPriority::High, &db);
assert_eq!(high_active.len(), 1);
assert_eq!(high_active[0].def_id, QuestId(30));
let low_active = journal.active_by_priority(QuestPriority::Low, &db);
assert_eq!(low_active.len(), 1);
assert_eq!(low_active[0].def_id, QuestId(31));
}
#[test]
fn harness_add_and_accept() {
let mut h = JournalTestHarness::new();
h.add_kill_quest(1, 1, "zombie", 10, 250);
h.accept(1);
assert!(h.journal.is_quest_active(QuestId(1)));
}
#[test]
fn no_effect_advance_returns_no_effect() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let result = journal.advance_objective(QuestId(1), ObjectiveId(1), 0, &db);
assert_eq!(result, ObjectiveAdvanceResult::NoEffect);
}
#[test]
fn advance_on_nonexistent_objective_returns_no_effect() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
let result = journal.advance_objective(QuestId(1), ObjectiveId(99), 1, &db);
assert_eq!(result, ObjectiveAdvanceResult::NoEffect);
}
#[test]
fn complete_objective_on_missing_quest_returns_error() {
let mut db = QuestDatabase::new();
let def = simple_kill_quest();
db.register(def);
let mut journal = QuestJournal::new();
let err = journal
.complete_objective(QuestId(1), ObjectiveId(1), &db)
.unwrap_err();
assert_eq!(err, JournalError::NotActive);
}
#[test]
fn fail_optional_objective_does_not_fail_quest() {
let mut db = QuestDatabase::new();
let def = QuestDef::new(
QuestId(200),
"Optional Test",
"desc",
QuestCategory::Side,
QuestPriority::Normal,
)
.with_objective(
ObjectiveDef::new(
ObjectiveId(1),
"Required obj",
ObjectiveType::Kill { enemy_type: "goblin".into() },
1,
)
)
.with_objective(
ObjectiveDef::new(
ObjectiveId(2),
"Optional bonus",
ObjectiveType::Collect { item_id: 55, count: 1 },
1,
)
.optional()
)
.with_reward(Reward::new().with_experience(100));
db.register(def.clone());
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let _ = journal.drain_events();
journal.fail_objective(QuestId(200), ObjectiveId(2), &db);
assert!(journal.is_quest_active(QuestId(200)));
journal.fail_objective(QuestId(200), ObjectiveId(1), &db);
assert!(!journal.is_quest_active(QuestId(200)));
}
#[test]
fn game_time_advances_with_tick() {
let db = QuestDatabase::new();
let mut journal = QuestJournal::new();
assert!((journal.game_time() - 0.0).abs() < f32::EPSILON);
journal.tick(1.5, &db);
assert!((journal.game_time() - 1.5).abs() < 0.001);
journal.tick(2.5, &db);
assert!((journal.game_time() - 4.0).abs() < 0.001);
}
#[test]
fn set_game_time_overrides() {
let mut journal = QuestJournal::new();
journal.set_game_time(100.0);
assert!((journal.game_time() - 100.0).abs() < f32::EPSILON);
}
#[test]
fn drain_events_empty_after_drain() {
let def = simple_kill_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def, 0.0).unwrap();
let first = journal.drain_events();
assert!(!first.is_empty());
let second = journal.drain_events();
assert!(second.is_empty());
}
#[test]
fn active_quest_ids_correct() {
let def1 = simple_kill_quest();
let def2 = two_objective_quest();
let mut journal = QuestJournal::new();
journal.accept_quest(&def1, 0.0).unwrap();
journal.accept_quest(&def2, 0.0).unwrap();
let mut ids = journal.active_quest_ids();
ids.sort();
assert_eq!(ids, vec![QuestId(1), QuestId(2)]);
}
}