Skip to main content

rosu_pp/osu/performance/
gradual.rs

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