use std::collections::HashMap;
use thiserror::Error;
use crate::{
builtins::lookup,
dialogue::{
Dialogue, DialogueNode, NodeName, Step, StepId, StepKind, Tag, TextPart, Value,
expr::{EvalError, Expr},
},
host::{HostError, NoHost, RepliqueHost},
};
const MAX_STEPS: usize = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Choice {
pub text: String,
pub tags: Vec<Tag>,
pub available: bool,
}
impl std::fmt::Display for Choice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
pub enum DialogueEvent {
Say {
speaker: Option<String>,
text: String,
tags: Vec<Tag>,
},
Command {
name: String,
args: Vec<Value>,
awaited: bool,
},
Choices {
choices: Vec<Choice>,
},
Finished,
}
pub enum ResumeEvent {
Advance,
Select(usize),
}
enum SuspendedAt {
Line {
next: StepId,
},
Command {
next: StepId,
},
Choice {
targets: Vec<(bool, StepId)>,
},
}
impl SuspendedAt {
fn resolve(&self, resume: &ResumeEvent) -> Result<StepId, VmError> {
match (self, resume) {
(Self::Line { next } | Self::Command { next }, ResumeEvent::Advance) => Ok(*next),
(Self::Choice { targets }, ResumeEvent::Select(i)) => match targets.get(*i).copied() {
None => Err(VmError::ChoiceOutOfRange {
index: *i,
count: targets.len(),
}),
Some((false, _)) => Err(VmError::ChoiceNotAvailable(*i)),
Some((_, step)) => Ok(step),
},
_ => Err(VmError::WrongResumeEvent),
}
}
}
#[derive(Clone)]
struct Cursor {
name: NodeName,
step: StepId,
}
pub(crate) struct EvalCtx<'a, H: RepliqueHost> {
pub vars: &'a VarStore,
pub host: &'a mut H,
}
impl<'a, H> EvalCtx<'a, H>
where
H: RepliqueHost,
{
pub fn call(&mut self, name: &str, args: Vec<Value>) -> Result<Value, HostError> {
if let Some(builtin) = lookup(name) {
return builtin.call(args).map_err(|e| HostError::BuiltinFailed {
name: name.into(),
message: e.to_string(),
});
}
self.host.call(name, args)
}
}
#[derive(Default)]
pub struct VarStore {
vars: HashMap<String, Value>,
}
impl VarStore {
pub fn new(vars: HashMap<String, Value>) -> Self {
Self { vars }
}
pub fn vars(&self) -> &HashMap<String, Value> {
&self.vars
}
pub fn insert(&mut self, name: String, val: Value) {
self.vars.insert(name, val);
}
pub fn get(&self, name: &str) -> Option<&Value> {
self.vars.get(name)
}
pub fn get_mut(&mut self, name: &str) -> Option<&mut Value> {
self.vars.get_mut(name)
}
}
#[derive(Error, Debug)]
pub enum VmError {
#[error("Dialogue not started")]
DialogueNotStarted,
#[error("Dialogue not finished")]
DialogueNotFinished,
#[error("Dialogue already finished")]
DialogueAlreadyFinished,
#[error("DialogueNode not found with name {0:?}")]
DialogueNodeNotFound(NodeName),
#[error("Choice is out of range: {index} > {count}")]
ChoiceOutOfRange { index: usize, count: usize },
#[error("Wrong ResumeEvent")]
WrongResumeEvent,
#[error("Step limit exceeded")]
StepLimitExceeded,
#[error("EvalError: {0}")]
ExprEvalError(#[from] EvalError),
#[error("No choice are available")]
NoAvailableChoice,
#[error("Selected choice {0} is not available.")]
ChoiceNotAvailable(usize),
}
#[derive(Default)]
enum VmState {
#[default]
NotStarted,
Suspended {
cursor: Cursor,
at: SuspendedAt,
},
Finished,
}
#[derive(Default)]
pub struct DialogueVm {
dialogue: Option<Dialogue>,
state: VmState,
vars: VarStore,
jump_stack: Vec<Cursor>,
}
impl DialogueVm {
pub fn start(
&mut self,
dialogue: Dialogue,
name: impl Into<NodeName>,
) -> Result<DialogueEvent, VmError> {
self.start_with(&mut NoHost, dialogue, name)
}
pub fn start_with<H: RepliqueHost>(
&mut self,
host: &mut H,
dialogue: Dialogue,
name: impl Into<NodeName>,
) -> Result<DialogueEvent, VmError> {
if !matches!(self.state, VmState::NotStarted | VmState::Finished) {
return Err(VmError::DialogueNotFinished);
}
self.dialogue = Some(dialogue);
let cursor = self.get_cursor_for_node(name.into())?;
self.run(host, cursor)
}
#[allow(unused)]
pub fn vars(&self) -> &VarStore {
&self.vars
}
pub fn resume(&mut self, resume: ResumeEvent) -> Result<DialogueEvent, VmError> {
self.resume_with(&mut NoHost, resume)
}
pub fn resume_with<H: RepliqueHost>(
&mut self,
host: &mut H,
resume: ResumeEvent,
) -> Result<DialogueEvent, VmError> {
let (mut cursor, next) = match &self.state {
VmState::Suspended { cursor, at } => (cursor.clone(), at.resolve(&resume)?),
VmState::NotStarted => return Err(VmError::DialogueNotStarted),
VmState::Finished => return Err(VmError::DialogueAlreadyFinished),
};
cursor.step = next;
self.run(host, cursor)
}
fn run<H: RepliqueHost>(
&mut self,
host: &mut H,
cursor: Cursor,
) -> Result<DialogueEvent, VmError> {
let jump_stack = self.jump_stack.clone();
let result = self.walk(host, cursor);
if result.is_err() {
self.jump_stack = jump_stack;
}
result
}
fn walk<H: RepliqueHost>(
&mut self,
host: &mut H,
mut cursor: Cursor,
) -> Result<DialogueEvent, VmError> {
for _ in 0..MAX_STEPS {
match self.get_step_at(&cursor)?.kind.clone() {
StepKind::Say { line, next } => {
let text = self.render_text(host, line.text)?;
self.state = VmState::Suspended {
cursor,
at: SuspendedAt::Line { next },
};
return Ok(DialogueEvent::Say {
speaker: line.speaker,
text,
tags: line.tags,
});
}
StepKind::Command { command, next } => {
let args = command
.args
.into_iter()
.map(|e| self.eval(host, e))
.collect::<Result<_, _>>()?;
self.state = VmState::Suspended {
cursor,
at: SuspendedAt::Command { next },
};
return Ok(DialogueEvent::Command {
name: command.name,
args,
awaited: command.awaited,
});
}
StepKind::Set {
name,
attrs,
value,
next,
} => {
let value = self.eval(host, value)?;
if attrs.is_empty() {
self.vars.insert(name, value);
} else {
self.set_attr(&name, &attrs, value)?;
}
cursor.step = next;
}
StepKind::Branch {
condition,
then,
otherwise,
} => {
cursor.step = match self.eval_condition(host, condition)? {
true => then,
false => otherwise,
};
}
StepKind::Choice { choices } => {
let choices = choices
.into_iter()
.map(|c| {
let available = match c.condition {
Some(e) => self.eval_condition(host, e)?,
None => true,
};
Ok((
c.target,
Choice {
text: self.render_text(host, c.text)?,
tags: c.tags,
available,
},
))
})
.collect::<Result<Vec<_>, VmError>>()?;
if !choices.iter().any(|(_, c)| c.available) {
return Err(VmError::NoAvailableChoice);
}
let (targets, choices): (Vec<_>, Vec<_>) = choices
.into_iter()
.map(|(step, c)| ((c.available, step), c))
.unzip();
self.state = VmState::Suspended {
cursor,
at: SuspendedAt::Choice { targets },
};
return Ok(DialogueEvent::Choices { choices });
}
StepKind::JumpBack { dest, next } => {
let back = Cursor {
name: cursor.name,
step: next,
};
cursor = self.get_cursor_for_node(dest)?;
self.jump_stack.push(back);
}
StepKind::Jump(name) => cursor = self.get_cursor_for_node(name)?,
StepKind::Return => match self.jump_stack.pop() {
Some(back) => cursor = back,
None => {
self.state = VmState::Finished;
return Ok(DialogueEvent::Finished);
}
},
StepKind::End => {
self.jump_stack.clear();
self.state = VmState::Finished;
return Ok(DialogueEvent::Finished);
}
}
}
Err(VmError::StepLimitExceeded)
}
fn dialogue(&self) -> Result<&Dialogue, VmError> {
self.dialogue.as_ref().ok_or(VmError::DialogueNotStarted)
}
fn get_cursor_for_node(&self, name: NodeName) -> Result<Cursor, VmError> {
let mut cursor = Cursor {
name,
step: StepId::default(),
};
let node = self.get_node_at(&cursor)?;
cursor.step = node.entry;
Ok(cursor)
}
fn get_node_at(&self, cursor: &Cursor) -> Result<&DialogueNode, VmError> {
self.dialogue()?
.get_node(&cursor.name)
.ok_or(VmError::DialogueNodeNotFound(cursor.name.clone()))
}
fn get_step_at(&self, cursor: &Cursor) -> Result<&Step, VmError> {
Ok(self.get_node_at(cursor)?.get_step(&cursor.step))
}
fn eval<H: RepliqueHost>(&self, host: &mut H, expr: Expr) -> Result<Value, EvalError> {
expr.eval(&mut EvalCtx {
vars: &self.vars,
host,
})
}
fn eval_condition<H: RepliqueHost>(&self, host: &mut H, expr: Expr) -> Result<bool, EvalError> {
match self.eval(host, expr)? {
Value::Bool(b) => Ok(b),
v => Err(EvalError::NotACondition(v.vtype().to_string())),
}
}
fn render_text<H: RepliqueHost>(
&self,
host: &mut H,
parts: Vec<TextPart>,
) -> Result<String, EvalError> {
let mut out = String::new();
for part in parts {
match part {
TextPart::Text(text) => out.push_str(&text),
TextPart::Expression(expr) => {
let val = self.eval(host, expr)?;
out.push_str(&val.to_string())
}
}
}
Ok(out)
}
fn set_attr(&mut self, name: &str, attrs: &[String], value: Value) -> Result<(), EvalError> {
let mut target = self
.vars
.get_mut(name)
.ok_or_else(|| EvalError::UnknownVariable(name.to_owned()))?;
for attr in attrs {
target = match target {
Value::Dict(map) => map
.get_mut(attr)
.ok_or_else(|| EvalError::DictHasNoAttr(attr.clone()))?,
other => {
return Err(EvalError::AttrExpectedDict {
name: attr.clone(),
received: other.vtype().to_string(),
});
}
};
}
*target = value;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dialogue::builder::DialogueNodeBuilder;
use crate::dialogue::{ChoiceDef, Command, TextLine, expr::Expr};
use crate::host::{HostError, test::TestHost};
use crate::parser::expr::BinaryOp;
fn lit(value: i64) -> Expr {
Expr::Litteral(Value::Int(value))
}
fn line(speaker: Option<&str>, text: &str, next: StepId) -> StepKind {
StepKind::Say {
line: TextLine {
speaker: speaker.map(String::from),
text: vec![TextPart::Text(text.into())],
tags: vec![],
},
next,
}
}
fn linear_dialogue() -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let l1 = b.push(line(None, "second", end));
let l0 = b.push(line(Some("Alice"), "first", l1));
Dialogue::new(vec![b.build(NodeName::new("start"), l0).unwrap()])
}
fn command_dialogue() -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let l1 = b.push(line(None, "after", end));
let cmd = b.push(StepKind::Command {
command: Command {
name: "play".into(),
args: vec![
Expr::Litteral(Value::String("bell".into())),
Expr::Litteral(Value::Float(0.5)),
],
awaited: false,
},
next: l1,
});
let l0 = b.push(line(None, "before", cmd));
Dialogue::new(vec![b.build(NodeName::new("start"), l0).unwrap()])
}
fn choice_dialogue() -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let left = b.push(line(None, "left", end));
let right = b.push(line(None, "right", end));
let c = b.push(StepKind::Choice {
choices: vec![
ChoiceDef {
text: vec![TextPart::Text("go left".into())],
target: left,
..Default::default()
},
ChoiceDef {
text: vec![TextPart::Text("go right".into())],
target: right,
..Default::default()
},
],
});
Dialogue::new(vec![b.build(NodeName::new("start"), c).unwrap()])
}
fn jump_dialogue(target: &str) -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let jump = b.push(StepKind::Jump(NodeName::new(target)));
let l0 = b.push(line(None, "before jump", jump));
let start = b.build(NodeName::new("start"), l0).unwrap();
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let l0 = b.push(line(None, "after jump", end));
let second = b.build(NodeName::new("second"), l0).unwrap();
Dialogue::new(vec![start, second])
}
fn expect_line(event: DialogueEvent) -> (Option<String>, String) {
match event {
DialogueEvent::Say { speaker, text, .. } => (speaker, text),
_ => panic!("expected a Line event"),
}
}
fn expect_choices(event: DialogueEvent) -> Vec<String> {
match event {
DialogueEvent::Choices { choices } => choices.into_iter().map(|c| c.text).collect(),
_ => panic!("expected a Choices event"),
}
}
fn expect_err(result: Result<DialogueEvent, VmError>) -> VmError {
match result {
Err(err) => err,
Ok(_) => panic!("expected an error"),
}
}
fn expect_finished(event: DialogueEvent) {
assert!(
matches!(event, DialogueEvent::Finished),
"expected a Finished event"
);
}
#[test]
fn start_emits_the_entry_line() {
let mut vm = DialogueVm::default();
let (speaker, text) = expect_line(vm.start(linear_dialogue(), "start").unwrap());
assert_eq!(speaker.as_deref(), Some("Alice"));
assert_eq!(text, "first");
}
#[test]
fn advance_walks_the_lines() {
let mut vm = DialogueVm::default();
vm.start(linear_dialogue(), "start").unwrap();
let (speaker, text) = expect_line(vm.resume(ResumeEvent::Advance).unwrap());
assert_eq!(speaker, None);
assert_eq!(text, "second");
expect_finished(vm.resume(ResumeEvent::Advance).unwrap());
}
#[test]
fn error_on_start_unknown_node() {
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(linear_dialogue(), "nope"));
assert!(matches!(err, VmError::DialogueNodeNotFound(n) if n == NodeName::new("nope")));
}
#[test]
fn error_on_resume_before_start() {
let mut vm = DialogueVm::default();
let err = expect_err(vm.resume(ResumeEvent::Advance));
assert!(matches!(err, VmError::DialogueNotStarted));
}
#[test]
fn error_on_resume_after_the_end() {
let mut vm = DialogueVm::default();
vm.start(linear_dialogue(), "start").unwrap();
vm.resume(ResumeEvent::Advance).unwrap();
expect_finished(vm.resume(ResumeEvent::Advance).unwrap());
let err = expect_err(vm.resume(ResumeEvent::Advance));
assert!(matches!(err, VmError::DialogueAlreadyFinished));
}
#[test]
fn error_on_start_while_suspended() {
let mut vm = DialogueVm::default();
vm.start(linear_dialogue(), "start").unwrap();
let err = expect_err(vm.start(linear_dialogue(), "start"));
assert!(matches!(err, VmError::DialogueNotFinished));
}
#[test]
fn start_again_after_the_end_is_allowed() {
let mut vm = DialogueVm::default();
vm.start(linear_dialogue(), "start").unwrap();
vm.resume(ResumeEvent::Advance).unwrap();
expect_finished(vm.resume(ResumeEvent::Advance).unwrap());
let (_, text) = expect_line(vm.start(linear_dialogue(), "start").unwrap());
assert_eq!(text, "first");
}
#[test]
fn a_command_suspends_the_dialogue_with_its_name_and_arguments() {
let mut vm = DialogueVm::default();
vm.start(command_dialogue(), "start").unwrap();
let event = vm.resume(ResumeEvent::Advance).unwrap();
let DialogueEvent::Command { name, args, .. } = event else {
panic!("expected a Command event");
};
assert_eq!(name, "play");
assert_eq!(args, vec![Value::String("bell".into()), Value::Float(0.5)]);
}
#[test]
fn advance_carries_on_after_a_command() {
let mut vm = DialogueVm::default();
vm.start(command_dialogue(), "start").unwrap();
vm.resume(ResumeEvent::Advance).unwrap();
let (_, text) = expect_line(vm.resume(ResumeEvent::Advance).unwrap());
assert_eq!(text, "after");
}
#[test]
fn error_on_select_on_a_command() {
let mut vm = DialogueVm::default();
vm.start(command_dialogue(), "start").unwrap();
vm.resume(ResumeEvent::Advance).unwrap();
let err = expect_err(vm.resume(ResumeEvent::Select(0)));
assert!(matches!(err, VmError::WrongResumeEvent));
}
#[test]
fn choice_emits_every_choice_text_in_order() {
let mut vm = DialogueVm::default();
let choices = expect_choices(vm.start(choice_dialogue(), "start").unwrap());
assert_eq!(choices, vec!["go left".to_string(), "go right".to_string()]);
}
#[test]
fn select_follows_the_matching_target() {
for (index, expected) in [(0, "left"), (1, "right")] {
let mut vm = DialogueVm::default();
vm.start(choice_dialogue(), "start").unwrap();
let (_, text) = expect_line(vm.resume(ResumeEvent::Select(index)).unwrap());
assert_eq!(text, expected);
}
}
#[test]
fn error_on_select_out_of_range_and_keeps_the_choice_pending() {
let mut vm = DialogueVm::default();
vm.start(choice_dialogue(), "start").unwrap();
let err = expect_err(vm.resume(ResumeEvent::Select(5)));
assert!(matches!(
err,
VmError::ChoiceOutOfRange { index: 5, count: 2 }
));
let (_, text) = expect_line(vm.resume(ResumeEvent::Select(0)).unwrap());
assert_eq!(text, "left");
}
#[test]
fn error_on_advance_on_a_choice() {
let mut vm = DialogueVm::default();
vm.start(choice_dialogue(), "start").unwrap();
let err = expect_err(vm.resume(ResumeEvent::Advance));
assert!(matches!(err, VmError::WrongResumeEvent));
}
#[test]
fn error_on_select_on_a_line() {
let mut vm = DialogueVm::default();
vm.start(linear_dialogue(), "start").unwrap();
let err = expect_err(vm.resume(ResumeEvent::Select(0)));
assert!(matches!(err, VmError::WrongResumeEvent));
}
fn set_dialogue(value: Expr) -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let cmd = b.push(StepKind::Command {
command: Command {
name: "show".into(),
args: vec![Expr::Var("gold".into())],
awaited: false,
},
next: end,
});
let set = b.push(StepKind::Set {
name: "gold".into(),
attrs: vec![],
value,
next: cmd,
});
Dialogue::new(vec![b.build(NodeName::new("start"), set).unwrap()])
}
fn expect_command(event: DialogueEvent) -> (String, Vec<Value>) {
match event {
DialogueEvent::Command { name, args, .. } => (name, args),
_ => panic!("expected a Command event"),
}
}
#[test]
fn an_assignment_is_invisible_and_gives_a_variable_its_value() {
let mut vm = DialogueVm::default();
let (name, args) = expect_command(vm.start(set_dialogue(lit(10)), "start").unwrap());
assert_eq!(name, "show");
assert_eq!(args, [Value::Int(10)]);
}
#[test]
fn an_assignment_reads_the_variables_written_before_it() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let cmd = b.push(StepKind::Command {
command: Command {
name: "show".into(),
args: vec![Expr::Var("gold".into())],
awaited: false,
},
next: end,
});
let second = b.push(StepKind::Set {
name: "gold".into(),
attrs: vec![],
value: Expr::Binary {
op: BinaryOp::Add,
lhs: Box::new(Expr::Var("gold".into())),
rhs: Box::new(lit(5)),
},
next: cmd,
});
let first = b.push(StepKind::Set {
name: "gold".into(),
attrs: vec![],
value: lit(10),
next: second,
});
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), first).unwrap()]);
let mut vm = DialogueVm::default();
let (_, args) = expect_command(vm.start(dialogue, "start").unwrap());
assert_eq!(args, [Value::Int(15)]);
}
fn set_attr_dialogue(attrs: &[&str], value: Expr) -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let write = b.push(StepKind::Set {
name: "player".into(),
attrs: attrs.iter().map(|a| (*a).to_string()).collect(),
value,
next: end,
});
let init = b.push(StepKind::Set {
name: "player".into(),
attrs: vec![],
value: Expr::LitteralDict(HashMap::from([(
"stats".to_owned(),
Expr::LitteralDict(HashMap::from([("hp".to_owned(), lit(10))])),
)])),
next: write,
});
Dialogue::new(vec![b.build(NodeName::new("start"), init).unwrap()])
}
fn dict(entries: &[(&str, Value)]) -> Value {
Value::Dict(
entries
.iter()
.map(|(key, val)| ((*key).to_owned(), val.clone()))
.collect(),
)
}
#[test]
fn an_assignment_writes_through_a_path_of_attributes() {
let mut vm = DialogueVm::default();
vm.start(set_attr_dialogue(&["stats", "hp"], lit(3)), "start")
.unwrap();
assert_eq!(
vm.vars().get("player"),
Some(&dict(&[("stats", dict(&[("hp", Value::Int(3))]))]))
);
}
#[test]
fn an_assignment_can_replace_a_whole_dict() {
let mut vm = DialogueVm::default();
vm.start(set_attr_dialogue(&["stats"], lit(0)), "start")
.unwrap();
assert_eq!(
vm.vars().get("player"),
Some(&dict(&[("stats", Value::Int(0))]))
);
}
#[test]
fn error_on_an_attribute_the_dict_does_not_hold() {
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(set_attr_dialogue(&["stats", "mp"], lit(3)), "start"));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::DictHasNoAttr(name)) if name == "mp"
));
}
#[test]
fn error_on_an_attribute_read_on_something_that_is_not_a_dict() {
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(
set_attr_dialogue(&["stats", "hp", "deeper"], lit(3)),
"start",
));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::AttrExpectedDict { name, .. }) if name == "deeper"
));
}
#[test]
fn error_on_an_attribute_written_on_a_variable_that_does_not_exist() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let write = b.push(StepKind::Set {
name: "unknown".into(),
attrs: vec!["hp".to_owned()],
value: lit(3),
next: end,
});
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), write).unwrap()]);
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(dialogue, "start"));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::UnknownVariable(name)) if name == "unknown"
));
}
#[test]
fn error_on_a_variable_that_was_never_assigned() {
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(set_dialogue(Expr::Var("unknown".into())), "start"));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::UnknownVariable(name)) if name == "unknown"
));
}
fn branch_dialogue(condition: Expr) -> Dialogue {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let yes = b.push(line(None, "oui", end));
let no = b.push(line(None, "non", end));
let branch = b.push(StepKind::Branch {
condition,
then: yes,
otherwise: no,
});
Dialogue::new(vec![b.build(NodeName::new("start"), branch).unwrap()])
}
#[test]
fn a_branch_takes_the_body_its_condition_points_at() {
for (condition, expected) in [(true, "oui"), (false, "non")] {
let mut vm = DialogueVm::default();
let dialogue = branch_dialogue(Expr::Litteral(Value::Bool(condition)));
let (_, text) = expect_line(vm.start(dialogue, "start").unwrap());
assert_eq!(text, expected, "condition {condition}");
}
}
#[test]
fn a_branch_reads_the_variables_written_before_it() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let yes = b.push(line(None, "riche", end));
let branch = b.push(StepKind::Branch {
condition: Expr::Binary {
op: BinaryOp::Gt,
lhs: Box::new(Expr::Var("gold".into())),
rhs: Box::new(lit(5)),
},
then: yes,
otherwise: end,
});
let set = b.push(StepKind::Set {
name: "gold".into(),
attrs: vec![],
value: lit(10),
next: branch,
});
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), set).unwrap()]);
let mut vm = DialogueVm::default();
let (_, text) = expect_line(vm.start(dialogue, "start").unwrap());
assert_eq!(text, "riche");
}
#[test]
fn error_on_a_condition_that_is_not_a_bool_at_run_time() {
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(branch_dialogue(lit(1)), "start"));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::NotACondition(vtype)) if vtype == "int"
));
}
#[test]
fn a_written_condition_runs() {
let src = ":= start\n\
[let $gold = 10]\n\
[if $gold > 5]\n\
\x20 Alice: J'ai plus de 5 pièces\n\
[else]\n\
\x20 Alice: J'ai pas d'argent\n\
---\n";
let file = crate::RepliqueFile::from_source(src);
assert_eq!(
file.diagnostics.errors(),
0,
"{}",
file.diagnostics
.render(src, crate::parser::diagnostic::Color::Never)
);
let mut vm = DialogueVm::default();
let (_, text) = expect_line(vm.start(file.dialogue.unwrap(), "start").unwrap());
assert_eq!(text, "J'ai plus de 5 pièces");
}
#[test]
fn jump_continues_in_the_target_node() {
let mut vm = DialogueVm::default();
vm.start(jump_dialogue("second"), "start").unwrap();
let (_, text) = expect_line(vm.resume(ResumeEvent::Advance).unwrap());
assert_eq!(text, "after jump");
expect_finished(vm.resume(ResumeEvent::Advance).unwrap());
}
#[test]
fn error_on_jump_to_unknown_node() {
let mut vm = DialogueVm::default();
vm.start(jump_dialogue("missing"), "start").unwrap();
let err = expect_err(vm.resume(ResumeEvent::Advance));
assert!(matches!(err, VmError::DialogueNodeNotFound(n) if n == NodeName::new("missing")));
}
#[test]
fn error_on_self_jumping_node() {
let mut b = DialogueNodeBuilder::default();
let jump = b.push(StepKind::Jump(NodeName::new("loop")));
let dialogue = Dialogue::new(vec![b.build(NodeName::new("loop"), jump).unwrap()]);
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(dialogue, "loop"));
assert!(matches!(err, VmError::StepLimitExceeded));
}
fn host_line(prefix: &str, expr: Expr, next: StepId) -> StepKind {
StepKind::Say {
line: TextLine {
speaker: None,
text: vec![TextPart::Text(prefix.into()), TextPart::Expression(expr)],
tags: vec![],
},
next,
}
}
fn call(name: &str, args: Vec<Expr>) -> Expr {
Expr::Function {
name: name.to_owned(),
args,
}
}
#[test]
fn an_inline_expression_asks_the_host_for_its_functions() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let l0 = b.push(host_line(
"Bonjour ",
call(
"upper_new",
vec![Expr::Litteral(Value::String("alice".into()))],
),
end,
));
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), l0).unwrap()]);
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let event = vm.start_with(&mut host, dialogue, "start").unwrap();
assert_eq!(expect_line(event).1, "Bonjour ALICE");
assert_eq!(host.called(), ["upper_new"]);
}
fn lines_with_host(src: &str) -> Result<Vec<String>, VmError> {
let file = crate::RepliqueFile::from_source(src);
let dialogue = file.dialogue.expect("the source compiles");
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let mut lines = vec![];
let mut event = vm.start_with(&mut host, dialogue, "start")?;
while !matches!(event, DialogueEvent::Finished) {
lines.push(expect_line(event).1);
event = vm.resume_with(&mut host, ResumeEvent::Advance)?;
}
Ok(lines)
}
#[test]
fn a_condition_asks_the_host_for_the_features_of_a_subject() {
let lines = lines_with_host(
":= start
[if Alice has happy]
Alice: Contente.
[if Bob has happy]
Bob: Content.
[elif Bob has sad]
Bob: Triste.
[if Alice has hungry and Bob has hungry]
Alice: On mange ?
[if not (Bob has angry)]
Bob: Calme.
---
",
);
assert_eq!(
lines.unwrap(),
["Contente.", "Triste.", "On mange ?", "Calme."]
);
}
#[test]
fn the_subject_and_the_feature_can_come_from_variables() {
let lines = lines_with_host(
":= start
[let $who = \"Bob\"]
[let $mood = \"thirsty\"]
[if $who has $mood]
Bob: Soif.
Bob: [$who has happy]
---
",
);
assert_eq!(lines.unwrap(), ["Soif.", "false"]);
}
#[test]
fn has_tells_a_dict_from_a_subject_at_run_time() {
let lines = lines_with_host(
":= start
[let $it = {sleepy: true}]
[if $it has sleepy]
Alice: Une clé.
[let $it = \"Alice\"]
[if $it has happy]
Alice: Une feature.
---
",
);
assert_eq!(lines.unwrap(), ["Une clé.", "Une feature."]);
}
#[test]
fn error_on_a_feature_the_host_does_not_know() {
let err =
lines_with_host(":= start\n[if Alice has sleepy]\n Alice: Zzz.\n---\n").unwrap_err();
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::HostError(HostError::UnknownFeature(name)))
if name == "sleepy"
));
}
#[test]
fn error_on_a_subject_the_host_does_not_know() {
let err = lines_with_host(":= start\n[if Caroline has happy]\n Caroline: Oui.\n---\n")
.unwrap_err();
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::HostError(HostError::UnknownSubject(name)))
if name == "Caroline"
));
}
fn said(src: &str) -> Vec<(Option<String>, String, Vec<Tag>)> {
let file = crate::RepliqueFile::from_source(src);
let mut vm = DialogueVm::default();
let mut lines = vec![];
let mut event = vm
.start(file.dialogue.expect("the source compiles"), "start")
.unwrap();
while let DialogueEvent::Say {
speaker,
text,
tags,
} = event
{
lines.push((speaker, text, tags));
event = vm.resume(ResumeEvent::Advance).unwrap();
}
lines
}
#[test]
fn a_line_reaches_the_host_with_its_tags() {
let lines = said(
":= start
Alice: #angry Je suis en colère #sound:alice_01
#mood:calm La porte se referme.
Alice: Plus rien à dire.
---
",
);
assert_eq!(
lines,
[
(
Some("Alice".to_owned()),
"Je suis en colère".to_owned(),
vec![Tag::new("angry"), Tag::with_value("sound", "alice_01")]
),
(
None,
"La porte se referme.".to_owned(),
vec![Tag::with_value("mood", "calm")]
),
(
Some("Alice".to_owned()),
"Plus rien à dire.".to_owned(),
vec![]
),
]
);
}
#[test]
fn the_tags_are_not_part_of_the_text() {
let lines = said(
":= start
[let $nom = \"Bob\"]
Alice: #a Bonjour [$nom] ! #b
Alice: #seul
Alice: \\#rien n'est un tag ici
---
",
);
let texts: Vec<_> = lines.iter().map(|(_, text, _)| text.as_str()).collect();
assert_eq!(texts, ["Bonjour Bob !", "", "#rien n'est un tag ici"]);
assert_eq!(lines[0].2, [Tag::new("a"), Tag::new("b")]);
assert_eq!(lines[1].2, [Tag::new("seul")]);
assert!(lines[2].2.is_empty());
}
#[test]
fn a_choice_reaches_the_host_with_its_tags() {
let file = crate::RepliqueFile::from_source(
":= start
[let $nom = \"Bob\"]
-> #hostile Attaquer [$nom] #cost:3
Alice: Aïe.
-> Partir
Alice: Au revoir. #fin
---
",
);
let mut vm = DialogueVm::default();
let DialogueEvent::Choices { choices } = vm
.start(file.dialogue.expect("the source compiles"), "start")
.unwrap()
else {
panic!("expected choices");
};
assert_eq!(
choices,
[
Choice {
text: "Attaquer Bob".to_owned(),
tags: vec![Tag::new("hostile"), Tag::with_value("cost", "3")],
available: true,
},
Choice {
text: "Partir".to_owned(),
tags: vec![],
available: true,
},
]
);
assert_eq!(choices[0].to_string(), "Attaquer Bob");
let DialogueEvent::Say { tags, .. } = vm.resume(ResumeEvent::Select(1)).unwrap() else {
panic!("expected a line");
};
assert_eq!(tags, [Tag::new("fin")]);
}
fn conditional_choices(
sick: bool,
tired: bool,
) -> (DialogueVm, Result<DialogueEvent, VmError>) {
let file = crate::RepliqueFile::from_source(format!(
":= start
[let $sick = {sick}]
[let $tired = {tired}]
-> Je suis malade [if $sick]
Alice: Soigne-toi.
-> Je suis fatigué #mood:low [if $tired]
Alice: Repose-toi.
---
"
));
let mut vm = DialogueVm::default();
let event = vm.start(file.dialogue.expect("the source compiles"), "start");
(vm, event)
}
#[test]
fn a_choice_is_available_when_its_condition_holds() {
let (_, event) = conditional_choices(false, true);
let DialogueEvent::Choices { choices } = event.unwrap() else {
panic!("expected a choice group");
};
assert_eq!(
choices,
[
Choice {
text: "Je suis malade".to_owned(),
tags: vec![],
available: false,
},
Choice {
text: "Je suis fatigué".to_owned(),
tags: vec![Tag::with_value("mood", "low")],
available: true,
},
]
);
}
#[test]
fn select_follows_an_available_choice() {
let (mut vm, event) = conditional_choices(false, true);
event.unwrap();
let (_, text) = expect_line(vm.resume(ResumeEvent::Select(1)).unwrap());
assert_eq!(text, "Repose-toi.");
}
#[test]
fn error_on_select_of_an_unavailable_choice_and_keeps_the_choice_pending() {
let (mut vm, event) = conditional_choices(false, true);
event.unwrap();
let err = expect_err(vm.resume(ResumeEvent::Select(0)));
assert!(matches!(err, VmError::ChoiceNotAvailable(0)));
let (_, text) = expect_line(vm.resume(ResumeEvent::Select(1)).unwrap());
assert_eq!(text, "Repose-toi.");
}
#[test]
fn error_when_no_choice_is_available() {
let (_, event) = conditional_choices(false, false);
assert!(matches!(expect_err(event), VmError::NoAvailableChoice));
}
#[test]
fn error_on_a_choice_condition_that_is_not_a_bool() {
let file = crate::RepliqueFile::from_source(
":= start
[let $n = 3]
-> Partir [if $n]
-> Rester
---
",
);
let mut vm = DialogueVm::default();
let err = expect_err(vm.start(file.dialogue.expect("the source compiles"), "start"));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::NotACondition(vtype)) if vtype == "int"
));
}
fn lines_of(src: &str) -> Vec<String> {
let file = crate::RepliqueFile::from_source(src);
let mut vm = DialogueVm::default();
let mut event = vm
.start(file.dialogue.expect("the source compiles"), "start")
.unwrap();
let mut lines = vec![];
loop {
event = match event {
DialogueEvent::Say { speaker, text, .. } => {
lines.push(format!("{}: {text}", speaker.unwrap_or_default()));
vm.resume(ResumeEvent::Advance).unwrap()
}
DialogueEvent::Choices { .. } => vm.resume(ResumeEvent::Select(0)).unwrap(),
DialogueEvent::Command { .. } => vm.resume(ResumeEvent::Advance).unwrap(),
DialogueEvent::Finished => break,
};
}
assert!(vm.jump_stack.is_empty(), "a finished run waits on no `<=>`");
lines
}
#[test]
fn a_jump_back_comes_back_once_the_node_is_over() {
let lines = lines_of(
":= start
Alice: Before.
<=> aside
Alice: After.
---
:= aside
Bob: Aside.
---
",
);
assert_eq!(lines, ["Alice: Before.", "Bob: Aside.", "Alice: After."]);
}
#[test]
fn a_jump_back_as_the_last_statement_ends_the_dialogue_after_its_node() {
let lines = lines_of(":= start\n<=> aside\n---\n\n:= aside\nBob: Aside.\n---\n");
assert_eq!(lines, ["Bob: Aside."]);
}
#[test]
fn jump_backs_nest() {
let lines = lines_of(
":= start
<=> outer
Alice: Back in start.
---
:= outer
Bob: Outer.
<=> inner
Bob: Back in outer.
---
:= inner
Caroline: Inner.
---
",
);
assert_eq!(
lines,
[
"Bob: Outer.",
"Caroline: Inner.",
"Bob: Back in outer.",
"Alice: Back in start.",
]
);
}
#[test]
fn a_jump_inside_a_jump_back_comes_back_from_where_it_leads() {
let lines = lines_of(
":= start
<=> aside
Alice: After.
---
:= aside
Bob: Aside.
=> further
Bob: Never said.
---
:= further
Caroline: Further.
---
",
);
assert_eq!(
lines,
["Bob: Aside.", "Caroline: Further.", "Alice: After."]
);
}
#[test]
fn end_inside_a_jump_back_ends_the_whole_dialogue() {
let lines = lines_of(
":= start
<=> aside
Alice: Never said.
---
:= aside
Bob: Aside.
=> END
---
",
);
assert_eq!(lines, ["Bob: Aside."]);
}
#[test]
fn a_jump_back_inside_a_block_comes_back_to_what_follows_the_block() {
let lines = lines_of(
":= start
-> Yes
<=> aside
Alice: Still in the choice.
-> No
Alice: Never said.
[let $n = 0]
[while $n < 2]
[let $n = $n + 1]
<=> aside
Alice: After.
---
:= aside
Bob: Aside.
---
",
);
assert_eq!(
lines,
[
"Bob: Aside.",
"Alice: Still in the choice.",
"Bob: Aside.",
"Bob: Aside.",
"Alice: After.",
]
);
}
#[test]
fn a_node_can_be_reached_both_ways() {
let lines = lines_of(
":= start
<=> aside
Alice: After.
=> aside
---
:= aside
Bob: Aside.
---
",
);
assert_eq!(lines, ["Bob: Aside.", "Alice: After.", "Bob: Aside."]);
}
#[test]
fn return_comes_back_before_the_end_of_the_node() {
let lines = lines_of(
":= start
<=> aside
Alice: After.
---
:= aside
-> Leave
Bob: Leaving.
=> RETURN
-> Stay
Bob: Never said.
Bob: Never said either.
---
",
);
assert_eq!(lines, ["Bob: Leaving.", "Alice: After."]);
}
#[test]
fn return_ends_the_dialogue_when_nothing_led_here() {
let lines = lines_of(":= start\nAlice: Hi.\n=> RETURN\nAlice: Never said.\n---\n");
assert_eq!(lines, ["Alice: Hi."]);
}
#[test]
fn a_node_can_jump_back_to_itself() {
let file = crate::RepliqueFile::from_source(
":= start
[let $n = 0]
<=> count
Alice: Done at [$n].
---
:= count
[let $n = $n + 1]
Alice: [$n]
[if $n < 100]
<=> count
---
",
);
let mut vm = DialogueVm::default();
let mut event = vm
.start(file.dialogue.expect("the source compiles"), "start")
.unwrap();
let mut last = String::new();
while let DialogueEvent::Say { text, .. } = event {
last = text;
event = vm.resume(ResumeEvent::Advance).unwrap();
}
assert_eq!(last, "Done at 100.");
assert!(vm.jump_stack.is_empty());
}
#[test]
fn a_jump_back_that_fails_is_forgotten() {
let file = crate::RepliqueFile::from_source(
":= start
Alice: Before.
<=> aside
---
:= aside
Bob: [$never_set]
---
",
);
let mut vm = DialogueVm::default();
vm.start(file.dialogue.expect("the source compiles"), "start")
.unwrap();
for _ in 0..3 {
let err = expect_err(vm.resume(ResumeEvent::Advance));
assert!(matches!(err, VmError::ExprEvalError(_)));
assert!(vm.jump_stack.is_empty());
}
}
#[test]
fn and_guards_what_follows_it() {
let lines = lines_of(
":= start
[let $stats = {strength: 3}]
[if $stats has magic and $stats.magic > 0]
Alice: Never said.
[if not ($stats has magic) or $stats.magic == 0]
Alice: No magic.
---
",
);
assert_eq!(lines, ["Alice: No magic."]);
}
#[test]
fn a_feature_is_read_off_its_subject() {
let file = crate::RepliqueFile::from_source(
":= start
[if Alice has mana and Alice.mana > 10]
Alice: I have [Alice.mana] mana and [Alice.stats.hp] hit points.
[if Bob has mana and Bob.mana > 10]
Bob: Never said.
[let $who = \"Alice\"]
Alice: Still [$who.mana].
Bob: [Bob.mana]
---
",
);
assert!(!file.has_errors());
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let (_, text) = expect_line(
vm.start_with(&mut host, file.dialogue.unwrap(), "start")
.unwrap(),
);
assert_eq!(text, "I have 12 mana and 7 hit points.");
let (_, text) = expect_line(vm.resume_with(&mut host, ResumeEvent::Advance).unwrap());
assert_eq!(text, "Still 12.");
let err = expect_err(vm.resume_with(&mut host, ResumeEvent::Advance));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::HostError(HostError::FeatureNotOnSubject { .. }))
));
}
#[test]
fn a_dialogue_without_a_function_never_calls_the_host() {
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
vm.start_with(&mut host, linear_dialogue(), "start")
.unwrap();
vm.resume_with(&mut host, ResumeEvent::Advance).unwrap();
assert_eq!(host.called(), [] as [&str; 0]);
}
#[test]
fn an_assignment_can_hold_what_the_host_answered() {
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let dialogue = set_dialogue(call("double", vec![lit(21)]));
let event = vm.start_with(&mut host, dialogue, "start").unwrap();
assert_eq!(expect_command(event).1, [Value::Int(42)]);
assert_eq!(vm.vars().get("gold"), Some(&Value::Int(42)));
}
#[test]
fn a_function_is_given_the_variables_the_dialogue_wrote() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let say = b.push(host_line(
"",
call("double", vec![Expr::Var("gold".into())]),
end,
));
let set = b.push(StepKind::Set {
name: "gold".into(),
attrs: vec![],
value: lit(4),
next: say,
});
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), set).unwrap()]);
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let event = vm.start_with(&mut host, dialogue, "start").unwrap();
assert_eq!(expect_line(event).1, "8");
assert_eq!(host.calls, [("double".to_owned(), vec![Value::Int(4)])]);
}
#[test]
fn a_branch_can_turn_on_what_the_host_answers() {
for (n, expected) in [(4, "oui"), (3, "non")] {
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let dialogue = branch_dialogue(call("is_even", vec![lit(n)]));
let event = vm.start_with(&mut host, dialogue, "start").unwrap();
assert_eq!(expect_line(event).1, expected, "is_even({n})");
}
}
#[test]
fn a_command_argument_can_come_from_the_host() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let cmd = b.push(StepKind::Command {
command: Command {
name: "show".into(),
args: vec![call("double", vec![lit(3)]), lit(1)],
awaited: false,
},
next: end,
});
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), cmd).unwrap()]);
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let (name, args) = expect_command(vm.start_with(&mut host, dialogue, "start").unwrap());
assert_eq!(name, "show");
assert_eq!(args, [Value::Int(6), Value::Int(1)]);
}
#[test]
fn a_host_that_refuses_a_call_leaves_the_vm_where_it_was() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let l1 = b.push(host_line(
"",
call(
"upper_new",
vec![Expr::Litteral(Value::String("alice".into()))],
),
end,
));
let l0 = b.push(line(None, "before", l1));
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), l0).unwrap()]);
let mut vm = DialogueVm::default();
assert_eq!(
expect_line(vm.start(dialogue, "start").unwrap()).1,
"before"
);
let err = expect_err(vm.resume(ResumeEvent::Advance));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::HostError(HostError::UnknownFunction(name)))
if name == "upper_new"
));
let mut host = TestHost::default();
let event = vm.resume_with(&mut host, ResumeEvent::Advance).unwrap();
assert_eq!(expect_line(event).1, "ALICE");
}
#[test]
fn error_on_a_function_the_host_fails_to_run() {
let mut b = DialogueNodeBuilder::default();
let end = b.push(StepKind::End);
let l0 = b.push(host_line("", call("boom", vec![]), end));
let dialogue = Dialogue::new(vec![b.build(NodeName::new("start"), l0).unwrap()]);
let mut vm = DialogueVm::default();
let mut host = TestHost::default();
let err = expect_err(vm.start_with(&mut host, dialogue, "start"));
assert!(matches!(
err,
VmError::ExprEvalError(EvalError::HostError(HostError::Failed { ref name, .. }))
if name == "boom"
));
}
}