Implement Chronicles of Darkness dice system, improve error handling.

Adds the Chronicles of Darkness 2E dice system to the bot, and also
somewhat improves the error handling when weird commands are received.
This commit is contained in:
projectmoon 2020-08-21 21:49:22 +00:00 committed by ProjectMoon
parent b2b15f9a85
commit 2c08eb41ad
8 changed files with 669 additions and 9 deletions

37
Cargo.lock generated
View File

@ -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"

View File

@ -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"

315
src/cofd.rs Normal file
View File

@ -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<u32>,
}
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: rand::Rng>(R);
impl<R: rand::Rng> DieRoller for RngDieRoller<R> {
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<R: DieRoller>(
roller: &mut R,
sides: u32,
explode_on_or_higher: u32,
) -> Vec<u32> {
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<R: DieRoller>(roller: &mut R, sides: u32) -> Vec<u32> {
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<R: DieRoller>(roller: &mut R, sides: u32, quality: DicePoolQuality) -> Vec<u32> {
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<u32> = (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<u32>,
position: usize,
}
impl SequentialDieRoller {
fn new(results: Vec<u32>) -> 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());
}
}

232
src/cofd/parser.rs Normal file
View File

@ -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<DicePoolElement>) -> (Option<u32>, DicePoolQuality, u32) {
let mut found_quality: Option<DicePoolQuality> = None;
let mut found_count: Option<u32> = None;
let mut found_successes_required: Option<u32> = 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<DicePoolElement>) -> 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<DicePoolElement>, 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)))
);
}
}

View File

@ -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!(
"<p><strong>Pool:</strong> {}</p><p><strong>Result</strong>: {}</p>",
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<Option<Box<dyn Command>>, 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());
}
}

View File

@ -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<dyn Command>> {
Ok((input, Box::new(RollCommand(expression))))
}
fn parse_pool_roll(input: &str) -> IResult<&str, Box<dyn Command>> {
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<dyn Command>> {
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<Box<dyn Command>>> {
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)),
}

View File

@ -1,4 +1,5 @@
pub mod bot;
pub mod cofd;
pub mod commands;
pub mod dice;
pub mod matrix;

View File

@ -14,7 +14,7 @@ pub trait Rolled {
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub struct DiceRoll(Vec<u32>);
pub struct DiceRoll(pub Vec<u32>);
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)
}
}