rosu_pp/catch/performance/
gradual.rs

1use crate::{
2    catch::{CatchGradualDifficulty, CatchPerformanceAttributes, CatchScoreState},
3    model::mode::ConvertError,
4    Beatmap, Difficulty,
5};
6
7/// Gradually calculate the performance attributes of an osu!catch map.
8///
9/// After each hit object you can call [`next`] and it will return the resulting
10/// current [`CatchPerformanceAttributes`]. To process multiple objects at once,
11/// use [`nth`] instead.
12///
13/// Both methods require a [`CatchScoreState`] that contains the current
14/// hitresults as well as the maximum combo so far.
15///
16/// Note that neither hits nor misses of tiny droplets require to be processed.
17/// Only fruits and droplets do.
18///
19/// If you only want to calculate difficulty attributes use
20/// [`CatchGradualDifficulty`] instead.
21///
22/// # Example
23///
24/// ```
25/// use rosu_pp::{Beatmap, Difficulty};
26/// use rosu_pp::catch::{Catch, CatchGradualPerformance, CatchScoreState};
27///
28/// let map = Beatmap::from_path("./resources/2118524.osu").unwrap();
29///
30/// let difficulty = Difficulty::new().mods(64); // DT
31/// let mut gradual = CatchGradualPerformance::new(difficulty, &map).unwrap();
32/// let mut state = CatchScoreState::new(); // empty state, everything is on 0.
33///
34/// // The first 10 hitresults are only fruits
35/// for _ in 0..10 {
36///     state.fruits += 1;
37///     state.max_combo += 1;
38///
39///     let attrs = gradual.next(state.clone()).unwrap();
40///     println!("PP: {}", attrs.pp);
41/// }
42///
43/// // Then comes a miss.
44/// // Note that state's max combo won't be incremented for
45/// // the next few objects because the combo is reset.
46/// state.misses += 1;
47/// let attrs = gradual.next(state.clone()).unwrap();
48/// println!("PP: {}", attrs.pp);
49///
50/// // The next 10 objects will be a mixture of fruits and droplets.
51/// // Notice how tiny droplets from sliders do not count as hit objects
52/// // that require processing. Only fruits and droplets do.
53/// // Also notice how all 10 objects will be processed in one go.
54/// state.fruits += 4;
55/// state.droplets += 6;
56/// state.tiny_droplets += 12;
57/// // The `nth` method takes a zero-based value.
58/// let attrs = gradual.nth(state.clone(), 9).unwrap();
59/// println!("PP: {}", attrs.pp);
60///
61/// // Now comes another fruit. Note that the max combo gets incremented again.
62/// state.fruits += 1;
63/// state.max_combo += 1;
64/// let attrs = gradual.next(state.clone()).unwrap();
65/// println!("PP: {}", attrs.pp);
66///
67/// // Skip to the end
68/// # /*
69/// state.max_combo = ...
70/// state.fruits = ...
71/// state.droplets = ...
72/// state.tiny_droplets = ...
73/// state.tiny_droplet_misses = ...
74/// state.misses = ...
75/// # */
76/// let attrs = gradual.last(state.clone()).unwrap();
77/// println!("PP: {}", attrs.pp);
78///
79/// // Once the final performance has been calculated, attempting to process
80/// // further objects will return `None`.
81/// assert!(gradual.next(state).is_none());
82/// ```
83///
84/// [`next`]: CatchGradualPerformance::next
85/// [`nth`]: CatchGradualPerformance::nth
86pub struct CatchGradualPerformance {
87    difficulty: CatchGradualDifficulty,
88}
89
90impl CatchGradualPerformance {
91    /// Create a new gradual performance calculator for osu!catch maps.
92    pub fn new(difficulty: Difficulty, map: &Beatmap) -> Result<Self, ConvertError> {
93        let difficulty = CatchGradualDifficulty::new(difficulty, map)?;
94
95        Ok(Self { difficulty })
96    }
97
98    /// Process the next hit object and calculate the performance attributes
99    /// for the resulting score state.
100    ///
101    /// Note that neither hits nor misses of tiny droplets require to be
102    /// processed. Only fruits and droplets do.
103    pub fn next(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
104        self.nth(state, 0)
105    }
106
107    /// Process all remaining hit objects and calculate the final performance
108    /// attributes.
109    pub fn last(&mut self, state: CatchScoreState) -> Option<CatchPerformanceAttributes> {
110        self.nth(state, usize::MAX)
111    }
112
113    /// Process everything up the the next `n`th hit object and calculate the
114    /// performance attributes for the resulting score state.
115    ///
116    /// Note that the count is zero-indexed, so `n=0` will process 1 object,
117    /// `n=1` will process 2, and so on.
118    #[allow(clippy::missing_panics_doc)]
119    pub fn nth(&mut self, state: CatchScoreState, n: usize) -> Option<CatchPerformanceAttributes> {
120        let performance = self
121            .difficulty
122            .nth(n)?
123            .performance()
124            .state(state)
125            .difficulty(self.difficulty.difficulty.clone())
126            .passed_objects(self.difficulty.idx as u32)
127            .calculate()
128            .expect("no conversion required");
129
130        Some(performance)
131    }
132
133    /// Returns the amount of remaining objects.
134    #[allow(clippy::len_without_is_empty)]
135    pub fn len(&self) -> usize {
136        self.difficulty.len()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use crate::{catch::CatchPerformance, Beatmap};
143
144    use super::*;
145
146    #[test]
147    fn next_and_nth() {
148        let map = Beatmap::from_path("./resources/2118524.osu").unwrap();
149
150        let difficulty = Difficulty::new().mods(88); // HDHRDT
151
152        let mut gradual = CatchGradualPerformance::new(difficulty.clone(), &map).unwrap();
153        let mut gradual_2nd = CatchGradualPerformance::new(difficulty.clone(), &map).unwrap();
154        let mut gradual_3rd = CatchGradualPerformance::new(difficulty.clone(), &map).unwrap();
155
156        let mut state = CatchScoreState::default();
157
158        for i in 1.. {
159            state.misses += 1;
160
161            let Some(next_gradual) = gradual.next(state.clone()) else {
162                assert_eq!(i, 731);
163                assert!(gradual_2nd.last(state.clone()).is_none()); // 730 % 2 == 0
164                assert!(gradual_3rd.last(state.clone()).is_some()); // 730 % 3 == 1
165                break;
166            };
167
168            if i % 2 == 0 {
169                let next_gradual_2nd = gradual_2nd.nth(state.clone(), 1).unwrap();
170                assert_eq!(next_gradual, next_gradual_2nd);
171            }
172
173            if i % 3 == 0 {
174                let next_gradual_3rd = gradual_3rd.nth(state.clone(), 2).unwrap();
175                assert_eq!(next_gradual, next_gradual_3rd);
176            }
177
178            let regular_calc = CatchPerformance::new(&map)
179                .difficulty(difficulty.clone())
180                .passed_objects(i as u32)
181                .state(state.clone());
182
183            let expected = regular_calc.calculate().unwrap();
184
185            assert_eq!(next_gradual, expected);
186        }
187    }
188}