use crate::context::Context; use crate::error::{BotError, DiceRollingError}; use crate::parser::dice::{Amount, Element, Operator}; use itertools::Itertools; use std::convert::TryFrom; use std::fmt; #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub enum DicePoolQuality { TenAgain, NineAgain, EightAgain, Rote, ChanceDie, NoExplode, } impl fmt::Display for DicePoolQuality { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { DicePoolQuality::TenAgain => write!(f, "ten-again"), DicePoolQuality::NineAgain => write!(f, "nine-again"), DicePoolQuality::EightAgain => write!(f, "eight-again"), DicePoolQuality::Rote => write!(f, "rote quality"), DicePoolQuality::ChanceDie => write!(f, "chance die"), DicePoolQuality::NoExplode => write!(f, "no roll-agains"), } } } #[derive(Debug, PartialEq, Eq, Clone, Copy)] pub struct DicePoolModifiers { pub(crate) success_on: i32, pub(crate) exceptional_on: i32, pub(crate) quality: DicePoolQuality, } impl DicePoolModifiers { pub fn default() -> DicePoolModifiers { DicePoolModifiers { success_on: 8, exceptional_on: 5, quality: DicePoolQuality::TenAgain, } } pub fn custom_quality(quality: DicePoolQuality) -> DicePoolModifiers { let success_on = if quality != DicePoolQuality::ChanceDie { 8 } else { 10 }; DicePoolModifiers { success_on: success_on, exceptional_on: 5, quality: quality, } } pub fn custom_exceptional_on(exceptional_on: i32) -> DicePoolModifiers { DicePoolModifiers { success_on: 8, exceptional_on: exceptional_on, quality: DicePoolQuality::TenAgain, } } pub fn custom(quality: DicePoolQuality, exceptional_on: i32) -> DicePoolModifiers { DicePoolModifiers { success_on: 8, exceptional_on: exceptional_on, quality: quality, } } } #[derive(Debug, PartialEq)] pub struct DicePool { pub(crate) amounts: Vec, pub(crate) sides: i32, pub(crate) modifiers: DicePoolModifiers, } impl DicePool { pub fn easy_pool(dice_amount: i32, quality: DicePoolQuality) -> DicePool { DicePool { amounts: vec![Amount { operator: Operator::Plus, element: Element::Number(dice_amount), }], sides: 10, modifiers: DicePoolModifiers::custom_quality(quality), } } pub fn easy_with_modifiers(dice_amount: i32, modifiers: DicePoolModifiers) -> DicePool { DicePool { amounts: vec![Amount { operator: Operator::Plus, element: Element::Number(dice_amount), }], sides: 10, modifiers: modifiers, } } pub fn new(amounts: Vec, modifiers: DicePoolModifiers) -> DicePool { DicePool { amounts: amounts, sides: 10, //TODO make configurable //TODO make configurable modifiers: modifiers, } } pub fn chance_die() -> DicePool { DicePool::easy_pool(1, DicePoolQuality::ChanceDie) } } ///The result of a successfully executed roll of a dice pool. Does not ///contain the heavy information of the DicePool instance. pub struct RolledDicePool { pub(crate) num_dice: i32, pub(crate) roll: DicePoolRoll, pub(crate) modifiers: DicePoolModifiers, } impl RolledDicePool { fn from(pool: &DicePool, num_dice: i32, rolls: Vec) -> RolledDicePool { RolledDicePool { modifiers: pool.modifiers, num_dice: num_dice, roll: DicePoolRoll { rolls: rolls, modifiers: pool.modifiers, }, } } } impl fmt::Display for RolledDicePool { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let dice_plural = if self.num_dice == 1 { "die" } else { "dice" }; write!( f, "{} {} ({}, exceptional on {} successes)", self.num_dice, dice_plural, self.modifiers.quality, self.modifiers.exceptional_on ) } } ///Store all rolls of the dice pool dice into one struct. #[derive(Debug, PartialEq, Eq, Clone)] pub struct DicePoolRoll { modifiers: DicePoolModifiers, rolls: Vec, } /// Amount of dice to display before cutting off and showing "and X /// more", so we don't spam the room with huge messages. const MAX_DISPLAYED_ROLLS: usize = 15; fn fmt_rolls(pool: &DicePoolRoll) -> String { let rolls = pool.rolls(); if rolls.len() > MAX_DISPLAYED_ROLLS { let shown_amount = rolls.into_iter().take(MAX_DISPLAYED_ROLLS).join(", "); format!( "{}, and {} more", shown_amount, rolls.len() - MAX_DISPLAYED_ROLLS ) } else { rolls.into_iter().join(", ") } } fn fmt_for_failure(pool: &DicePoolRoll) -> String { match pool.modifiers.quality { //There should only be 1 die in a chance die roll. DicePoolQuality::ChanceDie if pool.rolls().first() == Some(&1) => { String::from("dramatic failure!") } _ => String::from("failure!"), } } impl DicePoolRoll { pub fn rolls(&self) -> &[i32] { &self.rolls } pub fn successes(&self) -> i32 { let successes: usize = self .rolls .iter() .filter(|&roll| *roll >= self.modifiers.success_on) .count(); i32::try_from(successes).unwrap_or(0) } pub fn is_exceptional(&self) -> bool { self.successes() >= self.modifiers.exceptional_on } } /// Attach a Context to a dice pool. Needed for database access. pub struct DicePoolWithContext<'a>(pub &'a DicePool, pub &'a Context<'a>); impl fmt::Display for DicePoolRoll { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let successes = self.successes(); if successes > 0 { let success_msg = if self.is_exceptional() { format!("{} successes (exceptional!)", successes) } else { format!("{} successes", successes) }; write!(f, "{} ({})", success_msg, fmt_rolls(&self))?; } else { write!(f, "{} ({})", fmt_for_failure(&self), fmt_rolls(&self))?; } Ok(()) } } trait DieRoller { fn roll_number(&mut self, sides: i32) -> i32; } ///A version of DieRoller that uses a rand::Rng to roll numbers. struct RngDieRoller(R); impl DieRoller for RngDieRoller { fn roll_number(&mut self, sides: i32) -> i32 { self.0.gen_range(1..=sides) } } ///Roll a die in the pool, that "explodes" on a given number or higher. Dice will keep ///being rolled until the result is lower than the explode number, which is normally 10. ///Statistically speaking, usually one result will be returned from this function. fn roll_exploding_die( roller: &mut R, sides: i32, explode_on_or_higher: i32, ) -> Vec { let mut results = vec![]; loop { let roll = roller.roll_number(sides); results.push(roll); if roll < explode_on_or_higher { break; } } results } ///A die with the rote quality is re-rolled once if the roll fails. Otherwise, it obeys ///all normal rules (re-roll 10s). Re-rolled dice are appended to the result set, so we ///can keep track of the actual dice that were rolled. fn roll_rote_die(roller: &mut R, sides: i32, success_on: i32) -> Vec { let mut rolls = roll_exploding_die(roller, sides, 10); if rolls.len() == 1 && rolls[0] < success_on { rolls.append(&mut roll_exploding_die(roller, sides, 10)); } rolls } ///Roll a single die in the pool, potentially rolling additional dice depending on pool ///behavior. The default ten-again will "explode" the die if the result is 10 (repeatedly, if ///there are multiple 10s). Nine- and eight-again will explode similarly if the result is ///at least that number. Rote quality will re-roll a failure once, while also exploding ///on 10. The function returns a Vec of all rolled dice (usually 1). fn roll_die(roller: &mut R, pool: &DicePool) -> Vec { let mut results = vec![]; let sides = pool.sides; let success_on = pool.modifiers.success_on; match pool.modifiers.quality { DicePoolQuality::TenAgain => results.append(&mut roll_exploding_die(roller, sides, 10)), DicePoolQuality::NineAgain => results.append(&mut roll_exploding_die(roller, sides, 9)), DicePoolQuality::EightAgain => results.append(&mut roll_exploding_die(roller, sides, 8)), DicePoolQuality::Rote => results.append(&mut roll_rote_die(roller, sides, success_on)), DicePoolQuality::ChanceDie | DicePoolQuality::NoExplode => { results.push(roller.roll_number(sides)) } } results } fn roll_dice<'a, R: DieRoller>(pool: &DicePool, num_dice: i32, roller: &mut R) -> Vec { (0..num_dice) .flat_map(|_| roll_die(roller, &pool)) .collect() } ///Roll the dice in a dice pool, according to behavior documented in the various rolling ///methods. pub async fn roll_pool(pool: &DicePoolWithContext<'_>) -> Result { if pool.0.amounts.len() > 100 { return Err(DiceRollingError::ExpressionTooLarge.into()); } let num_dice = crate::logic::calculate_dice_amount(&pool.0.amounts, &pool.1).await?; let mut roller = RngDieRoller(rand::thread_rng()); if num_dice > 0 { let rolls = roll_dice(&pool.0, num_dice, &mut roller); Ok(RolledDicePool::from(&pool.0, num_dice, rolls)) } else { let chance_die = DicePool::chance_die(); let pool = DicePoolWithContext(&chance_die, &pool.1); let rolls = roll_dice(&pool.0, 1, &mut roller); Ok(RolledDicePool::from(&pool.0, 1, rolls)) } } #[cfg(test)] mod tests { use super::*; use crate::db::sqlite::Database; use crate::db::Variables; use url::Url; macro_rules! dummy_room { () => { crate::context::RoomContext { id: &matrix_sdk::ruma::room_id!("!fakeroomid:example.com"), display_name: "displayname".to_owned(), secure: false, } }; } ///Instead of being random, generate a series of numbers we have complete ///control over. struct SequentialDieRoller { results: Vec, position: usize, } impl SequentialDieRoller { fn new(results: Vec) -> SequentialDieRoller { SequentialDieRoller { results: results, position: 0, } } } impl DieRoller for SequentialDieRoller { fn roll_number(&mut self, _sides: i32) -> i32 { let roll = self.results[self.position]; self.position += 1; roll } } //Sanity checks #[test] pub fn chance_die_has_success_on_10_test() { assert_eq!(10, DicePool::chance_die().modifiers.success_on); } #[test] pub fn non_chance_die_has_success_on_8_test() { fn check_success_on(quality: DicePoolQuality) { let modifiers = DicePoolModifiers::custom_quality(quality); let amount = vec![Amount { operator: Operator::Plus, element: Element::Number(1), }]; assert_eq!(8, DicePool::new(amount, modifiers).modifiers.success_on); } check_success_on(DicePoolQuality::TenAgain); check_success_on(DicePoolQuality::NineAgain); check_success_on(DicePoolQuality::EightAgain); check_success_on(DicePoolQuality::Rote); check_success_on(DicePoolQuality::NoExplode); } //Dice rolling tests. #[test] pub fn ten_again_test() { let mut roller = SequentialDieRoller::new(vec![10, 8, 1]); let rolls = roll_exploding_die(&mut roller, 10, 10); assert_eq!(vec![10, 8], rolls); } #[test] pub fn nine_again_test() { let mut roller = SequentialDieRoller::new(vec![10, 9, 8, 1]); let rolls = roll_exploding_die(&mut roller, 10, 9); assert_eq!(vec![10, 9, 8], rolls); } #[test] pub fn eight_again_test() { let mut roller = SequentialDieRoller::new(vec![10, 9, 8, 8, 1]); let rolls = roll_exploding_die(&mut roller, 10, 8); assert_eq!(vec![10, 9, 8, 8, 1], rolls); } #[test] pub fn rote_quality_fail_then_succeed_test() { let mut roller = SequentialDieRoller::new(vec![5, 8, 1]); let rolls = roll_rote_die(&mut roller, 10, 8); assert_eq!(vec![5, 8], rolls); } #[test] pub fn rote_quality_fail_twice_test() { let mut roller = SequentialDieRoller::new(vec![5, 6, 10]); let rolls = roll_rote_die(&mut roller, 10, 8); assert_eq!(vec![5, 6], rolls); } #[test] pub fn rote_quality_fail_then_explode_test() { let mut roller = SequentialDieRoller::new(vec![5, 10, 8, 1]); let rolls = roll_rote_die(&mut roller, 10, 8); assert_eq!(vec![5, 10, 8], rolls); } #[test] pub fn rote_quality_obeys_success_on_test() { //With success_on = 8, should only roll once. let mut roller = SequentialDieRoller::new(vec![8, 7]); let rolls = roll_rote_die(&mut roller, 10, 8); assert_eq!(vec![8], rolls); //With success_on = 9, we should re-roll if it's an 8. roller = SequentialDieRoller::new(vec![8, 7]); let rolls = roll_rote_die(&mut roller, 10, 9); assert_eq!(vec![8, 7], rolls); } #[test] fn dice_pool_modifiers_chance_die_test() { let modifiers = DicePoolModifiers::custom_quality(DicePoolQuality::ChanceDie); assert_eq!(10, modifiers.success_on); } #[test] fn dice_pool_modifiers_default_sanity_check() { let modifiers = DicePoolModifiers::default(); assert_eq!(8, modifiers.success_on); assert_eq!(5, modifiers.exceptional_on); assert_eq!(DicePoolQuality::TenAgain, modifiers.quality); } #[test] pub fn no_explode_roll_test() { let pool = DicePool::easy_pool(1, DicePoolQuality::NoExplode); let mut roller = SequentialDieRoller::new(vec![10, 8]); let roll = roll_dice(&pool, 1, &mut roller); assert_eq!(vec![10], roll); } #[test] fn number_of_dice_equality_test() { let num_dice = 5; let rolls = vec![1, 2, 3, 4, 5]; let pool = DicePool::easy_pool(5, DicePoolQuality::TenAgain); let rolled_pool = RolledDicePool::from(&pool, num_dice, rolls); assert_eq!(5, rolled_pool.num_dice); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn rejects_large_expression_test() { let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); let homeserver = Url::parse("http://example.com").unwrap(); let db = Database::new(db_path.path().to_str().unwrap()) .await .unwrap(); let ctx = Context { account: crate::models::Account::default(), db: db, matrix_client: matrix_sdk::Client::new(homeserver).await.unwrap(), origin_room: dummy_room!(), active_room: dummy_room!(), username: "username", message_body: "message", }; let mut amounts = vec![]; for _ in 0..500 { amounts.push(Amount { operator: Operator::Plus, element: Element::Number(1), }); } let pool = DicePool::new(amounts, DicePoolModifiers::default()); let pool_with_ctx = DicePoolWithContext(&pool, &ctx); let result = roll_pool(&pool_with_ctx).await; assert!(matches!( result, Err(BotError::DiceRollingError( DiceRollingError::ExpressionTooLarge )) )); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn converts_to_chance_die_test() { let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) .await .unwrap(); let db = Database::new(db_path.path().to_str().unwrap()) .await .unwrap(); let homeserver = Url::parse("http://example.com").unwrap(); let ctx = Context { account: crate::models::Account::default(), db: db, matrix_client: matrix_sdk::Client::new(homeserver).await.unwrap(), origin_room: dummy_room!(), active_room: dummy_room!(), username: "username", message_body: "message", }; let mut amounts = vec![]; amounts.push(Amount { operator: Operator::Plus, element: Element::Number(-1), }); let pool = DicePool::new(amounts, DicePoolModifiers::default()); let pool_with_ctx = DicePoolWithContext(&pool, &ctx); let result = roll_pool(&pool_with_ctx).await; assert!(result.is_ok()); let roll = result.unwrap(); assert_eq!(DicePoolQuality::ChanceDie, roll.modifiers.quality); assert_eq!(1, roll.num_dice); } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn can_resolve_variables_test() { let db_path = tempfile::NamedTempFile::new_in(".").unwrap(); crate::db::sqlite::migrator::migrate(db_path.path().to_str().unwrap()) .await .unwrap(); let db = Database::new(db_path.path().to_str().unwrap()) .await .unwrap(); let homeserver = Url::parse("http://example.com").unwrap(); let ctx = Context { account: crate::models::Account::default(), db: db.clone(), matrix_client: matrix_sdk::Client::new(homeserver).await.unwrap(), origin_room: dummy_room!(), active_room: dummy_room!(), username: "username", message_body: "message", }; db.set_user_variable( &ctx.username, &ctx.origin_room.id.as_str(), "myvariable", 10, ) .await .expect("could not set myvariable to 10"); let amounts = vec![Amount { operator: Operator::Plus, element: Element::Variable("myvariable".to_owned()), }]; let pool = DicePool::new(amounts, DicePoolModifiers::default()); assert_eq!( crate::logic::calculate_dice_amount(&pool.amounts, &ctx) .await .unwrap(), 10 ); } //DicePool tests #[test] fn easy_pool_chance_die_test() { let pool = DicePool::easy_pool(1, DicePoolQuality::ChanceDie); assert_eq!(10, pool.modifiers.success_on); } #[test] fn easy_pool_quality_test() { fn check_quality(quality: DicePoolQuality) { let pool = DicePool::easy_pool(1, quality); assert_eq!(quality, pool.modifiers.quality); } check_quality(DicePoolQuality::TenAgain); check_quality(DicePoolQuality::NineAgain); check_quality(DicePoolQuality::EightAgain); check_quality(DicePoolQuality::Rote); check_quality(DicePoolQuality::ChanceDie); check_quality(DicePoolQuality::NoExplode); } #[test] fn is_successful_on_equal_test() { let result = DicePoolRoll { rolls: vec![8], modifiers: DicePoolModifiers { exceptional_on: 5, success_on: 8, quality: DicePoolQuality::TenAgain, }, }; assert_eq!(1, result.successes()); } #[test] fn chance_die_success_test() { let result = DicePoolRoll { rolls: vec![10], modifiers: DicePoolModifiers { exceptional_on: 5, success_on: 10, quality: DicePoolQuality::ChanceDie, }, }; assert_eq!(1, result.successes()); } #[test] fn chance_die_fail_test() { let result = DicePoolRoll { rolls: vec![9], modifiers: DicePoolModifiers { exceptional_on: 5, success_on: 10, quality: DicePoolQuality::ChanceDie, }, }; assert_eq!(0, result.successes()); } #[test] fn is_exceptional_test() { let result = DicePoolRoll { rolls: vec![8, 8, 9, 10, 8], modifiers: DicePoolModifiers { exceptional_on: 5, success_on: 8, quality: DicePoolQuality::TenAgain, }, }; assert_eq!(5, result.successes()); assert_eq!(true, result.is_exceptional()); } #[test] fn is_not_exceptional_test() { let result = DicePoolRoll { rolls: vec![8, 8, 9, 10], modifiers: DicePoolModifiers::default(), }; assert_eq!(4, result.successes()); assert_eq!(false, result.is_exceptional()); } //Format tests #[test] fn formats_rolled_dice_pool_single_die() { let pool = DicePool::easy_pool(5, DicePoolQuality::TenAgain); let rolled_pool = RolledDicePool::from(&pool, 1, vec![1]); let message = format!("{}", rolled_pool); assert!(message.starts_with("1 die")); } #[test] fn formats_rolled_dice_pool_multiple_dice() { let pool = DicePool::easy_pool(5, DicePoolQuality::TenAgain); let rolled_pool = RolledDicePool::from(&pool, 2, vec![1, 2]); let message = format!("{}", rolled_pool); assert!(message.starts_with("2 dice")); } #[test] fn formats_rolled_dice_pool_zero_dice() { let pool = DicePool::easy_pool(5, DicePoolQuality::TenAgain); let rolled_pool = RolledDicePool::from(&pool, 0, vec![]); let message = format!("{}", rolled_pool); assert!(message.starts_with("0 dice")); } #[test] fn formats_dramatic_failure_test() { let result = DicePoolRoll { rolls: vec![1], modifiers: DicePoolModifiers::custom_quality(DicePoolQuality::ChanceDie), }; assert_eq!("dramatic failure!", fmt_for_failure(&result)); } #[test] fn formats_regular_failure_when_not_chance_die_test() { let result = DicePoolRoll { rolls: vec![1], modifiers: DicePoolModifiers { quality: DicePoolQuality::TenAgain, exceptional_on: 5, success_on: 10, }, }; assert_eq!("failure!", fmt_for_failure(&result)); } #[test] fn formats_lots_of_dice_test() { let result = DicePoolRoll { modifiers: DicePoolModifiers::default(), rolls: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9], }; assert_eq!( "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, and 4 more", fmt_rolls(&result) ); } #[test] fn shows_more_than_10_dice_test() { //Make sure we display more than 10 dice when below the display limit (15). let result = DicePoolRoll { modifiers: DicePoolModifiers::default(), rolls: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], }; assert_eq!( "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14", fmt_rolls(&result) ); } #[test] fn shows_exactly_15_dice_test() { //If we are at format limit (15), make sure all are shown let result = DicePoolRoll { modifiers: DicePoolModifiers::default(), rolls: vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; assert_eq!( "1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15", fmt_rolls(&result) ); } }