1use 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#[derive(Debug, Clone, PartialEq)]
15pub struct DieRoll {
16 pub roll_id: usize,
18 pub die_name: String,
20 pub value: FaceValue,
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct RollBatchResult {
27 pub rolls: Vec<DieRoll>,
29 pub integer_sum: Option<i64>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct RollAnalysis {
39 pub expected_value: f64,
41 pub point_range: PointRange,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct PointRange {
48 pub min: i64,
50 pub max: i64,
52}
53
54#[derive(Debug, Clone, PartialEq)]
56pub struct TrayResult {
57 pub tray_name: String,
59 pub slots: Vec<SlotResult>,
61 pub integer_sum: Option<i64>,
66}
67
68#[derive(Debug, Clone, PartialEq)]
70pub struct SlotResult {
71 pub slot_id: u32,
73 pub die_name: String,
75 pub locked: bool,
77 pub current_value: Option<FaceValue>,
79}
80
81#[derive(Debug, Clone)]
87pub struct DiceEngine {
88 dice: Vec<Die>,
89 trays: Vec<Tray>,
90}
91
92impl DiceEngine {
93 pub fn new() -> Self {
95 Self {
96 dice: builtin_dice(),
97 trays: Vec::new(),
98 }
99 }
100
101 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 pub fn set_trays(&mut self, trays: Vec<Tray>) {
118 self.trays = trays;
119 }
120
121 pub fn custom_dice(&self) -> Vec<&Die> {
123 self.dice
124 .iter()
125 .filter(|die| die.kind == DieKind::Custom)
126 .collect()
127 }
128
129 pub fn trays(&self) -> &[Tray] {
131 &self.trays
132 }
133
134 pub fn list_dice(&self) -> &[Die] {
138 &self.dice
139 }
140
141 pub fn list_trays(&self) -> &[Tray] {
143 &self.trays
144 }
145
146 pub fn get_tray(&self, name: &str) -> Option<&Tray> {
148 self.trays.iter().find(|tray| tray.name == name)
149 }
150
151 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}