Skip to main content

i_ching/core/
divination.rs

1use crate::core::reading::{Age, Line, Polarity, Reading};
2use rand::Rng;
3
4pub struct Diviner {
5    rng: rand::rngs::ThreadRng,
6}
7
8impl Diviner {
9    pub fn new() -> Self {
10        Self {
11            rng: rand::rng(),
12        }
13    }
14
15    /// Cast a complete reading using the three coins method
16    pub fn cast_reading(&mut self, question: Option<String>) -> Reading {
17        let lines = [
18            self.cast_line(),
19            self.cast_line(),
20            self.cast_line(),
21            self.cast_line(),
22            self.cast_line(),
23            self.cast_line(),
24        ];
25
26        Reading::new(lines, question)
27    }
28
29    /// Convert a traditional line number (6-9) to a Line
30    ///
31    /// Traditional interpretation:
32    /// - 6 (2+2+2): Old Yin (changing) - probability 1/8
33    /// - 7 (2+2+3): Young Yang - probability 3/8
34    /// - 8 (2+3+3): Young Yin - probability 3/8
35    /// - 9 (3+3+3): Old Yang (changing) - probability 1/8
36    fn number_to_line(number: u8) -> Line {
37        match number {
38            6 => Line::new(Age::Old, Polarity::Yin),    // Old Yin
39            7 => Line::new(Age::Young, Polarity::Yang), // Young Yang
40            8 => Line::new(Age::Young, Polarity::Yin),  // Young Yin
41            9 => Line::new(Age::Old, Polarity::Yang),   // Old Yang
42            _ => panic!("Invalid line number: {}. Must be 6, 7, 8, or 9", number),
43        }
44    }
45
46    /// Cast a single line using three coins
47    ///
48    /// Each coin contributes 2 (tails) or 3 (heads), giving totals of 6-9.
49    /// See `number_to_line` for probability details.
50    fn cast_line(&mut self) -> Line {
51        let coin_sum: u8 = (0..3)
52            .map(|_| if self.rng.random_bool(0.5) { 3 } else { 2 })
53            .sum();
54
55        Self::number_to_line(coin_sum)
56    }
57
58    /// Cast a reading from specific line numbers (6, 7, 8, 9)
59    pub fn cast_reading_from_numbers(
60        &self,
61        numbers: [u8; 6],
62        question: Option<String>,
63    ) -> Result<Reading, anyhow::Error> {
64        let mut lines = [Line::new(Age::Young, Polarity::Yang); 6];
65
66        for (i, &num) in numbers.iter().enumerate() {
67            lines[i] = Line::from_traditional_number(num)?;
68        }
69
70        Ok(Reading::new(lines, question))
71    }
72
73    /// Cast a reading using specific line numbers (for testing)
74    #[cfg(test)]
75    pub fn cast_reading_with_numbers(
76        &mut self,
77        numbers: [u8; 6],
78        question: Option<String>,
79    ) -> Reading {
80        let lines = numbers.map(Self::number_to_line);
81        Reading::new(lines, question)
82    }
83}
84
85impl Default for Diviner {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_line_number_interpretation() {
97        let mut diviner = Diviner::new();
98
99        // Test specific line numbers directly
100        let numbers = [6, 7, 8, 9, 6, 8];
101
102        let reading = diviner.cast_reading_with_numbers(numbers, Some("Test question".to_string()));
103
104        assert_eq!(reading.traditional_numbers(), [6, 7, 8, 9, 6, 8]);
105        assert!(reading.has_changing_lines());
106        assert_eq!(reading.changing_line_positions(), vec![1, 4, 5]); // Lines 1, 4, 5 are changing (6, 9, 6)
107    }
108
109    #[test]
110    fn test_cast_from_numbers() {
111        let diviner = Diviner::new();
112        let numbers = [7, 8, 9, 6, 7, 8];
113
114        let reading = diviner
115            .cast_reading_from_numbers(numbers, Some("Test from numbers".to_string()))
116            .unwrap();
117
118        assert_eq!(reading.traditional_numbers(), numbers);
119        assert!(reading.has_changing_lines());
120        assert_eq!(reading.changing_line_positions(), vec![3, 4]); // Lines 3, 4 are changing (9, 6)
121    }
122
123    #[test]
124    fn test_invalid_numbers() {
125        let diviner = Diviner::new();
126        let invalid_numbers = [7, 8, 5, 6, 7, 8]; // 5 is invalid
127
128        let result = diviner.cast_reading_from_numbers(invalid_numbers, None);
129        assert!(result.is_err());
130        assert!(result
131            .unwrap_err()
132            .to_string()
133            .contains("Invalid line number: 5"));
134    }
135
136    #[test]
137    fn test_random_casting() {
138        let mut diviner = Diviner::new();
139
140        // Just test that it doesn't panic and produces valid results
141        for _ in 0..10 {
142            let reading = diviner.cast_reading(Some("Random test".to_string()));
143
144            // Verify all traditional numbers are valid
145            for &num in &reading.traditional_numbers() {
146                assert!([6, 7, 8, 9].contains(&num));
147            }
148
149            // Verify hexagram number is in valid range
150            let hexagram = reading.primary_hexagram();
151            assert!(hexagram >= 1 && hexagram <= 64);
152        }
153    }
154}