Skip to main content

rosu_pp/mania/performance/
gradual.rs

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