diff --git a/Cargo.lock b/Cargo.lock index 1645021..046b629 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.6.4" @@ -677,6 +688,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1154,7 +1166,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.7", ] [[package]] @@ -1260,7 +1272,18 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1269,6 +1292,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.2" @@ -1677,6 +1706,7 @@ dependencies = [ "clap", "crossterm", "flate2", + "rand 0.10.2", "ratatui", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 289e160..e85a5be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ publish = false clap = { version = "4.6.4", features = ["derive"] } crossterm = "0.29" flate2 = "1.1.9" +rand = "0.10.2" ratatui = { version = "0.30.2", features = ["scrolling-regions", "crossterm_0_29"] } regex = "1.13.1" serde = { version = "1.0.229", features = ["derive"] } diff --git a/src/dice.rs b/src/dice.rs new file mode 100644 index 0000000..0b75be4 --- /dev/null +++ b/src/dice.rs @@ -0,0 +1,570 @@ +//! The 5e dice grammar: parsing dice notation and rolling it. +//! +//! Notation is one or more terms joined by `+` and `-`. A term is a flat +//! integer modifier or a dice group written `XdY`, where `X` (the count) +//! defaults to 1 and `Y` (the die size) may be `%` for 100. A group takes +//! suffixes, in any order, case-insensitive, and tolerant of whitespace: +//! `khN`/`klN` keep the highest or lowest N dice, `dhN`/`dlN` drop the +//! highest or lowest N, `roN`/`roN` reroll a die once, `rN`/`rN` reroll a die until the condition stops matching, and `miN` sets a +//! floor on each die's value. Rerolls and minimums settle before keep and +//! drop choose which dice count. A die size must be one this grammar +//! knows: 2, 4, 6, 8, 10, 12, 20, or 100. + +use rand::RngExt; + +/// Rerolling until a condition stops matching would loop forever for a +/// condition no roll can ever fail (`d1r1`, or `r>0` on any die). This caps +/// how many times a single die rerolls, so a degenerate condition still +/// produces a roll instead of hanging. +const MAX_REROLLS: u32 = 100; + +/// The die sizes a 5e table rolls. Anything else, including `d3` (a halved +/// d6 in the SRD's shorthand), is not a die this grammar accepts. +const VALID_SIDES: [u32; 8] = [2, 4, 6, 8, 10, 12, 20, 100]; + +/// Notation that failed to parse, with a reason worded so that whatever +/// wrote the notation can correct it and try again. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiceError { + pub notation: String, + pub reason: String, +} + +impl std::fmt::Display for DiceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "can't parse dice notation `{}`: {}", + self.notation, self.reason + ) + } +} + +impl std::error::Error for DiceError {} + +/// Which end of the roll a keep/drop suffix selects, and how many dice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeepDrop { + KeepHighest(u32), + KeepLowest(u32), + DropHighest(u32), + DropLowest(u32), +} + +/// The value a reroll suffix compares a die's result against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Condition { + Equal(u32), + LessThan(u32), + GreaterThan(u32), +} + +impl Condition { + fn matches(self, value: u32) -> bool { + match self { + Condition::Equal(n) => value == n, + Condition::LessThan(n) => value < n, + Condition::GreaterThan(n) => value > n, + } + } +} + +/// Whether a reroll suffix fires once (`ro`) or repeats until the +/// condition stops matching (`r`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RerollMode { + Once, + Recursive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Reroll { + mode: RerollMode, + condition: Condition, +} + +/// A parsed `XdY` group with whatever suffixes it carried. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Group { + count: u32, + sides: u32, + negative: bool, + reroll: Option, + minimum: Option, + keep_drop: Option, +} + +/// One term of a notation: a flat number, already signed, or a dice group. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Term { + Modifier(i64), + Group(Group), +} + +/// Notation parsed into terms, ready to roll. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Notation { + text: String, + terms: Vec, +} + +/// One die as it fell: every value it showed, and whether the value it +/// settled on counted toward the total. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Die { + pub sides: u32, + /// Every value this die showed, in the order rolled. A die rerolled + /// by `ro` or `r` has more than one entry; the last entry is the + /// value a minimum, if any, is measured against. + pub rolls: Vec, + /// The value that counted toward the group's sum: the last roll, + /// raised to the group's minimum when one applies. + pub value: u32, + /// Whether this die's value was kept. A keep/drop suffix can drop it. + pub kept: bool, + /// Whether this die's group was subtracted, so its value counts + /// against the total rather than toward it. + pub negative: bool, +} + +impl Die { + /// This die's signed contribution to the total: `0` when it was not + /// kept, otherwise its value, negated when its group was subtracted. + pub fn contribution(&self) -> i64 { + if !self.kept { + return 0; + } + if self.negative { + -i64::from(self.value) + } else { + i64::from(self.value) + } + } +} + +/// A completed roll: every die that hit the table, in the order it was +/// rolled, plus the flat modifier and the total. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RollResult { + pub notation: String, + pub dice: Vec, + pub modifier: i64, + pub total: i64, +} + +/// Parses dice notation into a `Notation`, ready to roll. +pub fn parse(input: &str) -> Result { + let compact: String = input + .chars() + .filter(|c| !c.is_whitespace()) + .flat_map(char::to_lowercase) + .collect(); + + if compact.is_empty() { + return Err(DiceError { + notation: input.to_string(), + reason: "the notation is empty; give a dice expression like `d20` or `2d6+3`" + .to_string(), + }); + } + + let terms = split_terms(&compact) + .into_iter() + .map(|(negative, text)| parse_term(text, negative, input)) + .collect::, DiceError>>()?; + + Ok(Notation { + text: input.to_string(), + terms, + }) +} + +/// Rolls parsed notation using `rng` as the source of randomness. +pub fn roll(notation: &Notation, rng: &mut impl rand::Rng) -> RollResult { + let mut dice = Vec::new(); + let mut modifier = 0i64; + let mut total = 0i64; + + for term in ¬ation.terms { + match term { + Term::Modifier(value) => { + modifier += value; + total += value; + } + Term::Group(group) => { + let group_dice = roll_group(group, rng); + total += group_dice.iter().map(Die::contribution).sum::(); + dice.extend(group_dice); + } + } + } + + RollResult { + notation: notation.text.clone(), + dice, + modifier, + total, + } +} + +/// Splits notation already stripped of whitespace and lowercased into +/// `(negative, text)` terms at each top-level `+`/`-`. A term's text can +/// come out empty (a leading, repeated, or trailing operator); that is +/// caught later as a parse error rather than here. +fn split_terms(compact: &str) -> Vec<(bool, &str)> { + let mut terms = Vec::new(); + let mut start = 0; + let mut negative = false; + for (i, c) in compact.char_indices() { + if c == '+' || c == '-' { + terms.push((negative, &compact[start..i])); + negative = c == '-'; + start = i + c.len_utf8(); + } + } + terms.push((negative, &compact[start..])); + terms +} + +/// Parses one term: a dice group if it contains a `d`, otherwise a flat +/// number. `original` is the notation as the caller wrote it, kept around +/// for error messages. +fn parse_term(text: &str, negative: bool, original: &str) -> Result { + if text.is_empty() { + return Err(DiceError { + notation: original.to_string(), + reason: "found an empty term; check for a stray, repeated, or trailing `+`/`-`" + .to_string(), + }); + } + + if let Some(d_index) = text.find('d') { + return Ok(Term::Group(parse_group(text, d_index, negative, original)?)); + } + + let value: u32 = text.parse().map_err(|_| DiceError { + notation: original.to_string(), + reason: format!("`{text}` is not a whole number or a dice group like `2d6`"), + })?; + let value = i64::from(value); + Ok(Term::Modifier(if negative { -value } else { value })) +} + +/// Parses a dice group term (count, `d`, size, suffixes), given the index +/// of its `d`. +fn parse_group( + text: &str, + d_index: usize, + negative: bool, + original: &str, +) -> Result { + let (count_text, rest) = text.split_at(d_index); + let rest = &rest[1..]; + + let count = if count_text.is_empty() { + 1 + } else { + let count = parse_number(count_text, text, original)?; + if count == 0 { + return Err(DiceError { + notation: original.to_string(), + reason: format!( + "a dice count of `0` in `{text}` is invalid; omit it for 1 die, or use a positive number" + ), + }); + } + count + }; + + let (sides, suffix_text) = if let Some(rest) = rest.strip_prefix('%') { + (100, rest) + } else { + let (digits, rest) = take_digits(rest); + if digits.is_empty() { + return Err(DiceError { + notation: original.to_string(), + reason: format!( + "`{text}` is missing a die size after `d`; use a number or `%`, like `d20` or `d%`" + ), + }); + } + (parse_number(digits, text, original)?, rest) + }; + if !VALID_SIDES.contains(&sides) { + return Err(DiceError { + notation: original.to_string(), + reason: format!( + "a die size of `{sides}` in `{text}` is not a valid 5e die size; \ +use 2, 4, 6, 8, 10, 12, 20, or 100 (`%`)" + ), + }); + } + + let suffixes = parse_suffixes(suffix_text, text, original)?; + + Ok(Group { + count, + sides, + negative, + reroll: suffixes.reroll, + minimum: suffixes.minimum, + keep_drop: suffixes.keep_drop, + }) +} + +/// The suffixes found on a group, in whatever order they appeared. +struct Suffixes { + reroll: Option, + minimum: Option, + keep_drop: Option, +} + +/// Parses a group's suffix text, consuming known suffix tokens from the +/// front until nothing is left. Suffixes may appear in any order; a +/// suffix type repeated more than once keeps its last occurrence. +fn parse_suffixes(mut s: &str, group_text: &str, original: &str) -> Result { + let mut suffixes = Suffixes { + reroll: None, + minimum: None, + keep_drop: None, + }; + + while !s.is_empty() { + if let Some(rest) = s.strip_prefix("ro") { + let (kind, rest) = take_condition(rest); + let (n, rest) = take_required_number(rest, "ro", group_text, original)?; + suffixes.reroll = Some(Reroll { + mode: RerollMode::Once, + condition: kind.build(n), + }); + s = rest; + } else if let Some(rest) = s.strip_prefix('r') { + let (kind, rest) = take_condition(rest); + let (n, rest) = take_required_number(rest, "r", group_text, original)?; + suffixes.reroll = Some(Reroll { + mode: RerollMode::Recursive, + condition: kind.build(n), + }); + s = rest; + } else if let Some(rest) = s.strip_prefix("kh") { + let (n, rest) = take_number_with_default(rest, 1, group_text, original)?; + suffixes.keep_drop = Some(KeepDrop::KeepHighest(validate_count( + n, "kh", group_text, original, + )?)); + s = rest; + } else if let Some(rest) = s.strip_prefix("kl") { + let (n, rest) = take_number_with_default(rest, 1, group_text, original)?; + suffixes.keep_drop = Some(KeepDrop::KeepLowest(validate_count( + n, "kl", group_text, original, + )?)); + s = rest; + } else if let Some(rest) = s.strip_prefix("dh") { + let (n, rest) = take_required_number(rest, "dh", group_text, original)?; + suffixes.keep_drop = Some(KeepDrop::DropHighest(validate_count( + n, "dh", group_text, original, + )?)); + s = rest; + } else if let Some(rest) = s.strip_prefix("dl") { + let (n, rest) = take_required_number(rest, "dl", group_text, original)?; + suffixes.keep_drop = Some(KeepDrop::DropLowest(validate_count( + n, "dl", group_text, original, + )?)); + s = rest; + } else if let Some(rest) = s.strip_prefix("mi") { + let (n, rest) = take_required_number(rest, "mi", group_text, original)?; + suffixes.minimum = Some(n); + s = rest; + } else { + return Err(DiceError { + notation: original.to_string(), + reason: format!( + "`{s}` in `{group_text}` is not a suffix I know; use kh, kl, dh, dl, ro, r, or mi" + ), + }); + } + } + + Ok(suffixes) +} + +/// Which comparison a reroll condition uses, before it has a number. +enum ConditionKind { + Equal, + LessThan, + GreaterThan, +} + +impl ConditionKind { + fn build(self, n: u32) -> Condition { + match self { + ConditionKind::Equal => Condition::Equal(n), + ConditionKind::LessThan => Condition::LessThan(n), + ConditionKind::GreaterThan => Condition::GreaterThan(n), + } + } +} + +/// Consumes a leading `<` or `>` for a reroll condition; bare `roN`/`rN` +/// (no comparison) means equality. +fn take_condition(s: &str) -> (ConditionKind, &str) { + if let Some(rest) = s.strip_prefix('<') { + (ConditionKind::LessThan, rest) + } else if let Some(rest) = s.strip_prefix('>') { + (ConditionKind::GreaterThan, rest) + } else { + (ConditionKind::Equal, s) + } +} + +/// Consumes a leading run of ASCII digits, returning the digits and +/// whatever follows them. Returns an empty digit string when `s` does +/// not start with a digit. +fn take_digits(s: &str) -> (&str, &str) { + let end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + s.split_at(end) +} + +/// Parses a digit run into a `u32`, reporting a clear error if it does +/// not fit. +fn parse_number(digits: &str, group_text: &str, original: &str) -> Result { + digits.parse().map_err(|_| DiceError { + notation: original.to_string(), + reason: format!("`{digits}` in `{group_text}` is not a valid number"), + }) +} + +/// Consumes a number that a suffix requires (`dhN`, `roN`, `miN`, ...), +/// erroring when none follows. +fn take_required_number<'a>( + s: &'a str, + suffix: &str, + group_text: &str, + original: &str, +) -> Result<(u32, &'a str), DiceError> { + let (digits, rest) = take_digits(s); + if digits.is_empty() { + return Err(DiceError { + notation: original.to_string(), + reason: format!("`{suffix}` in `{group_text}` needs a number after it"), + }); + } + Ok((parse_number(digits, group_text, original)?, rest)) +} + +/// Consumes a number that a suffix allows to default (`kh`, `kl`), using +/// `default` when none follows. +fn take_number_with_default<'a>( + s: &'a str, + default: u32, + group_text: &str, + original: &str, +) -> Result<(u32, &'a str), DiceError> { + let (digits, rest) = take_digits(s); + if digits.is_empty() { + return Ok((default, rest)); + } + Ok((parse_number(digits, group_text, original)?, rest)) +} + +/// Rejects a keep/drop count of zero; larger-than-the-group counts are +/// left as-is and clamped when the roll happens. +fn validate_count( + n: u32, + suffix: &str, + group_text: &str, + original: &str, +) -> Result { + if n == 0 { + return Err(DiceError { + notation: original.to_string(), + reason: format!( + "a {suffix} count of `0` in `{group_text}` is invalid; use a positive number" + ), + }); + } + Ok(n) +} + +/// Rolls every die in a group, then applies its keep/drop rule, if any. +fn roll_group(group: &Group, rng: &mut impl rand::Rng) -> Vec { + let mut dice: Vec = (0..group.count).map(|_| roll_die(group, rng)).collect(); + apply_keep_drop(&mut dice, group.keep_drop); + dice +} + +/// Rolls a single die, applying its group's reroll rule and minimum. +fn roll_die(group: &Group, rng: &mut impl rand::Rng) -> Die { + let mut rolls = vec![roll_one(group.sides, rng)]; + + if let Some(reroll) = group.reroll { + match reroll.mode { + RerollMode::Once => { + if reroll.condition.matches(*rolls.last().unwrap()) { + rolls.push(roll_one(group.sides, rng)); + } + } + RerollMode::Recursive => { + let mut rerolls = 0; + while reroll.condition.matches(*rolls.last().unwrap()) && rerolls < MAX_REROLLS { + rolls.push(roll_one(group.sides, rng)); + rerolls += 1; + } + } + } + } + + let natural = *rolls.last().unwrap(); + let value = group + .minimum + .map_or(natural, |minimum| natural.max(minimum)); + + Die { + sides: group.sides, + rolls, + value, + kept: true, + negative: group.negative, + } +} + +fn roll_one(sides: u32, rng: &mut impl rand::Rng) -> u32 { + rng.random_range(1..=sides) +} + +/// Marks each die in `dice` kept or dropped according to `keep_drop`. +/// Ties are broken by roll order: the die rolled first wins a tie for +/// being highest or lowest. +fn apply_keep_drop(dice: &mut [Die], keep_drop: Option) { + let Some(keep_drop) = keep_drop else { + return; + }; + let (n, highest, keep) = match keep_drop { + KeepDrop::KeepHighest(n) => (n, true, true), + KeepDrop::KeepLowest(n) => (n, false, true), + KeepDrop::DropHighest(n) => (n, true, false), + KeepDrop::DropLowest(n) => (n, false, false), + }; + let n = (n as usize).min(dice.len()); + + let mut order: Vec = (0..dice.len()).collect(); + order.sort_by(|&a, &b| { + if highest { + dice[b].value.cmp(&dice[a].value) + } else { + dice[a].value.cmp(&dice[b].value) + } + }); + + let mut selected = vec![false; dice.len()]; + for &i in &order[..n] { + selected[i] = true; + } + for (die, is_selected) in dice.iter_mut().zip(selected) { + die.kept = if keep { is_selected } else { !is_selected }; + } +} + +#[cfg(test)] +#[path = "dice_tests.rs"] +mod tests; diff --git a/src/dice_tests.rs b/src/dice_tests.rs new file mode 100644 index 0000000..64f16ed --- /dev/null +++ b/src/dice_tests.rs @@ -0,0 +1,878 @@ +//! Tests for `dice.rs`, split out to keep the production file under the +//! project's file-length guideline. + +use super::*; +use rand::SeedableRng; +use rand::rngs::StdRng; + +fn group(count: u32, sides: u32) -> Group { + Group { + count, + sides, + negative: false, + reroll: None, + minimum: None, + keep_drop: None, + } +} + +fn notation(text: &str, terms: Vec) -> Notation { + Notation { + text: text.to_string(), + terms, + } +} + +fn parses_to(input: &str, expected: Notation) { + assert_eq!(parse(input).unwrap(), expected); +} + +/// Parses and rolls `input` with a fresh `StdRng` seeded from `seed`, so a +/// test can assert on exact outcomes. +fn rolled(input: &str, seed: u64) -> RollResult { + let notation = parse(input).unwrap(); + let mut rng = StdRng::seed_from_u64(seed); + roll(¬ation, &mut rng) +} + +// --- Groups: count, size, percentile --------------------------------- + +#[test] +fn bare_d_defaults_count_to_one() { + parses_to("d20", notation("d20", vec![Term::Group(group(1, 20))])); +} + +#[test] +fn explicit_count_of_one() { + parses_to("1d20", notation("1d20", vec![Term::Group(group(1, 20))])); +} + +#[test] +fn multiple_dice() { + parses_to("3d6", notation("3d6", vec![Term::Group(group(3, 6))])); +} + +#[test] +fn two_digit_count_and_size() { + parses_to("12d6", notation("12d6", vec![Term::Group(group(12, 6))])); +} + +#[test] +fn a_standard_die_size_of_two() { + parses_to("d2", notation("d2", vec![Term::Group(group(1, 2))])); +} + +#[test] +fn percentile_die_is_100_sided() { + parses_to("d%", notation("d%", vec![Term::Group(group(1, 100))])); +} + +#[test] +fn explicit_100_sided_die() { + parses_to("1d100", notation("1d100", vec![Term::Group(group(1, 100))])); +} + +// --- Modifiers --------------------------------------------------------- + +#[test] +fn positive_modifier() { + parses_to( + "2d6+3", + notation("2d6+3", vec![Term::Group(group(2, 6)), Term::Modifier(3)]), + ); +} + +#[test] +fn negative_modifier() { + parses_to( + "1d20-2", + notation( + "1d20-2", + vec![Term::Group(group(1, 20)), Term::Modifier(-2)], + ), + ); +} + +#[test] +fn a_lone_modifier_with_a_group_after_it() { + parses_to( + "d8+1", + notation("d8+1", vec![Term::Group(group(1, 8)), Term::Modifier(1)]), + ); +} + +// --- Keep and drop ------------------------------------------------------- + +#[test] +fn keep_highest_with_explicit_count() { + parses_to( + "4d6kh3", + notation( + "4d6kh3", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::KeepHighest(3)), + ..group(4, 6) + })], + ), + ); +} + +#[test] +fn keep_lowest_with_explicit_count() { + parses_to( + "2d20kl1", + notation( + "2d20kl1", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::KeepLowest(1)), + ..group(2, 20) + })], + ), + ); +} + +#[test] +fn bare_kh_means_advantage() { + parses_to( + "2d20kh", + notation( + "2d20kh", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::KeepHighest(1)), + ..group(2, 20) + })], + ), + ); +} + +#[test] +fn bare_kl_means_disadvantage() { + parses_to( + "2d20kl", + notation( + "2d20kl", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::KeepLowest(1)), + ..group(2, 20) + })], + ), + ); +} + +#[test] +fn drop_lowest_and_keep_highest_parse_to_their_own_forms() { + let dropped = parse("4d6dl1").unwrap(); + let kept = parse("4d6kh3").unwrap(); + + assert_eq!( + dropped, + notation( + "4d6dl1", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::DropLowest(1)), + ..group(4, 6) + })] + ) + ); + assert_eq!( + kept, + notation( + "4d6kh3", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::KeepHighest(3)), + ..group(4, 6) + })] + ) + ); +} + +#[test] +fn drop_highest_with_explicit_count() { + parses_to( + "4d6dh1", + notation( + "4d6dh1", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::DropHighest(1)), + ..group(4, 6) + })], + ), + ); +} + +#[test] +fn keep_highest_combined_with_a_modifier() { + parses_to( + "4d6kh3+2", + notation( + "4d6kh3+2", + vec![ + Term::Group(Group { + keep_drop: Some(KeepDrop::KeepHighest(3)), + ..group(4, 6) + }), + Term::Modifier(2), + ], + ), + ); +} + +// --- Rerolls ------------------------------------------------------------- + +#[test] +fn reroll_once_on_an_exact_value() { + parses_to( + "d20ro1", + notation( + "d20ro1", + vec![Term::Group(Group { + reroll: Some(Reroll { + mode: RerollMode::Once, + condition: Condition::Equal(1), + }), + ..group(1, 20) + })], + ), + ); +} + +#[test] +fn reroll_once_below_a_value() { + parses_to( + "2d6ro<3", + notation( + "2d6ro<3", + vec![Term::Group(Group { + reroll: Some(Reroll { + mode: RerollMode::Once, + condition: Condition::LessThan(3), + }), + ..group(2, 6) + })], + ), + ); +} + +#[test] +fn reroll_once_above_a_value() { + parses_to( + "d20ro>18", + notation( + "d20ro>18", + vec![Term::Group(Group { + reroll: Some(Reroll { + mode: RerollMode::Once, + condition: Condition::GreaterThan(18), + }), + ..group(1, 20) + })], + ), + ); +} + +#[test] +fn reroll_recursive_on_an_exact_value() { + parses_to( + "d20r1", + notation( + "d20r1", + vec![Term::Group(Group { + reroll: Some(Reroll { + mode: RerollMode::Recursive, + condition: Condition::Equal(1), + }), + ..group(1, 20) + })], + ), + ); +} + +#[test] +fn reroll_recursive_below_a_value() { + parses_to( + "d20r<3", + notation( + "d20r<3", + vec![Term::Group(Group { + reroll: Some(Reroll { + mode: RerollMode::Recursive, + condition: Condition::LessThan(3), + }), + ..group(1, 20) + })], + ), + ); +} + +#[test] +fn reroll_recursive_above_a_value() { + parses_to( + "d20r>18", + notation( + "d20r>18", + vec![Term::Group(Group { + reroll: Some(Reroll { + mode: RerollMode::Recursive, + condition: Condition::GreaterThan(18), + }), + ..group(1, 20) + })], + ), + ); +} + +// --- Minimums -------------------------------------------------------------- + +#[test] +fn minimum_per_die() { + parses_to( + "8d6mi2", + notation( + "8d6mi2", + vec![Term::Group(Group { + minimum: Some(2), + ..group(8, 6) + })], + ), + ); +} + +// --- Suffix combinations and ordering -------------------------------------- + +#[test] +fn suffixes_combine_and_order_does_not_matter() { + let a = parse("2d6mi2kh1").unwrap(); + let b = parse("2d6kh1mi2").unwrap(); + + let expected_group = Group { + minimum: Some(2), + keep_drop: Some(KeepDrop::KeepHighest(1)), + ..group(2, 6) + }; + assert_eq!( + a, + notation("2d6mi2kh1", vec![Term::Group(expected_group.clone())]) + ); + assert_eq!(b, notation("2d6kh1mi2", vec![Term::Group(expected_group)])); +} + +// --- Case and whitespace tolerance ----------------------------------------- + +#[test] +fn suffixes_are_case_insensitive() { + parses_to( + "2D20KH1", + notation( + "2D20KH1", + vec![Term::Group(Group { + keep_drop: Some(KeepDrop::KeepHighest(1)), + ..group(2, 20) + })], + ), + ); +} + +#[test] +fn whitespace_around_operators_is_tolerated() { + parses_to( + " 2d6 + 3 ", + notation( + " 2d6 + 3 ", + vec![Term::Group(group(2, 6)), Term::Modifier(3)], + ), + ); +} + +#[test] +fn the_srd_writes_modifiers_with_a_space() { + parses_to( + "2d6 + 3", + notation("2d6 + 3", vec![Term::Group(group(2, 6)), Term::Modifier(3)]), + ); +} + +// --- Compound notation ------------------------------------------------- + +#[test] +fn two_dice_groups() { + parses_to( + "2d8+1d6", + notation( + "2d8+1d6", + vec![Term::Group(group(2, 8)), Term::Group(group(1, 6))], + ), + ); +} + +#[test] +fn three_dice_groups() { + parses_to( + "1d8+1d6+1d4", + notation( + "1d8+1d6+1d4", + vec![ + Term::Group(group(1, 8)), + Term::Group(group(1, 6)), + Term::Group(group(1, 4)), + ], + ), + ); +} + +#[test] +fn compound_groups_with_a_trailing_modifier() { + parses_to( + "2d6+1d4+2", + notation( + "2d6+1d4+2", + vec![ + Term::Group(group(2, 6)), + Term::Group(group(1, 4)), + Term::Modifier(2), + ], + ), + ); +} + +#[test] +fn a_subtracted_dice_group() { + parses_to( + "2d6-1d4", + notation( + "2d6-1d4", + vec![ + Term::Group(group(2, 6)), + Term::Group(Group { + negative: true, + ..group(1, 4) + }), + ], + ), + ); +} + +#[test] +fn chris_example_flat_die() { + parses_to("d6", notation("d6", vec![Term::Group(group(1, 6))])); +} + +#[test] +fn chris_example_die_with_modifier() { + parses_to( + "d8+1", + notation("d8+1", vec![Term::Group(group(1, 8)), Term::Modifier(1)]), + ); +} + +#[test] +fn chris_example_two_dice() { + parses_to("2d10", notation("2d10", vec![Term::Group(group(2, 10))])); +} + +#[test] +fn chris_example_two_dice_with_negative_modifier() { + parses_to( + "2d10-2", + notation( + "2d10-2", + vec![Term::Group(group(2, 10)), Term::Modifier(-2)], + ), + ); +} + +#[test] +fn chris_example_full_compound() { + parses_to( + "3d6-1 + 4d100+5", + notation( + "3d6-1 + 4d100+5", + vec![ + Term::Group(group(3, 6)), + Term::Modifier(-1), + Term::Group(group(4, 100)), + Term::Modifier(5), + ], + ), + ); +} + +// --- Rolling --------------------------------------------------------------- + +#[test] +fn rolling_a_single_die() { + let result = rolled("1d20", 0); + + assert_eq!(result.notation, "1d20"); + assert_eq!(result.dice.len(), 1); + assert_eq!(result.dice[0].value, 17); + assert_eq!(result.dice[0].rolls, vec![17]); + assert!(result.dice[0].kept); + assert_eq!(result.modifier, 0); + assert_eq!(result.total, 17); +} + +#[test] +fn rolling_multiple_dice() { + let result = rolled("3d6", 0); + + let values: Vec = result.dice.iter().map(|die| die.value).collect(); + assert_eq!(values, vec![5, 5, 4]); + assert_eq!(result.total, 14); +} + +#[test] +fn rolling_with_a_positive_modifier() { + let result = rolled("1d20+5", 0); + + assert_eq!(result.modifier, 5); + assert_eq!(result.total, 22); +} + +#[test] +fn rolling_with_a_negative_modifier() { + let result = rolled("1d20-3", 0); + + assert_eq!(result.modifier, -3); + assert_eq!(result.total, 14); +} + +#[test] +fn advantage_keeps_the_higher_roll() { + let result = rolled("2d20kh", 0); + + assert_eq!(result.dice[0].value, 17); + assert!(result.dice[0].kept); + assert_eq!(result.dice[1].value, 15); + assert!(!result.dice[1].kept); + assert_eq!(result.total, 17); +} + +#[test] +fn disadvantage_keeps_the_lower_roll() { + let result = rolled("2d20kl", 2); + + assert_eq!(result.dice[0].value, 2); + assert!(result.dice[0].kept); + assert_eq!(result.dice[1].value, 6); + assert!(!result.dice[1].kept); + assert_eq!(result.total, 2); +} + +#[test] +fn keep_highest_three_of_four() { + let result = rolled("4d6kh3", 0); + + let kept: Vec = result.dice.iter().map(|die| die.kept).collect(); + assert_eq!(kept, vec![true, true, false, true]); + assert_eq!(result.dice[2].contribution(), 0); + assert_eq!(result.total, 15); +} + +#[test] +fn rolling_keep_highest_combined_with_a_modifier() { + let result = rolled("4d6kh3+2", 0); + + assert_eq!(result.modifier, 2); + assert_eq!(result.total, 17); +} + +#[test] +fn drop_lowest_and_keep_highest_are_the_same_roll_said_two_ways() { + let dropped = rolled("4d6dl1", 0); + let kept = rolled("4d6kh3", 0); + + assert_eq!(dropped.total, kept.total); + assert!(!dropped.dice[2].kept); + assert!(!kept.dice[2].kept); +} + +#[test] +fn drop_highest_one_of_four() { + let result = rolled("4d6dh1", 0); + + let kept: Vec = result.dice.iter().map(|die| die.kept).collect(); + assert_eq!(kept, vec![false, true, true, true]); + assert_eq!(result.total, 14); +} + +#[test] +fn a_keep_count_larger_than_the_group_keeps_every_die() { + let result = rolled("2d20kh5", 0); + + assert!(result.dice[0].kept); + assert!(result.dice[1].kept); + assert_eq!(result.total, 32); +} + +#[test] +fn a_drop_count_larger_than_the_group_drops_every_die() { + let result = rolled("2d20dh5", 0); + + assert!(!result.dice[0].kept); + assert!(!result.dice[1].kept); + assert_eq!(result.total, 0); +} + +#[test] +fn reroll_once_fires_when_the_condition_matches() { + let result = rolled("d20ro1", 37); + + assert_eq!(result.dice[0].rolls, vec![1, 11]); + assert_eq!(result.dice[0].value, 11); + assert_eq!(result.total, 11); +} + +#[test] +fn reroll_once_does_not_fire_when_the_condition_does_not_match() { + let result = rolled("d20ro1", 0); + + assert_eq!(result.dice[0].rolls, vec![17]); + assert_eq!(result.dice[0].value, 17); + assert_eq!(result.total, 17); +} + +#[test] +fn reroll_once_above_a_value_fires() { + let result = rolled("d20ro>18", 16); + + assert_eq!(result.dice[0].rolls, vec![19, 18]); + assert_eq!(result.dice[0].value, 18); +} + +#[test] +fn reroll_once_below_a_value_applies_per_die() { + let result = rolled("2d6ro<3", 2); + + assert_eq!(result.dice[0].rolls, vec![1, 2]); + assert_eq!(result.dice[0].value, 2); + assert_eq!(result.dice[1].rolls, vec![6]); + assert_eq!(result.dice[1].value, 6); + assert_eq!(result.total, 8); +} + +#[test] +fn reroll_recursive_keeps_rerolling_until_the_condition_stops_matching() { + let result = rolled("d20r1", 314); + + assert_eq!(result.dice[0].rolls, vec![1, 1, 11]); + assert_eq!(result.dice[0].value, 11); + assert_eq!(result.total, 11); +} + +#[test] +fn reroll_recursive_stops_at_the_max_reroll_cap() { + let result = rolled("1d2r>0", 0); + + assert_eq!(result.dice[0].rolls.len(), (MAX_REROLLS + 1) as usize); +} + +#[test] +fn minimum_raises_a_low_roll() { + let result = rolled("8d6mi2", 0); + + assert_eq!(result.dice[5].rolls, vec![1]); + assert_eq!(result.dice[5].value, 2); + assert_eq!(result.dice[0].value, 5); + assert_eq!(result.total, 35); +} + +#[test] +fn minimum_applies_before_keep_and_drop_decide() { + let without_minimum = rolled("2d6kh1", 37); + let with_minimum = rolled("2d6mi4kh1", 37); + + // Without a floor, the second die (4) is higher and wins. + assert_eq!(without_minimum.dice[0].value, 1); + assert!(!without_minimum.dice[0].kept); + assert!(without_minimum.dice[1].kept); + + // The floor raises the first die's 1 to 4, tying it with the second. + // Ties go to the die rolled first, so the floor changes which die wins. + assert_eq!(with_minimum.dice[0].value, 4); + assert!(with_minimum.dice[0].kept); + assert!(!with_minimum.dice[1].kept); +} + +#[test] +fn a_subtracted_group_negates_its_kept_sum() { + let result = rolled("2d6-1d4", 0); + + assert_eq!(result.dice[2].value, 3); + assert!(result.dice[2].negative); + assert_eq!(result.dice[2].contribution(), -3); + assert_eq!(result.total, 7); +} + +#[test] +fn compound_groups_roll_in_notation_order() { + let result = rolled("2d8+1d6", 0); + + let sides: Vec = result.dice.iter().map(|die| die.sides).collect(); + assert_eq!(sides, vec![8, 8, 6]); + let values: Vec = result.dice.iter().map(|die| die.value).collect(); + assert_eq!(values, vec![7, 6, 4]); + assert_eq!(result.total, 17); +} + +#[test] +fn the_result_keeps_the_notation_exactly_as_written() { + let result = rolled(" 2d6 + 3 ", 0); + + assert_eq!(result.notation, " 2d6 + 3 "); + assert_eq!(result.total, 13); +} + +// --- Error cases --------------------------------------------------------- + +#[test] +fn empty_notation_is_an_error() { + let error = parse("").unwrap_err(); + assert!(error.reason.contains("empty")); +} + +#[test] +fn whitespace_only_notation_is_an_error() { + let error = parse(" ").unwrap_err(); + assert!(error.reason.contains("empty")); +} + +#[test] +fn unparseable_notation_is_an_error() { + let error = parse("banana").unwrap_err(); + assert!(error.reason.contains("banana")); +} + +#[test] +fn a_leading_operator_leaves_an_empty_term() { + let error = parse("+2d6").unwrap_err(); + assert!(error.reason.contains("empty term")); +} + +#[test] +fn a_trailing_operator_leaves_an_empty_term() { + let error = parse("2d6+").unwrap_err(); + assert!(error.reason.contains("empty term")); +} + +#[test] +fn a_repeated_operator_leaves_an_empty_term() { + let error = parse("2d6+-3").unwrap_err(); + assert!(error.reason.contains("empty term")); +} + +#[test] +fn a_non_numeric_count_is_an_error() { + let error = parse("xd6").unwrap_err(); + assert!(error.reason.contains('x')); +} + +#[test] +fn a_zero_count_is_an_error() { + let error = parse("0d6").unwrap_err(); + assert!(error.reason.contains("count")); +} + +#[test] +fn a_missing_die_size_is_an_error() { + let error = parse("1dx").unwrap_err(); + assert!(error.reason.contains("die size")); +} + +#[test] +fn a_zero_die_size_is_an_error() { + let error = parse("d0").unwrap_err(); + assert!(error.reason.contains("die size")); +} + +#[test] +fn d3_is_not_a_standard_die_size() { + let error = parse("d3").unwrap_err(); + assert!(error.reason.contains("die size")); + assert!(error.reason.contains('3')); +} + +#[test] +fn an_unsupported_die_size_is_an_error() { + let error = parse("1d7").unwrap_err(); + assert!(error.reason.contains("die size")); + assert!(error.reason.contains('7')); +} + +#[test] +fn an_unrecognized_suffix_is_an_error() { + let error = parse("1d6xy").unwrap_err(); + assert!(error.reason.contains("xy")); +} + +#[test] +fn a_zero_keep_highest_count_is_an_error() { + let error = parse("4d6kh0").unwrap_err(); + assert!(error.reason.contains("kh")); +} + +#[test] +fn a_zero_drop_lowest_count_is_an_error() { + let error = parse("4d6dl0").unwrap_err(); + assert!(error.reason.contains("dl")); +} + +#[test] +fn a_zero_keep_lowest_count_is_an_error() { + let error = parse("2d20kl0").unwrap_err(); + assert!(error.reason.contains("kl")); +} + +#[test] +fn a_zero_drop_highest_count_is_an_error() { + let error = parse("4d6dh0").unwrap_err(); + assert!(error.reason.contains("dh")); +} + +#[test] +fn a_reroll_once_missing_its_number_is_an_error() { + let error = parse("1d6ro").unwrap_err(); + assert!(error.reason.contains("ro")); +} + +#[test] +fn a_reroll_recursive_missing_its_number_is_an_error() { + let error = parse("1d6r").unwrap_err(); + assert!(error.reason.contains('r')); +} + +#[test] +fn a_dangling_reroll_condition_is_an_error() { + let error = parse("d20ro<").unwrap_err(); + assert!(error.reason.contains("ro")); +} + +#[test] +fn a_drop_highest_missing_its_number_is_an_error() { + let error = parse("4d6dh").unwrap_err(); + assert!(error.reason.contains("dh")); +} + +#[test] +fn a_minimum_missing_its_number_is_an_error() { + let error = parse("8d6mi").unwrap_err(); + assert!(error.reason.contains("mi")); +} + +#[test] +fn an_overlong_number_is_an_error() { + let error = parse("d6kh99999999999999999999").unwrap_err(); + assert!(error.reason.contains("not a valid number")); +} + +#[test] +fn dice_error_display_names_the_notation_and_the_reason() { + let error = DiceError { + notation: "banana".to_string(), + reason: "is not a dice roll".to_string(), + }; + + assert_eq!( + error.to_string(), + "can't parse dice notation `banana`: is not a dice roll" + ); +} diff --git a/src/lib.rs b/src/lib.rs index fa4ba92..7452e6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod chat; pub mod cli; pub mod config; +pub mod dice; pub mod dm; pub mod play; pub mod srd;