Skip to main content

rosu_pp/catch/performance/
gradual.rs

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