Skip to main content

rust_roll/
roller.rs

1//! Rolling dice from a [`Notation`], following the D&D 5e (2014) rules.
2//!
3//! The entry point is [`roll`], which takes a [`RollType`] describing what
4//! to roll and how. Randomness comes from a generator passed in by the
5//! caller, so results can be made repeatable in tests by using a seeded
6//! generator.
7
8use crate::notation::Notation;
9use rand::{Rng, RngExt};
10use std::cmp::Reverse;
11
12/// What to roll, and how.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum RollType {
16    /// Roll the notation once.
17    Normal(Notation),
18    /// Roll the notation twice, returning the results highest first.
19    Advantage(Notation),
20    /// Roll the notation twice, returning the results lowest first.
21    Disadvantage(Notation),
22    /// Roll the notation as a critical hit: double the dice, apply the
23    /// modifier once. See [`Notation::critical`].
24    Critical(Notation),
25    /// Roll six ability scores, each 4d6 with the lowest die dropped.
26    AbilityScores,
27}
28
29/// The result of a single roll.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Roll {
32    /// Every die rolled, in the order rolled, without the modifier. For
33    /// ability scores, this includes the dropped die.
34    pub dice: Vec<u32>,
35    /// The final result, after applying any modifier or dropping the
36    /// lowest die.
37    pub total: u32,
38}
39
40fn roll_dice(count: u32, sides: u32, rng: &mut impl Rng) -> Vec<u32> {
41    (0..count).map(|_| rng.random_range(1..=sides)).collect()
42}
43
44fn roll_once(notation: Notation, rng: &mut impl Rng) -> Roll {
45    let dice = roll_dice(notation.count(), notation.sides(), rng);
46    let sum: u32 = dice.iter().sum();
47    let total = notation.modifier().map_or(sum, |m| m.apply(sum));
48
49    Roll { dice, total }
50}
51
52fn roll_ability_score(rng: &mut impl Rng) -> Roll {
53    let dice = roll_dice(4, 6, rng);
54    let sum: u32 = dice.iter().sum();
55    let lowest = dice.iter().min().copied().unwrap_or(0);
56    let total = sum - lowest;
57
58    Roll { dice, total }
59}
60
61/// Rolls dice as described by `roll_type`, using `rng` for randomness.
62///
63/// Returns one [`Roll`] for normal and critical rolls, two for advantage
64/// and disadvantage, and six for ability scores.
65///
66/// # Examples
67///
68/// ```
69/// use rust_roll::notation::Notation;
70/// use rust_roll::roller::{roll, RollType};
71///
72/// let notation: Notation = "2d6".parse().unwrap();
73/// let rolls = roll(RollType::Normal(notation), &mut rand::rng());
74///
75/// assert_eq!(rolls.len(), 1);
76/// assert_eq!(rolls[0].dice.len(), 2);
77/// ```
78pub fn roll(roll_type: RollType, rng: &mut impl Rng) -> Vec<Roll> {
79    match roll_type {
80        RollType::Normal(notation) => vec![roll_once(notation, rng)],
81        RollType::Critical(notation) => vec![roll_once(notation.critical(), rng)],
82        RollType::Advantage(notation) => {
83            let mut rolls = vec![roll_once(notation, rng), roll_once(notation, rng)];
84            rolls.sort_by_key(|roll| Reverse(roll.total));
85
86            rolls
87        }
88        RollType::Disadvantage(notation) => {
89            let mut rolls = vec![roll_once(notation, rng), roll_once(notation, rng)];
90            rolls.sort_by_key(|roll| roll.total);
91
92            rolls
93        }
94        RollType::AbilityScores => (0..6).map(|_| roll_ability_score(rng)).collect(),
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use rand::{SeedableRng, rngs::StdRng};
102    use rstest::{fixture, rstest};
103
104    use crate::notation::Operator;
105    use crate::test_helpers::{modifier, notation};
106
107    const RANDOM_SEED: u64 = 42;
108
109    #[fixture]
110    fn rng() -> StdRng {
111        StdRng::seed_from_u64(RANDOM_SEED)
112    }
113
114    mod roll_dice {
115        use super::*;
116
117        #[rstest]
118        fn returns_count_dice(mut rng: StdRng) {
119            let count = 3;
120            let sides = 6;
121
122            let result = roll_dice(count, sides, &mut rng);
123
124            assert_eq!(result.len(), count as usize);
125        }
126
127        #[rstest]
128        fn every_die_is_within_range_of_sides(mut rng: StdRng) {
129            let count = 100;
130            let sides = 10;
131
132            let result = roll_dice(count, sides, &mut rng);
133
134            assert!(
135                result.iter().all(|die| (1..=sides).contains(die)),
136                "a die was out of range: {result:?}"
137            );
138        }
139
140        #[rstest]
141        fn every_possible_face_can_come_up(mut rng: StdRng) {
142            let count = 1_000;
143            let sides = 6;
144
145            let result = roll_dice(count, sides, &mut rng);
146
147            let missing: Vec<u32> = (1..=6).filter(|face| !result.contains(face)).collect();
148            assert!(missing.is_empty(), "faces never rolled: {missing:?}");
149        }
150    }
151
152    mod roll_once {
153        use super::*;
154
155        #[rstest]
156        fn rolls_count_dice(mut rng: StdRng) {
157            let notation = notation(2, 6, None);
158
159            let result = roll_once(notation, &mut rng);
160
161            assert_eq!(result.dice.len(), 2);
162        }
163
164        #[rstest]
165        fn applies_modifier_to_total(mut rng: StdRng) {
166            let notation = notation(3, 1, Some(modifier(Operator::Add, 2)));
167
168            let result = roll_once(notation, &mut rng);
169
170            assert_eq!(result.total, 5);
171        }
172
173        #[rstest]
174        fn total_is_sum_of_dice_with_no_modifier(mut rng: StdRng) {
175            let notation = notation(2, 6, None);
176
177            let result = roll_once(notation, &mut rng);
178
179            assert_eq!(result.total, result.dice.iter().sum::<u32>());
180        }
181
182        #[rstest]
183        fn leaves_dice_unmodified_when_applying_modifier(mut rng: StdRng) {
184            let notation = notation(1, 1, Some(modifier(Operator::Add, 5)));
185
186            let result = roll_once(notation, &mut rng);
187
188            assert_eq!(result.dice, vec![1]);
189            assert_eq!(result.total, 6);
190        }
191    }
192
193    mod roll_ability_score {
194        use super::*;
195        #[rstest]
196        fn rolls_four_dice(mut rng: StdRng) {
197            let result = roll_ability_score(&mut rng);
198
199            assert_eq!(result.dice.len(), 4);
200        }
201
202        #[rstest]
203        fn dice_have_six_sides(mut rng: StdRng) {
204            let result = roll_ability_score(&mut rng);
205
206            assert!(
207                result.dice.iter().all(|die| (1..=6).contains(die)),
208                "a die was outside 1..=6: {:?}",
209                result.dice
210            );
211        }
212
213        #[rstest]
214        fn total_drops_the_lowest_die(mut rng: StdRng) {
215            let result = roll_ability_score(&mut rng);
216
217            let sum: u32 = result.dice.iter().sum();
218            let lowest = *result.dice.iter().min().unwrap();
219            assert_eq!(result.total, sum - lowest);
220        }
221
222        #[rstest]
223        fn total_is_between_3_and_18(mut rng: StdRng) {
224            let result = roll_ability_score(&mut rng);
225
226            assert!(
227                (3..=18).contains(&result.total),
228                "total out of range: {}",
229                result.total
230            );
231        }
232    }
233
234    mod roll {
235        use super::*;
236
237        #[rstest]
238        fn normal_returns_one_roll(mut rng: StdRng) {
239            let roll_type = RollType::Normal(notation(2, 6, None));
240
241            let result = roll(roll_type, &mut rng);
242
243            assert_eq!(result.len(), 1);
244            let roll = &result[0];
245            assert_eq!(roll.dice.len(), 2);
246        }
247
248        #[rstest]
249        fn critical_doubles_original_dice(mut rng: StdRng) {
250            let roll_type = RollType::Critical(notation(1, 8, Some(modifier(Operator::Add, 2))));
251
252            let result = roll(roll_type, &mut rng);
253
254            assert_eq!(result.len(), 1);
255            let roll = &result[0];
256            assert_eq!(roll.dice.len(), 2);
257        }
258
259        #[rstest]
260        fn critical_applies_modifier_once(mut rng: StdRng) {
261            let roll_type = RollType::Critical(notation(1, 1, Some(modifier(Operator::Add, 2))));
262
263            let result = roll(roll_type, &mut rng);
264
265            assert_eq!(result[0].total, 4);
266        }
267
268        #[rstest]
269        fn advantage_returns_two_rolls_of_count_dice(mut rng: StdRng) {
270            let roll_type = RollType::Advantage(notation(1, 20, None));
271            let result = roll(roll_type, &mut rng);
272
273            assert_eq!(result.len(), 2);
274            assert_eq!(result[0].dice.len(), 1);
275            assert_eq!(result[1].dice.len(), 1);
276        }
277
278        #[rstest]
279        fn advantage_returns_rolls_sorted_highest_to_lowest(mut rng: StdRng) {
280            let roll_type = RollType::Advantage(notation(1, 20, Some(modifier(Operator::Add, 2))));
281
282            let result = roll(roll_type, &mut rng);
283
284            assert!(result[0].total >= result[1].total);
285        }
286
287        #[rstest]
288        fn disadvantage_returns_two_rolls_of_count_dice(mut rng: StdRng) {
289            let roll_type = RollType::Disadvantage(notation(1, 20, None));
290
291            let result = roll(roll_type, &mut rng);
292
293            assert_eq!(result.len(), 2);
294            assert_eq!(result[0].dice.len(), 1);
295            assert_eq!(result[1].dice.len(), 1);
296        }
297
298        #[rstest]
299        fn disadvantage_returns_rolls_sorted_lowest_to_highest(mut rng: StdRng) {
300            let roll_type =
301                RollType::Disadvantage(notation(1, 20, Some(modifier(Operator::Add, 2))));
302
303            let result = roll(roll_type, &mut rng);
304
305            assert!(result[0].total <= result[1].total);
306        }
307
308        #[rstest]
309        fn ability_scores_returns_six_rolls(mut rng: StdRng) {
310            let result = roll(RollType::AbilityScores, &mut rng);
311
312            assert_eq!(result.len(), 6);
313        }
314    }
315}