diff --git a/Cargo.lock b/Cargo.lock index fcc811e..b6cc4f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,11 +22,13 @@ checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" name = "axfive-matrix-dicebot" version = "0.1.2" dependencies = [ + "itertools", "nom", "rand", "reqwest", "serde", "serde_json", + "thiserror", "tokio", "toml", ] @@ -89,6 +91,12 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "134951f4028bdadb9b84baf4232681efbf277da25144b9b0ad65df75946c422b" +[[package]] +name = "either" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56b59865bce947ac5958779cfa508f6c3b9497cc762b7e24a12d11ccde2c4f" + [[package]] name = "encoding_rs" version = "0.8.23" @@ -313,6 +321,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47be2f14c678be2fdcab04ab1171db51b2762ce6f0a8ee87c8dd4a04ed216135" +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.6" @@ -815,6 +832,26 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "thiserror" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfdd070ccd8ccb78f4ad66bf1982dc37f620ef696c6b5028fe2ed83dd3d0d08" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd80fc12f73063ac132ac92aceea36734f04a1d93c1240c6944e23a3b8841793" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "time" version = "0.1.43" diff --git a/Cargo.toml b/Cargo.toml index 5f8f93c..0cd3e0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,8 @@ serde_json = "1" toml = "0.5" nom = "5" rand = "0.7" +thiserror = "1.0" +itertools = "0.9" [dependencies.serde] version = "1" diff --git a/src/cofd.rs b/src/cofd.rs new file mode 100644 index 0000000..aa81ba1 --- /dev/null +++ b/src/cofd.rs @@ -0,0 +1,315 @@ +use crate::roll::{Roll, Rolled}; +use itertools::Itertools; +use std::convert::TryFrom; +use std::fmt; + +pub mod parser; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum DicePoolQuality { + TenAgain, + NineAgain, + EightAgain, + Rote, + ChanceDie, +} + +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"), + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DicePool { + pub(crate) count: u32, + pub(crate) sides: u32, + pub(crate) success_on: u32, + pub(crate) exceptional_success: u32, + pub(crate) quality: DicePoolQuality, +} + +impl fmt::Display for DicePool { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} dice ({}, exceptional on {} successes)", + self.count, self.quality, self.exceptional_success + ) + } +} + +impl DicePool { + fn new(count: u32, successes_for_exceptional: u32, quality: DicePoolQuality) -> DicePool { + DicePool { + count: count, + sides: 10, //TODO make configurable + success_on: 8, //TODO make configurable + exceptional_success: successes_for_exceptional, + quality: quality, + } + } + + pub fn chance_die() -> DicePool { + DicePool { + count: 1, + sides: 10, + success_on: 10, + exceptional_success: 5, + quality: DicePoolQuality::ChanceDie, + } + } +} + +///Store all rolls of the dice pool dice into one struct. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct DicePoolRoll { + success_on: u32, + exceptional_on: u32, + rolls: Vec, +} + +impl DicePoolRoll { + pub fn rolls(&self) -> &[u32] { + &self.rolls + } + + pub fn successes(&self) -> i32 { + let successes = self + .rolls + .iter() + .cloned() + .filter(|&roll| roll >= self.success_on) + .count(); + i32::try_from(successes).unwrap_or(0) + } + + pub fn is_exceptional(&self) -> bool { + self.successes() >= (self.exceptional_on as i32) + } +} + +impl Roll for DicePool { + type Output = DicePoolRoll; + + fn roll(&self) -> DicePoolRoll { + roll_dice(self) + } +} + +impl Rolled for DicePoolRoll { + fn rolled_value(&self) -> i32 { + self.successes() + } +} + +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, self.rolls().iter().join(", "))?; + } else { + write!(f, "failure! ({})", self.rolls().iter().join(", "))?; + } + + Ok(()) + } +} + +trait DieRoller { + fn roll_number(&mut self, sides: u32) -> u32; +} + +///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: u32) -> u32 { + self.0.gen_range(1, sides + 1) + } +} + +///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: u32, + explode_on_or_higher: u32, +) -> 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: u32) -> Vec { + let mut rolls = roll_exploding_die(roller, sides, 10); + if rolls.len() == 1 && rolls[0] < 8 { + 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, sides: u32, quality: DicePoolQuality) -> Vec { + let mut results = vec![]; + match 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)), + DicePoolQuality::ChanceDie => results.push(roller.roll_number(sides)), + } + + results +} + +///Roll the dice in a dice pool, according to behavior documented in the various rolling +///methods. +fn roll_dice(pool: &DicePool) -> DicePoolRoll { + let mut roller = RngDieRoller(rand::thread_rng()); + + let rolls: Vec = (0..pool.count) + .flat_map(|_| roll_die(&mut roller, pool.sides, pool.quality)) + .collect(); + + DicePoolRoll { + rolls: rolls, + exceptional_on: pool.exceptional_success, + success_on: pool.success_on, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + ///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: u32) -> u32 { + let roll = self.results[self.position]; + self.position += 1; + roll + } + } + + //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); + 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); + 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); + assert_eq!(vec![5, 10, 8], rolls); + } + + //DiceRoll tests + #[test] + fn is_successful_on_equal_test() { + let result = DicePoolRoll { + rolls: vec![8], + exceptional_on: 5, + success_on: 8, + }; + + assert_eq!(1, result.successes()); + } + + #[test] + fn is_exceptional_test() { + let result = DicePoolRoll { + rolls: vec![8, 8, 9, 10, 8], + exceptional_on: 5, + success_on: 8, + }; + + 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], + exceptional_on: 5, + success_on: 8, + }; + + assert_eq!(4, result.successes()); + assert_eq!(false, result.is_exceptional()); + } +} diff --git a/src/cofd/parser.rs b/src/cofd/parser.rs new file mode 100644 index 0000000..71ca0da --- /dev/null +++ b/src/cofd/parser.rs @@ -0,0 +1,232 @@ +use nom::{ + alt, bytes::complete::tag, character::complete::digit1, complete, many0, named, + sequence::tuple, tag, IResult, +}; + +use crate::cofd::{DicePool, DicePoolQuality}; +use crate::parser::eat_whitespace; + +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum DicePoolElement { + NumberOfDice(u32), + SuccessesForExceptional(u32), + DicePoolQuality(DicePoolQuality), +} + +// Parse a single digit expression. Does not eat whitespace +fn parse_digit(input: &str) -> IResult<&str, u32> { + let (input, num) = digit1(input)?; + Ok((input, num.parse().unwrap())) +} + +fn parse_quality(input: &str) -> IResult<&str, DicePoolQuality> { + let (input, _) = eat_whitespace(input)?; + named!(quality(&str) -> DicePoolQuality, alt!( + complete!(tag!("n")) => { |_| DicePoolQuality::NineAgain } | + complete!(tag!("e")) => { |_| DicePoolQuality::EightAgain } | + complete!(tag!("r")) => { |_| DicePoolQuality::Rote } + )); + + let (input, dice_pool_quality) = quality(input)?; + Ok((input, dice_pool_quality)) +} + +fn parse_exceptional_requirement(input: &str) -> IResult<&str, u32> { + let (input, _) = eat_whitespace(input)?; + let (input, (_, successes)) = tuple((tag("s"), digit1))(input)?; + Ok((input, successes.parse().unwrap())) +} + +// Parse a dice pool element expression. Eats whitespace. +fn parse_dice_pool_element(input: &str) -> IResult<&str, DicePoolElement> { + let (input, _) = eat_whitespace(input)?; + named!(element(&str) -> DicePoolElement, alt!( + parse_digit => { |num| DicePoolElement::NumberOfDice(num) } | + parse_quality => { |qual| DicePoolElement::DicePoolQuality(qual) } | + parse_exceptional_requirement => { |succ| DicePoolElement::SuccessesForExceptional(succ) } + )); + + let (input, element) = element(input)?; + Ok((input, element)) +} + +fn find_elements(elements: Vec) -> (Option, DicePoolQuality, u32) { + let mut found_quality: Option = None; + let mut found_count: Option = None; + let mut found_successes_required: Option = None; + + for element in elements { + if found_quality.is_some() && found_count.is_some() && found_successes_required.is_some() { + break; + } + + match element { + DicePoolElement::NumberOfDice(found) => { + if found_count.is_none() { + found_count = Some(found); + } + } + DicePoolElement::DicePoolQuality(found) => { + if found_quality.is_none() { + found_quality = Some(found); + } + } + DicePoolElement::SuccessesForExceptional(found) => { + if found_successes_required.is_none() { + found_successes_required = Some(found); + } + } + }; + } + + let quality: DicePoolQuality = found_quality.unwrap_or(DicePoolQuality::TenAgain); + let successes_for_exceptional: u32 = found_successes_required.unwrap_or(5); + (found_count, quality, successes_for_exceptional) +} + +fn convert_to_dice_pool(input: &str, elements: Vec) -> IResult<&str, DicePool> { + let (count, quality, successes_for_exceptional) = find_elements(elements); + + if count.is_some() { + Ok(( + input, + DicePool::new(count.unwrap(), successes_for_exceptional, quality), + )) + } else { + use nom::error::ErrorKind; + use nom::Err; + Err(Err::Error((input, ErrorKind::Alt))) + } +} + +pub fn parse_dice_pool(input: &str) -> IResult<&str, DicePool> { + named!(first_element(&str) -> DicePoolElement, alt!( + parse_dice_pool_element => { |e| e } + )); + let (input, first) = first_element(input)?; + let (input, elements) = if input.trim().is_empty() { + (input, vec![first]) + } else { + named!(rest_elements(&str) -> Vec, many0!(parse_dice_pool_element)); + let (input, mut rest) = rest_elements(input)?; + rest.insert(0, first); + (input, rest) + }; + + convert_to_dice_pool(input, elements) +} + +pub fn create_chance_die() -> IResult<&'static str, DicePool> { + Ok(("", DicePool::chance_die())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_digit_test() { + use nom::error::ErrorKind; + use nom::Err; + assert_eq!(parse_digit("1"), Ok(("", 1))); + assert_eq!(parse_digit("10"), Ok(("", 10))); + assert_eq!( + parse_digit("adsf"), + Err(Err::Error(("adsf", ErrorKind::Digit))) + ); + } + + #[test] + fn quality_test() { + use nom::error::ErrorKind; + use nom::Err; + assert_eq!(parse_quality("n"), Ok(("", DicePoolQuality::NineAgain))); + assert_eq!(parse_quality("e"), Ok(("", DicePoolQuality::EightAgain))); + assert_eq!(parse_quality("r"), Ok(("", DicePoolQuality::Rote))); + assert_eq!(parse_quality("b"), Err(Err::Error(("b", ErrorKind::Alt)))); + } + + #[test] + fn multiple_quality_test() { + assert_eq!(parse_quality("ner"), Ok(("er", DicePoolQuality::NineAgain))); + } + + #[test] + fn exceptional_success_test() { + use nom::error::ErrorKind; + use nom::Err; + assert_eq!(parse_exceptional_requirement("s3"), Ok(("", 3))); + assert_eq!(parse_exceptional_requirement("s10"), Ok(("", 10))); + assert_eq!(parse_exceptional_requirement("s20b"), Ok(("b", 20))); + assert_eq!( + parse_exceptional_requirement("sab10"), + Err(Err::Error(("ab10", ErrorKind::Digit))) + ); + } + + #[test] + fn dice_pool_element_expression_test() { + use nom::error::ErrorKind; + use nom::Err; + + assert_eq!( + parse_dice_pool_element("8"), + Ok(("", DicePoolElement::NumberOfDice(8))) + ); + + assert_eq!( + parse_dice_pool_element("n"), + Ok(( + "", + DicePoolElement::DicePoolQuality(DicePoolQuality::NineAgain) + )) + ); + + assert_eq!( + parse_dice_pool_element("s3"), + Ok(("", DicePoolElement::SuccessesForExceptional(3))) + ); + + assert_eq!( + parse_dice_pool_element("8ns3"), + Ok(("ns3", DicePoolElement::NumberOfDice(8))) + ); + + assert_eq!( + parse_dice_pool_element("totallynotvalid"), + Err(Err::Error(("totallynotvalid", ErrorKind::Alt))) + ); + } + + #[test] + fn dice_pool_number_only_test() { + assert_eq!( + parse_dice_pool("8"), + Ok(("", DicePool::new(8, 5, DicePoolQuality::TenAgain))) + ); + } + + #[test] + fn dice_pool_number_with_quality() { + assert_eq!( + parse_dice_pool("8n"), + Ok(("", DicePool::new(8, 5, DicePoolQuality::NineAgain))) + ); + } + + #[test] + fn dice_pool_number_with_success_change() { + assert_eq!( + parse_dice_pool("8s3"), + Ok(("", DicePool::new(8, 3, DicePoolQuality::TenAgain))) + ); + } + + #[test] + fn dice_pool_with_quality_and_success_change() { + assert_eq!( + parse_dice_pool("8rs3"), + Ok(("", DicePool::new(8, 3, DicePoolQuality::Rote))) + ); + } +} diff --git a/src/commands.rs b/src/commands.rs index 557dff5..fd8a8f2 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,3 +1,4 @@ +use crate::cofd::DicePool; use crate::dice::ElementExpression; use crate::roll::Roll; @@ -18,13 +19,18 @@ impl Execution { } } -pub struct RollCommand(ElementExpression); - pub trait Command { fn execute(&self) -> Execution; + fn name(&self) -> &'static str; } +pub struct RollCommand(ElementExpression); + impl Command for RollCommand { + fn name(&self) -> &'static str { + "roll regular dice" + } + fn execute(&self) -> Execution { let roll = self.0.roll(); let plain = format!("Dice: {}\nResult: {}", self.0, roll); @@ -36,13 +42,55 @@ impl Command for RollCommand { } } +pub struct PoolRollCommand(DicePool); + +impl Command for PoolRollCommand { + fn name(&self) -> &'static str { + "roll dice pool" + } + + fn execute(&self) -> Execution { + let roll = self.0.roll(); + let plain = format!("Pool: {}\nResult: {}", self.0, roll); + let html = format!( + "

Pool: {}

Result: {}

", + self.0, roll + ); + Execution { plain, html } + } +} + /// Parse a command string into a dynamic command execution trait object. /// Returns an error if a command was recognized but not parsed correctly. Returns None if no /// command was recognized. pub fn parse_command(s: &str) -> Result>, String> { - // Ignore trailing input, if any. match parser::parse_command(s) { - Ok((_, result)) => Ok(result), + //The first clause prevents bot from spamming messages to itself + //after executing a previous command. + Ok((input, result)) => match (input, &result) { + ("", Some(_)) | (_, None) => Ok(result), + _ => Err(format!("{}: malformed dice expression", s)), + }, Err(err) => Err(err.to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chance_die_is_not_malformed() { + assert!(parse_command("!chance").is_ok()); + } + + #[test] + fn roll_malformed_expression_test() { + assert!(parse_command("!roll 1d20asdlfkj").is_err()); + } + + #[test] + fn roll_dice_pool_expression_test() { + assert!(parse_command("!pool 8abc").is_err()); + } +} diff --git a/src/commands/parser.rs b/src/commands/parser.rs index 84cf416..cf709da 100644 --- a/src/commands/parser.rs +++ b/src/commands/parser.rs @@ -1,6 +1,7 @@ -use nom::{complete, named, tag, take_while, tuple, IResult}; +use nom::{alt, complete, named, tag, take_while, tuple, IResult}; -use crate::commands::{Command, RollCommand}; +use crate::cofd::parser::{create_chance_die, parse_dice_pool}; +use crate::commands::{Command, PoolRollCommand, RollCommand}; use crate::dice::parser::parse_element_expression; use crate::parser::eat_whitespace; @@ -11,18 +12,41 @@ fn parse_roll(input: &str) -> IResult<&str, Box> { Ok((input, Box::new(RollCommand(expression)))) } +fn parse_pool_roll(input: &str) -> IResult<&str, Box> { + let (input, _) = eat_whitespace(input)?; + let (input, pool) = parse_dice_pool(input)?; + Ok((input, Box::new(PoolRollCommand(pool)))) +} + +fn chance_die() -> IResult<&'static str, Box> { + let (input, pool) = create_chance_die()?; + Ok((input, Box::new(PoolRollCommand(pool)))) +} + /// Potentially parse a command expression. If we recognize the command, an error should be raised /// if the command is misparsed. If we don't recognize the command, ignore it and return none pub fn parse_command(original_input: &str) -> IResult<&str, Option>> { let (input, _) = eat_whitespace(original_input)?; - named!(command(&str) -> (&str, &str), tuple!(complete!(tag!("!")), complete!(take_while!(char::is_alphabetic)))); + + //Parser understands either specific !commands with no input, or any !command with extra input. + named!(command(&str) -> (&str, &str), tuple!( + complete!(tag!("!")), + alt!( + complete!(tag!("chance")) | //TODO figure out how to just have it read single commands. + complete!(take_while!(char::is_alphabetic)) + ) + )); + let (input, command) = match command(input) { // Strip the exclamation mark Ok((input, (_, result))) => (input, result), Err(_e) => return Ok((original_input, None)), }; + match command { "r" | "roll" => parse_roll(input).map(|(input, command)| (input, Some(command))), + "rp" | "pool" => parse_pool_roll(input).map(|(input, command)| (input, Some(command))), + "chance" => chance_die().map(|(input, command)| (input, Some(command))), // No recognized command, ignore this. _ => Ok((original_input, None)), } diff --git a/src/lib.rs b/src/lib.rs index 6e7d7f9..ad467d1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod bot; +pub mod cofd; pub mod commands; pub mod dice; pub mod matrix; diff --git a/src/roll.rs b/src/roll.rs index fc26293..3352270 100644 --- a/src/roll.rs +++ b/src/roll.rs @@ -14,7 +14,7 @@ pub trait Rolled { } #[derive(Debug, PartialEq, Eq, Clone)] -pub struct DiceRoll(Vec); +pub struct DiceRoll(pub Vec); impl DiceRoll { pub fn rolls(&self) -> &[u32] { @@ -53,9 +53,10 @@ impl Roll for dice::Dice { fn roll(&self) -> DiceRoll { let mut rng = rand::thread_rng(); - let rolls = (0..self.count) + let rolls: Vec<_> = (0..self.count) .map(|_| rng.gen_range(1, self.sides + 1)) .collect(); + DiceRoll(rolls) } }