dicelib/
dice.rs

1//! Represents a Dice.
2
3use anyhow::Result;
4use rand::{prelude::Rng, RngCore};
5use regex::Regex;
6
7use crate::error::{Error, RollError};
8
9/// A dice roll expressed in RPG term e.g. 3d6 means "roll a 6-sided die 3 times".
10pub struct DiceRoll {
11    /// How many times the dice will be rolled.
12    pub rolls: u32,
13
14    /// The maximum number of sides the dice has. If it has 6 sides, the most it
15    /// can roll for at any one time is 6.
16    pub sides: u32,
17}
18
19impl DiceRoll {
20    /// All dice must be rollable at least once.
21    pub const MINIMUM_ROLLS: u32 = 1;
22
23    /// All dice must have at least 2 sides.
24    pub const MINIMUM_SIDES: u32 = 2;
25
26    /// Create a `DiceRoll`.
27    ///
28    /// # Errors
29    /// - `rolls` is less than 1
30    /// - `sides` is less than 2
31    pub fn new(rolls: u32, sides: u32) -> Result<Self> {
32        if rolls < Self::MINIMUM_ROLLS {
33            return Err(Error::InvalidRolls(rolls).into());
34        }
35
36        if sides < Self::MINIMUM_SIDES {
37            return Err(Error::InvalidSides(sides).into());
38        }
39
40        Ok(Self { rolls, sides })
41    }
42
43    /// Create a `DiceRoll` from a string.
44    /// ```
45    /// use dicelib::dice::DiceRoll;
46    /// let dice = DiceRoll::from_string(&"3d6".to_string()).unwrap();
47    /// ```
48    ///
49    /// # Errors
50    /// - See `parse_rolls_and_sides()`
51    // TODO: use &str instead of &String
52    pub fn from_string(string: &str) -> Result<Self> {
53        let (rolls, sides) = Self::parse_rolls_and_sides(string)?;
54        Self::new(rolls, sides)
55    }
56
57    /// Utility function to parse the rolls and sides of a dice roll string
58    /// into a pair of u32s. If you want a `DiceRoll`, use `from_string()` instead.
59    /// ```
60    /// use dicelib::dice::DiceRoll;
61    /// let (rolls, sides) = DiceRoll::parse_rolls_and_sides(&"1d4".to_string()).unwrap();
62    /// ```
63    ///
64    /// # Errors
65    /// - If rolls or sides cannot be matched (expression is malformed)
66    /// - If the matched rolls and sides are not parseable as `u32`
67    pub fn parse_rolls_and_sides(string: &str) -> Result<(u32, u32)> {
68        // parse into rolls and sides, with regex validation
69        lazy_static! {
70            static ref PATTERN: Regex = Regex::new(r"^(\d+)d(\d+)$").unwrap();
71        }
72
73        // Parse the captures as u32s.
74        let captures = PATTERN
75            .captures(string)
76            .ok_or_else(|| Error::InvalidExpression(string.to_string()))?;
77
78        // The error handling here is more of a formality because if we got this
79        // far, we probably matched two ints.
80        let rolls = captures
81            .get(1)
82            .ok_or_else(|| Error::FailedToParseRolls(string.to_string()))?
83            .as_str()
84            .parse::<u32>()?;
85        let sides = captures
86            .get(2)
87            .ok_or_else(|| Error::FailedToParseSides(string.to_string()))?
88            .as_str()
89            .parse::<u32>()?;
90
91        Ok((rolls, sides))
92    }
93
94    // TODO: this function is the hot path for very large numbers of rolls.
95    /// Performs the `DiceRoll` and returns the sum of all rolls.
96    ///
97    /// # Errors
98    /// - `IntegerOverFlow` if the rolls and sides are very, very big numbers.
99    pub fn roll(&self, rng: &mut impl RngCore) -> Result<u32> {
100        let mut result: u32 = 0;
101
102        // TODO: experiment with a bigint implementation, benchmark against native
103        // integers.
104        for _ in 0..self.rolls {
105            // TODO: benchmark this against unchecked +=
106            let roll = rng.gen_range(1, self.sides + 1);
107            result = result
108                .checked_add(roll)
109                .ok_or(RollError::IntegerOverFlow(result, roll))?;
110        }
111
112        Ok(result)
113    }
114}
115
116#[cfg(test)]
117mod dice_unit_tests {
118    use super::DiceRoll;
119
120    #[test]
121    fn dice_from_string() {
122        let _d = DiceRoll::from_string(&"1d6".to_string());
123        assert!(true);
124    }
125}