use std::collections::HashSet;
use crate::data::{
ActorId, Conversation, ConversationId, DialogueDatabase, DialogueEntry, EntryId,
};
pub const MAX_EVALUATE_DEPTH: usize = 128;
#[derive(Debug, Clone, PartialEq)]
pub enum ConversationRef {
Id(ConversationId),
Title(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Subtitle {
pub conversation: ConversationId,
pub entry: EntryId,
pub actor: ActorId,
pub conversant: ActorId,
pub text: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Response {
pub conversation: ConversationId,
pub entry: EntryId,
pub text: String,
pub is_player: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Step {
Line(Subtitle),
Menu(Vec<Response>),
End,
}
pub fn find_conversation<'a>(
db: &'a DialogueDatabase,
conversation: &ConversationRef,
) -> Option<&'a Conversation> {
match conversation {
ConversationRef::Id(id) => db.conversations.iter().find(|c| c.id == *id),
ConversationRef::Title(title) => db.conversations.iter().find(|c| &c.title == title),
}
}
pub fn root_entry(conversation: &Conversation) -> Option<&DialogueEntry> {
conversation
.entries
.iter()
.find(|e| e.is_root)
.or_else(|| conversation.entries.first())
}
pub fn entry_at(db: &DialogueDatabase, at: (ConversationId, EntryId)) -> Option<&DialogueEntry> {
db.conversations
.iter()
.find(|c| c.id == at.0)?
.entries
.iter()
.find(|e| e.id == at.1)
}
pub fn subtitle_at(db: &DialogueDatabase, at: (ConversationId, EntryId)) -> Option<Subtitle> {
let entry = entry_at(db, at)?;
Some(Subtitle {
conversation: at.0,
entry: at.1,
actor: entry.actor,
conversant: entry.conversant,
text: entry.dialogue_text.clone(),
})
}
pub type Gate<'a> = &'a mut dyn FnMut((ConversationId, EntryId)) -> bool;
pub fn responses(
db: &DialogueDatabase,
from: (ConversationId, EntryId),
gate: Gate<'_>,
) -> Vec<Response> {
collect_responses(db, from, 0, &mut HashSet::new(), gate)
}
fn collect_responses(
db: &DialogueDatabase,
from: (ConversationId, EntryId),
depth: usize,
visited: &mut HashSet<(ConversationId, EntryId)>,
gate: Gate<'_>,
) -> Vec<Response> {
if depth > MAX_EVALUATE_DEPTH {
return Vec::new();
}
entry_at(db, from)
.into_iter()
.flat_map(|entry| &entry.links)
.map(|link| (link.dest_conversation, link.dest_entry))
.flat_map(|dest| destination_responses(db, dest, depth, visited, gate))
.collect()
}
fn destination_responses(
db: &DialogueDatabase,
dest: (ConversationId, EntryId),
depth: usize,
visited: &mut HashSet<(ConversationId, EntryId)>,
gate: Gate<'_>,
) -> Vec<Response> {
if !visited.insert(dest) || !gate(dest) {
return Vec::new();
}
match entry_at(db, dest) {
Some(entry) if entry.is_group => collect_responses(db, dest, depth + 1, visited, gate),
Some(entry) => vec![response(db, dest, entry)],
None => Vec::new(),
}
}
fn response(
db: &DialogueDatabase,
(conversation, entry): (ConversationId, EntryId),
destination: &DialogueEntry,
) -> Response {
let text = if destination.menu_text.is_empty() {
&destination.dialogue_text
} else {
&destination.menu_text
};
Response {
conversation,
entry,
text: text.clone(),
is_player: actor_is_player(db, destination.actor),
}
}
fn actor_is_player(db: &DialogueDatabase, actor: ActorId) -> bool {
db.actors
.iter()
.find(|a| a.id == actor)
.is_some_and(|a| a.is_player)
}
pub fn step_from(db: &DialogueDatabase, at: (ConversationId, EntryId), gate: Gate<'_>) -> Step {
let responses = responses(db, at, gate);
if let Some(npc) = responses.iter().find(|r| !r.is_player) {
match subtitle_at(db, (npc.conversation, npc.entry)) {
Some(subtitle) => Step::Line(subtitle),
None => Step::End,
}
} else if !responses.is_empty() {
Step::Menu(responses)
} else {
Step::End
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{Actor, Link};
use rstest::{fixture, rstest};
#[fixture]
fn db() -> DialogueDatabase {
let entry = |id: i32, actor: i32, menu: &str, text: &str, group: bool, links: Vec<i32>| {
DialogueEntry {
id: EntryId(id),
actor: ActorId(actor),
conversant: ActorId(1 - actor),
menu_text: menu.to_owned(),
dialogue_text: text.to_owned(),
is_root: id == 1,
is_group: group,
links: links
.into_iter()
.map(|to| Link {
dest_conversation: ConversationId(1),
dest_entry: EntryId(to),
})
.collect(),
fields: vec![],
condition: String::new(),
script: String::new(),
sequence: String::new(),
}
};
DialogueDatabase {
version: "1".to_owned(),
variables: vec![],
actors: vec![
Actor {
id: ActorId(0),
name: "Player".to_owned(),
is_player: true,
fields: vec![],
},
Actor {
id: ActorId(1),
name: "Feri".to_owned(),
is_player: false,
fields: vec![],
},
],
conversations: vec![Conversation {
id: ConversationId(1),
title: "Test".to_owned(),
actor: ActorId(0),
conversant: ActorId(1),
entries: vec![
entry(1, 1, "", "", false, vec![2]),
entry(2, 1, "", "Hello", false, vec![3, 4]),
entry(3, 0, "Ask", "What is this?", false, vec![5]),
entry(4, 0, "Leave", "Bye", false, vec![]),
entry(5, 1, "", "", true, vec![6]),
entry(6, 1, "", "The dialogue system for Bevy", false, vec![2]),
],
fields: vec![],
}],
}
}
#[rstest]
fn finds_conversation_by_id_and_title(db: DialogueDatabase) {
assert!(find_conversation(&db, &ConversationRef::Id(ConversationId(1))).is_some());
assert!(find_conversation(&db, &ConversationRef::Title("Test".to_owned())).is_some());
assert!(find_conversation(&db, &ConversationRef::Title("Nope".to_owned())).is_none());
}
#[rstest]
fn start_skips_root_and_presents_first_npc_line(db: DialogueDatabase) {
let step = step_from(&db, (ConversationId(1), EntryId(1)), &mut |_| true);
let Step::Line(subtitle) = step else {
panic!("expected a line, got {step:?}");
};
assert_eq!(subtitle.text, "Hello");
assert_eq!(subtitle.actor, ActorId(1));
}
#[rstest]
fn player_responses_become_a_menu_with_menu_text_labels(db: DialogueDatabase) {
let step = step_from(&db, (ConversationId(1), EntryId(2)), &mut |_| true);
let Step::Menu(responses) = step else {
panic!("expected a menu, got {step:?}");
};
assert_eq!(responses.len(), 2);
assert_eq!(responses[0].text, "Ask");
assert_eq!(responses[1].text, "Leave");
assert!(responses.iter().all(|r| r.is_player));
}
#[rstest]
fn groups_are_flattened(db: DialogueDatabase) {
let step = step_from(&db, (ConversationId(1), EntryId(3)), &mut |_| true);
let Step::Line(subtitle) = step else {
panic!("expected a line, got {step:?}");
};
assert_eq!(subtitle.entry, EntryId(6));
}
#[rstest]
fn dead_end_ends_the_conversation(db: DialogueDatabase) {
assert_eq!(
step_from(&db, (ConversationId(1), EntryId(4)), &mut |_| true),
Step::End
);
}
#[rstest]
fn cycles_terminate(db: DialogueDatabase) {
let step = step_from(&db, (ConversationId(1), EntryId(6)), &mut |_| true);
assert!(matches!(step, Step::Line(_)));
}
#[rstest]
fn menu_label_falls_back_to_dialogue_text(mut db: DialogueDatabase) {
db.conversations[0].entries[2].menu_text.clear();
let Step::Menu(responses) = step_from(&db, (ConversationId(1), EntryId(2)), &mut |_| true)
else {
panic!("expected a menu");
};
assert_eq!(responses[0].text, "What is this?");
}
#[rstest]
fn gated_destinations_are_dropped(db: DialogueDatabase) {
let step = step_from(&db, (ConversationId(1), EntryId(2)), &mut |key| {
key.1 != EntryId(4)
});
let Step::Menu(responses) = step else {
panic!("expected a menu, got {step:?}");
};
assert_eq!(responses.len(), 1);
assert_eq!(responses[0].text, "Ask");
let step = step_from(&db, (ConversationId(1), EntryId(2)), &mut |key| {
key.1 != EntryId(3) && key.1 != EntryId(4)
});
assert_eq!(step, Step::End);
}
#[rstest]
fn gating_a_group_drops_its_subtree(db: DialogueDatabase) {
let step = step_from(&db, (ConversationId(1), EntryId(3)), &mut |key| {
key.1 != EntryId(5)
});
assert_eq!(step, Step::End);
}
}