Skip to main content

rdice_core/
engine.rs

1//! Stateful dice engine.
2//!
3//! [`DiceEngine`] combines dice definitions and trays, then exposes the core
4//! operations used by user interfaces: dice CRUD, tray CRUD, random rolls, and
5//! deterministic roll analysis.
6
7use rand::{Rng, RngExt};
8
9use crate::die::{CUSTOM_PREFIX, Die, DieKind, FaceValue, builtin_dice};
10use crate::error::{DiceError, Result};
11use crate::tray::{Tray, TraySlot};
12
13/// Result of rolling one die.
14#[derive(Debug, Clone, PartialEq)]
15pub struct DieRoll {
16    /// One-based roll number within a batch, or `1` for a single-die roll.
17    pub roll_id: usize,
18    /// Canonical name of the die that was rolled.
19    pub die_name: String,
20    /// Face value selected by the roll.
21    pub value: FaceValue,
22}
23
24/// Result of rolling multiple dice.
25#[derive(Debug, Clone, PartialEq)]
26pub struct RollBatchResult {
27    /// Individual roll results in input order.
28    pub rolls: Vec<DieRoll>,
29    /// Sum of integer faces when at least one integer face was rolled.
30    ///
31    /// Text faces are excluded. This is `None` when the batch contains no
32    /// integer face values.
33    pub integer_sum: Option<i64>,
34}
35
36/// Deterministic analysis for a roll expression.
37#[derive(Debug, Clone, PartialEq)]
38pub struct RollAnalysis {
39    /// Average point value of the roll, including modifiers.
40    pub expected_value: f64,
41    /// Inclusive minimum and maximum point values, including modifiers.
42    pub point_range: PointRange,
43}
44
45/// Inclusive integer point range.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PointRange {
48    /// Minimum possible point value.
49    pub min: i64,
50    /// Maximum possible point value.
51    pub max: i64,
52}
53
54/// Snapshot result for a tray.
55#[derive(Debug, Clone, PartialEq)]
56pub struct TrayResult {
57    /// Tray name.
58    pub tray_name: String,
59    /// Current slot snapshots in tray order.
60    pub slots: Vec<SlotResult>,
61    /// Sum of current integer slot values when any are present.
62    ///
63    /// Text faces and empty slots are excluded. This is `None` when the tray has
64    /// no current integer values.
65    pub integer_sum: Option<i64>,
66}
67
68/// Snapshot result for one tray slot.
69#[derive(Debug, Clone, PartialEq)]
70pub struct SlotResult {
71    /// Stable slot identifier within the tray.
72    pub slot_id: u32,
73    /// Canonical die name assigned to the slot.
74    pub die_name: String,
75    /// Whether this slot is locked against future tray rolls.
76    pub locked: bool,
77    /// Most recent rolled value for the slot.
78    pub current_value: Option<FaceValue>,
79}
80
81/// In-memory dice and tray engine.
82///
83/// A new engine starts with the built-in dice and no trays. Custom dice and
84/// trays are held in memory; callers that need persistence should serialize the
85/// data structures exposed by this crate.
86#[derive(Debug, Clone)]
87pub struct DiceEngine {
88    dice: Vec<Die>,
89    trays: Vec<Tray>,
90}
91
92impl DiceEngine {
93    /// Creates a new engine with the built-in dice loaded.
94    pub fn new() -> Self {
95        Self {
96            dice: builtin_dice(),
97            trays: Vec::new(),
98        }
99    }
100
101    /// Replaces all custom dice while preserving the built-in dice.
102    ///
103    /// Each supplied die is normalized to [`DieKind::Custom`] and receives
104    /// [`CUSTOM_PREFIX`] if it does not already have it.
105    pub fn set_custom_dice(&mut self, custom: Vec<Die>) {
106        let mut dice = builtin_dice();
107        dice.extend(custom.into_iter().map(|mut die| {
108            let base_name = die.name.strip_prefix(CUSTOM_PREFIX).unwrap_or(&die.name);
109            die.name = format!("{CUSTOM_PREFIX}{base_name}");
110            die.kind = DieKind::Custom;
111            die
112        }));
113        self.dice = dice;
114    }
115
116    /// Replaces the engine's trays.
117    pub fn set_trays(&mut self, trays: Vec<Tray>) {
118        self.trays = trays;
119    }
120
121    /// Returns all custom dice.
122    pub fn custom_dice(&self) -> Vec<&Die> {
123        self.dice
124            .iter()
125            .filter(|die| die.kind == DieKind::Custom)
126            .collect()
127    }
128
129    /// Returns all trays.
130    pub fn trays(&self) -> &[Tray] {
131        &self.trays
132    }
133
134    /// Returns all dice known to the engine.
135    ///
136    /// Built-in dice are listed before custom dice.
137    pub fn list_dice(&self) -> &[Die] {
138        &self.dice
139    }
140
141    /// Returns all trays known to the engine.
142    pub fn list_trays(&self) -> &[Tray] {
143        &self.trays
144    }
145
146    /// Returns a tray by exact name.
147    pub fn get_tray(&self, name: &str) -> Option<&Tray> {
148        self.trays.iter().find(|tray| tray.name == name)
149    }
150
151    /// Resolves a user-provided die name to its canonical engine name.
152    ///
153    /// The lookup first tries an exact match, then a case-insensitive match, and
154    /// finally the custom-die form with [`CUSTOM_PREFIX`].
155    pub fn resolve_die_name(&self, name: &str) -> Option<String> {
156        if let Some(die) = self.dice.iter().find(|die| die.name == name) {
157            return Some(die.name.clone());
158        }
159
160        if let Some(die) = self
161            .dice
162            .iter()
163            .find(|die| die.name.eq_ignore_ascii_case(name))
164        {
165            return Some(die.name.clone());
166        }
167
168        let normalized_name = Self::normalize_custom_name(name).ok()?;
169        self.dice
170            .iter()
171            .find(|die| die.name == normalized_name)
172            .map(|die| die.name.clone())
173    }
174
175    /// Creates a custom die and returns the stored die.
176    ///
177    /// The supplied name must be non-empty and contain no whitespace. Names are
178    /// normalized with [`CUSTOM_PREFIX`].
179    ///
180    /// # Errors
181    ///
182    /// Returns [`DiceError::InvalidName`] for invalid names,
183    /// [`DiceError::InvalidFaceCount`] for empty faces, and
184    /// [`DiceError::DieAlreadyExists`] when the normalized name is already in
185    /// use.
186    pub fn create_die(&mut self, name: &str, faces: Vec<FaceValue>) -> Result<&Die> {
187        let normalized_name = Self::normalize_custom_name(name)?;
188        if faces.is_empty() {
189            return Err(DiceError::InvalidFaceCount);
190        }
191        if self.find_die_index(&normalized_name).is_some() {
192            return Err(DiceError::DieAlreadyExists(normalized_name));
193        }
194
195        self.dice.push(Die {
196            name: normalized_name,
197            faces,
198            kind: DieKind::Custom,
199        });
200
201        Ok(self.dice.last().expect("just pushed custom die"))
202    }
203
204    /// Replaces the faces of a custom die and returns the stored die.
205    ///
206    /// # Errors
207    ///
208    /// Returns [`DiceError::InvalidFaceCount`] for empty faces,
209    /// [`DiceError::CannotModifyBuiltin`] for built-in dice,
210    /// [`DiceError::InvalidName`] for invalid custom names, and
211    /// [`DiceError::DieNotFound`] when the die does not exist.
212    pub fn modify_die(&mut self, name: &str, faces: Vec<FaceValue>) -> Result<&Die> {
213        if faces.is_empty() {
214            return Err(DiceError::InvalidFaceCount);
215        }
216        if self.is_builtin_name(name) {
217            return Err(DiceError::CannotModifyBuiltin(name.to_string()));
218        }
219
220        let normalized_name = Self::normalize_custom_name(name)?;
221        let die_index = self
222            .find_die_index(&normalized_name)
223            .ok_or_else(|| DiceError::DieNotFound(normalized_name.clone()))?;
224
225        if self.dice[die_index].kind == DieKind::Builtin {
226            return Err(DiceError::CannotModifyBuiltin(
227                self.dice[die_index].name.clone(),
228            ));
229        }
230
231        self.dice[die_index].faces = faces;
232        Ok(&self.dice[die_index])
233    }
234
235    /// Deletes a custom die.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`DiceError::CannotModifyBuiltin`] for built-in dice,
240    /// [`DiceError::InvalidName`] for invalid custom names,
241    /// [`DiceError::DieNotFound`] when the die does not exist, and
242    /// [`DiceError::CannotDeleteInUse`] when any tray still references the die.
243    pub fn delete_die(&mut self, name: &str) -> Result<()> {
244        if self.is_builtin_name(name) {
245            return Err(DiceError::CannotModifyBuiltin(name.to_string()));
246        }
247
248        let normalized_name = Self::normalize_custom_name(name)?;
249        let die_index = self
250            .find_die_index(&normalized_name)
251            .ok_or_else(|| DiceError::DieNotFound(normalized_name.clone()))?;
252
253        if self.dice[die_index].kind == DieKind::Builtin {
254            return Err(DiceError::CannotModifyBuiltin(
255                self.dice[die_index].name.clone(),
256            ));
257        }
258
259        let trays_in_use: Vec<String> = self
260            .trays
261            .iter()
262            .filter(|tray| {
263                tray.slots
264                    .iter()
265                    .any(|slot| slot.die_name == normalized_name)
266            })
267            .map(|tray| tray.name.clone())
268            .collect();
269
270        if !trays_in_use.is_empty() {
271            return Err(DiceError::CannotDeleteInUse {
272                die: normalized_name,
273                trays: trays_in_use,
274            });
275        }
276
277        self.dice.remove(die_index);
278        Ok(())
279    }
280
281    /// Creates an empty tray and returns it.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`DiceError::InvalidName`] for empty names or names containing
286    /// whitespace, and [`DiceError::TrayAlreadyExists`] when the name is already
287    /// in use.
288    pub fn create_tray(&mut self, name: &str) -> Result<&Tray> {
289        Self::validate_name(name)?;
290        if self.find_tray_index(name).is_some() {
291            return Err(DiceError::TrayAlreadyExists(name.to_string()));
292        }
293
294        self.trays.push(Tray::new(name.to_string()));
295        Ok(self.trays.last().expect("just pushed tray"))
296    }
297
298    /// Deletes a tray by exact name.
299    ///
300    /// # Errors
301    ///
302    /// Returns [`DiceError::TrayNotFound`] when the tray does not exist.
303    pub fn delete_tray(&mut self, name: &str) -> Result<()> {
304        let tray_index = self
305            .find_tray_index(name)
306            .ok_or_else(|| DiceError::TrayNotFound(name.to_string()))?;
307        self.trays.remove(tray_index);
308        Ok(())
309    }
310
311    /// Renames a tray and returns it.
312    ///
313    /// # Errors
314    ///
315    /// Returns [`DiceError::TrayNotFound`] when `old_name` does not exist,
316    /// [`DiceError::InvalidName`] when `new_name` is invalid, and
317    /// [`DiceError::TrayAlreadyExists`] when `new_name` belongs to another tray.
318    pub fn rename_tray(&mut self, old_name: &str, new_name: &str) -> Result<&Tray> {
319        Self::validate_name(new_name)?;
320        let tray_index = self
321            .find_tray_index(old_name)
322            .ok_or_else(|| DiceError::TrayNotFound(old_name.to_string()))?;
323
324        if old_name != new_name && self.find_tray_index(new_name).is_some() {
325            return Err(DiceError::TrayAlreadyExists(new_name.to_string()));
326        }
327
328        self.trays[tray_index].name = new_name.to_string();
329        Ok(&self.trays[tray_index])
330    }
331
332    /// Adds a die to a tray and returns the assigned slot identifier.
333    ///
334    /// The die name is resolved through [`Self::resolve_die_name`]. Slot
335    /// identifiers are assigned by the tray and are not reused after removal.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`DiceError::DieNotFound`] when the die cannot be resolved and
340    /// [`DiceError::TrayNotFound`] when the tray does not exist.
341    pub fn add_die_to_tray(&mut self, die_name: &str, tray_name: &str) -> Result<u32> {
342        let resolved_die_name = self
343            .resolve_die_name(die_name)
344            .ok_or_else(|| DiceError::DieNotFound(Self::die_not_found_name(die_name)))?;
345
346        let tray_index = self
347            .find_tray_index(tray_name)
348            .ok_or_else(|| DiceError::TrayNotFound(tray_name.to_string()))?;
349        let tray = &mut self.trays[tray_index];
350
351        let slot_id = tray.next_slot_id;
352        tray.next_slot_id += 1;
353        tray.slots.push(TraySlot {
354            die_name: resolved_die_name,
355            slot_id,
356            locked: false,
357            current_value: None,
358        });
359
360        Ok(slot_id)
361    }
362
363    /// Removes a slot from a tray.
364    ///
365    /// # Errors
366    ///
367    /// Returns [`DiceError::TrayNotFound`] when the tray does not exist and
368    /// [`DiceError::SlotNotFound`] when the slot does not exist in that tray.
369    pub fn remove_slot(&mut self, tray_name: &str, slot_id: u32) -> Result<()> {
370        let tray = self.find_tray_mut(tray_name)?;
371        let slot_index = tray
372            .slots
373            .iter()
374            .position(|slot| slot.slot_id == slot_id)
375            .ok_or_else(|| DiceError::SlotNotFound {
376                tray: tray_name.to_string(),
377                slot_id,
378            })?;
379        tray.slots.remove(slot_index);
380        Ok(())
381    }
382
383    /// Locks a tray slot so future tray rolls preserve its current value.
384    ///
385    /// # Errors
386    ///
387    /// Returns [`DiceError::TrayNotFound`] when the tray does not exist and
388    /// [`DiceError::SlotNotFound`] when the slot does not exist in that tray.
389    pub fn lock_slot(&mut self, tray_name: &str, slot_id: u32) -> Result<()> {
390        let slot = self.find_slot_mut(tray_name, slot_id)?;
391        slot.locked = true;
392        Ok(())
393    }
394
395    /// Unlocks a tray slot so future tray rolls can update its current value.
396    ///
397    /// # Errors
398    ///
399    /// Returns [`DiceError::TrayNotFound`] when the tray does not exist and
400    /// [`DiceError::SlotNotFound`] when the slot does not exist in that tray.
401    pub fn unlock_slot(&mut self, tray_name: &str, slot_id: u32) -> Result<()> {
402        let slot = self.find_slot_mut(tray_name, slot_id)?;
403        slot.locked = false;
404        Ok(())
405    }
406
407    /// Rolls one die.
408    ///
409    /// Numeric names such as `d13` are supported even when the die is not stored
410    /// in the engine.
411    ///
412    /// # Errors
413    ///
414    /// Returns [`DiceError::InvalidNumericDie`] for numeric dice with fewer than
415    /// two faces and [`DiceError::DieNotFound`] for unresolved named dice.
416    pub fn roll_die(&self, die_name: &str) -> Result<DieRoll> {
417        let mut rng = rand::rng();
418        self.roll_die_with_rng(1, die_name, &mut rng)
419    }
420
421    fn roll_die_with_rng(
422        &self,
423        roll_id: usize,
424        die_name: &str,
425        rng: &mut impl Rng,
426    ) -> Result<DieRoll> {
427        let resolved = self.resolve_roll_die_name(die_name)?;
428
429        Ok(DieRoll {
430            roll_id,
431            die_name: resolved.clone(),
432            value: self.roll_face(&resolved, rng)?,
433        })
434    }
435
436    /// Rolls multiple dice in order.
437    ///
438    /// The returned roll identifiers start at `1` and follow the input order.
439    ///
440    /// # Errors
441    ///
442    /// Returns the first error encountered while resolving or rolling a die.
443    pub fn roll_dice(&self, die_names: &[String]) -> Result<RollBatchResult> {
444        let mut rolls = Vec::with_capacity(die_names.len());
445        let mut integer_sum = 0_i64;
446        let mut has_integer = false;
447        let mut rng = rand::rng();
448
449        for (index, die_name) in die_names.iter().enumerate() {
450            let roll = self.roll_die_with_rng(index + 1, die_name, &mut rng)?;
451            if let FaceValue::Integer(value) = &roll.value {
452                integer_sum += *value;
453                has_integer = true;
454            }
455            rolls.push(roll);
456        }
457
458        Ok(RollBatchResult {
459            rolls,
460            integer_sum: has_integer.then_some(integer_sum),
461        })
462    }
463
464    /// Computes expected value and inclusive point range without rolling.
465    ///
466    /// Integer faces contribute their value. Text faces contribute zero points.
467    /// Modifiers are added to both expected value and range.
468    ///
469    /// # Errors
470    ///
471    /// Returns the first error encountered while resolving or analyzing a die.
472    pub fn analyze_roll(&self, die_names: &[String], modifiers: &[i64]) -> Result<RollAnalysis> {
473        let modifier_sum: i64 = modifiers.iter().sum();
474        let mut expected_value = modifier_sum as f64;
475        let mut min = modifier_sum;
476        let mut max = modifier_sum;
477
478        for die_name in die_names {
479            let resolved = self.resolve_roll_die_name(die_name)?;
480            let die_points = self.analyze_die_points(&resolved)?;
481            expected_value += die_points.expected_value;
482            min += die_points.point_range.min;
483            max += die_points.point_range.max;
484        }
485
486        Ok(RollAnalysis {
487            expected_value,
488            point_range: PointRange { min, max },
489        })
490    }
491
492    /// Rolls every unlocked slot in a tray and returns the updated tray.
493    ///
494    /// Locked slots keep their existing `current_value`.
495    ///
496    /// # Errors
497    ///
498    /// Returns [`DiceError::TrayNotFound`] when the tray does not exist and
499    /// [`DiceError::DieNotFound`] when a tray slot references a missing die.
500    pub fn roll_tray(&mut self, tray_name: &str) -> Result<&Tray> {
501        let tray_index = self
502            .find_tray_index(tray_name)
503            .ok_or_else(|| DiceError::TrayNotFound(tray_name.to_string()))?;
504
505        let slots_to_roll: Vec<(usize, String)> = self.trays[tray_index]
506            .slots
507            .iter()
508            .enumerate()
509            .filter(|(_, slot)| !slot.locked)
510            .map(|(index, slot)| (index, slot.die_name.clone()))
511            .collect();
512
513        let mut rng = rand::rng();
514        for (slot_index, die_name) in slots_to_roll {
515            let value = self.roll_face(&die_name, &mut rng)?;
516            self.trays[tray_index].slots[slot_index].current_value = Some(value);
517        }
518
519        Ok(&self.trays[tray_index])
520    }
521
522    /// Returns a snapshot of a tray and its current slot values.
523    ///
524    /// # Errors
525    ///
526    /// Returns [`DiceError::TrayNotFound`] when the tray does not exist.
527    pub fn show_tray(&self, tray_name: &str) -> Result<TrayResult> {
528        let tray = self
529            .trays
530            .iter()
531            .find(|tray| tray.name == tray_name)
532            .ok_or_else(|| DiceError::TrayNotFound(tray_name.to_string()))?;
533
534        let slots: Vec<SlotResult> = tray
535            .slots
536            .iter()
537            .map(|slot| SlotResult {
538                slot_id: slot.slot_id,
539                die_name: slot.die_name.clone(),
540                locked: slot.locked,
541                current_value: slot.current_value.clone(),
542            })
543            .collect();
544
545        let mut sum = 0_i64;
546        let mut has_integer = false;
547        for slot in &slots {
548            if let Some(FaceValue::Integer(value)) = &slot.current_value {
549                sum += *value;
550                has_integer = true;
551            }
552        }
553
554        Ok(TrayResult {
555            tray_name: tray.name.clone(),
556            slots,
557            integer_sum: has_integer.then_some(sum),
558        })
559    }
560
561    fn roll_face(&self, die_name: &str, rng: &mut impl Rng) -> Result<FaceValue> {
562        if let Some(face_count) = parse_numeric_die_name(die_name) {
563            let value = rng.random_range(1..=face_count);
564            return Ok(FaceValue::Integer(value as i64));
565        }
566
567        let die_index = self
568            .find_die_index(die_name)
569            .ok_or_else(|| DiceError::DieNotFound(die_name.to_string()))?;
570        let faces = &self.dice[die_index].faces;
571        let face_index = rng.random_range(0..faces.len());
572        Ok(faces[face_index].clone())
573    }
574
575    fn analyze_die_points(&self, die_name: &str) -> Result<RollAnalysis> {
576        if let Some(face_count) = parse_numeric_die_name(die_name) {
577            if face_count < 2 {
578                return Err(DiceError::InvalidNumericDie(die_name.to_string()));
579            }
580            return Ok(RollAnalysis {
581                expected_value: (face_count + 1) as f64 / 2.0,
582                point_range: PointRange {
583                    min: 1,
584                    max: face_count as i64,
585                },
586            });
587        }
588
589        let die_index = self
590            .find_die_index(die_name)
591            .ok_or_else(|| DiceError::DieNotFound(die_name.to_string()))?;
592        let points = self.dice[die_index]
593            .faces
594            .iter()
595            .map(face_point_value)
596            .collect::<Vec<_>>();
597        let point_sum: i64 = points.iter().sum();
598        let min = points.iter().min().copied().unwrap_or(0);
599        let max = points.iter().max().copied().unwrap_or(0);
600
601        Ok(RollAnalysis {
602            expected_value: point_sum as f64 / points.len() as f64,
603            point_range: PointRange { min, max },
604        })
605    }
606
607    fn resolve_roll_die_name(&self, name: &str) -> Result<String> {
608        if let Some(face_count) = parse_numeric_die_name(name) {
609            if face_count < 2 {
610                return Err(DiceError::InvalidNumericDie(name.to_string()));
611            }
612            return Ok(format!("D{face_count}"));
613        }
614
615        self.resolve_die_name(name)
616            .ok_or_else(|| DiceError::DieNotFound(Self::die_not_found_name(name)))
617    }
618
619    fn validate_name(name: &str) -> Result<()> {
620        if name.trim().is_empty() || name.chars().any(char::is_whitespace) {
621            return Err(DiceError::InvalidName);
622        }
623        Ok(())
624    }
625
626    fn normalize_custom_name(name: &str) -> Result<String> {
627        let base_name = name.strip_prefix(CUSTOM_PREFIX).unwrap_or(name);
628        Self::validate_name(base_name)?;
629        Ok(format!("{CUSTOM_PREFIX}{base_name}"))
630    }
631
632    fn die_not_found_name(name: &str) -> String {
633        if name.starts_with(CUSTOM_PREFIX) || looks_like_builtin_name(name) {
634            name.to_string()
635        } else {
636            format!("{CUSTOM_PREFIX}{name}")
637        }
638    }
639
640    fn find_die_index(&self, name: &str) -> Option<usize> {
641        self.dice.iter().position(|die| die.name == name)
642    }
643
644    fn find_tray_index(&self, name: &str) -> Option<usize> {
645        self.trays.iter().position(|tray| tray.name == name)
646    }
647
648    fn find_tray_mut(&mut self, tray_name: &str) -> Result<&mut Tray> {
649        self.trays
650            .iter_mut()
651            .find(|tray| tray.name == tray_name)
652            .ok_or_else(|| DiceError::TrayNotFound(tray_name.to_string()))
653    }
654
655    fn find_slot_mut(&mut self, tray_name: &str, slot_id: u32) -> Result<&mut TraySlot> {
656        let tray = self.find_tray_mut(tray_name)?;
657        tray.slots
658            .iter_mut()
659            .find(|slot| slot.slot_id == slot_id)
660            .ok_or_else(|| DiceError::SlotNotFound {
661                tray: tray_name.to_string(),
662                slot_id,
663            })
664    }
665
666    fn is_builtin_name(&self, name: &str) -> bool {
667        self.dice
668            .iter()
669            .any(|die| die.kind == DieKind::Builtin && die.name == name)
670    }
671}
672
673impl Default for DiceEngine {
674    fn default() -> Self {
675        Self::new()
676    }
677}
678
679fn looks_like_builtin_name(name: &str) -> bool {
680    name.starts_with('D')
681}
682
683fn parse_numeric_die_name(name: &str) -> Option<u64> {
684    let face_count = name.strip_prefix('d').or_else(|| name.strip_prefix('D'))?;
685    if face_count.is_empty() || !face_count.chars().all(|ch| ch.is_ascii_digit()) {
686        return None;
687    }
688    face_count.parse().ok()
689}
690
691fn face_point_value(face: &FaceValue) -> i64 {
692    match face {
693        FaceValue::Integer(value) => *value,
694        FaceValue::Text(_) => 0,
695    }
696}