rosu_pp/catch/performance/
mod.rs

1use std::cmp::{self, Ordering};
2
3use rosu_map::section::general::GameMode;
4
5use self::calculator::CatchPerformanceCalculator;
6
7use crate::{
8    any::{Difficulty, IntoModePerformance, IntoPerformance},
9    model::{mode::ConvertError, mods::GameMods},
10    osu::OsuPerformance,
11    util::map_or_attrs::MapOrAttrs,
12    Performance,
13};
14
15use super::{attributes::CatchPerformanceAttributes, score_state::CatchScoreState, Catch};
16
17mod calculator;
18pub mod gradual;
19
20/// Performance calculator on osu!catch maps.
21#[derive(Clone, Debug, PartialEq)]
22#[must_use]
23pub struct CatchPerformance<'map> {
24    map_or_attrs: MapOrAttrs<'map, Catch>,
25    difficulty: Difficulty,
26    acc: Option<f64>,
27    combo: Option<u32>,
28    fruits: Option<u32>,
29    droplets: Option<u32>,
30    tiny_droplets: Option<u32>,
31    tiny_droplet_misses: Option<u32>,
32    misses: Option<u32>,
33}
34
35impl<'map> CatchPerformance<'map> {
36    /// Create a new performance calculator for osu!catch maps.
37    ///
38    /// The argument `map_or_attrs` must be either
39    /// - previously calculated attributes ([`CatchDifficultyAttributes`]
40    ///   or [`CatchPerformanceAttributes`])
41    /// - a [`Beatmap`] (by reference or value)
42    ///
43    /// If a map is given, difficulty attributes will need to be calculated
44    /// internally which is a costly operation. Hence, passing attributes
45    /// should be prefered.
46    ///
47    /// However, when passing previously calculated attributes, make sure they
48    /// have been calculated for the same map and [`Difficulty`] settings.
49    /// Otherwise, the final attributes will be incorrect.
50    ///
51    /// [`Beatmap`]: crate::model::beatmap::Beatmap
52    /// [`CatchDifficultyAttributes`]: crate::catch::CatchDifficultyAttributes
53    pub fn new(map_or_attrs: impl IntoModePerformance<'map, Catch>) -> Self {
54        map_or_attrs.into_performance()
55    }
56
57    /// Try to create a new performance calculator for osu!catch maps.
58    ///
59    /// Returns `None` if `map_or_attrs` does not belong to osu!catch i.e.
60    /// a [`DifficultyAttributes`] or [`PerformanceAttributes`] of a different
61    /// mode.
62    ///
63    /// See [`CatchPerformance::new`] for more information.
64    ///
65    /// [`DifficultyAttributes`]: crate::any::DifficultyAttributes
66    /// [`PerformanceAttributes`]: crate::any::PerformanceAttributes
67    pub fn try_new(map_or_attrs: impl IntoPerformance<'map>) -> Option<Self> {
68        if let Performance::Catch(calc) = map_or_attrs.into_performance() {
69            Some(calc)
70        } else {
71            None
72        }
73    }
74
75    /// Specify mods.
76    ///
77    /// Accepted types are
78    /// - `u32`
79    /// - [`rosu_mods::GameModsLegacy`]
80    /// - [`rosu_mods::GameMods`]
81    /// - [`rosu_mods::GameModsIntermode`]
82    /// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
83    ///
84    /// See <https://github.com/ppy/osu-api/wiki#mods>
85    pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
86        self.difficulty = self.difficulty.mods(mods);
87
88        self
89    }
90
91    /// Specify the max combo of the play.
92    pub const fn combo(mut self, combo: u32) -> Self {
93        self.combo = Some(combo);
94
95        self
96    }
97
98    /// Specify the amount of fruits of a play i.e. n300.
99    pub const fn fruits(mut self, n_fruits: u32) -> Self {
100        self.fruits = Some(n_fruits);
101
102        self
103    }
104
105    /// Specify the amount of droplets of a play i.e. n100.
106    pub const fn droplets(mut self, n_droplets: u32) -> Self {
107        self.droplets = Some(n_droplets);
108
109        self
110    }
111
112    /// Specify the amount of tiny droplets of a play i.e. n50.
113    pub const fn tiny_droplets(mut self, n_tiny_droplets: u32) -> Self {
114        self.tiny_droplets = Some(n_tiny_droplets);
115
116        self
117    }
118
119    /// Specify the amount of tiny droplet misses of a play i.e. `n_katu`.
120    pub const fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: u32) -> Self {
121        self.tiny_droplet_misses = Some(n_tiny_droplet_misses);
122
123        self
124    }
125
126    /// Specify the amount of fruit / droplet misses of the play.
127    pub const fn misses(mut self, n_misses: u32) -> Self {
128        self.misses = Some(n_misses);
129
130        self
131    }
132
133    /// Use the specified settings of the given [`Difficulty`].
134    pub fn difficulty(mut self, difficulty: Difficulty) -> Self {
135        self.difficulty = difficulty;
136
137        self
138    }
139
140    /// Amount of passed objects for partial plays, e.g. a fail.
141    ///
142    /// If you want to calculate the performance after every few objects,
143    /// instead of using [`CatchPerformance`] multiple times with different
144    /// `passed_objects`, you should use [`CatchGradualPerformance`].
145    ///
146    /// [`CatchGradualPerformance`]: crate::catch::CatchGradualPerformance
147    pub fn passed_objects(mut self, passed_objects: u32) -> Self {
148        self.difficulty = self.difficulty.passed_objects(passed_objects);
149
150        self
151    }
152
153    /// Adjust the clock rate used in the calculation.
154    ///
155    /// If none is specified, it will take the clock rate based on the mods
156    /// i.e. 1.5 for DT, 0.75 for HT and 1.0 otherwise.
157    ///
158    /// | Minimum | Maximum |
159    /// | :-----: | :-----: |
160    /// | 0.01    | 100     |
161    pub fn clock_rate(mut self, clock_rate: f64) -> Self {
162        self.difficulty = self.difficulty.clock_rate(clock_rate);
163
164        self
165    }
166
167    /// Override a beatmap's set AR.
168    ///
169    /// `with_mods` determines if the given value should be used before
170    /// or after accounting for mods, e.g. on `true` the value will be
171    /// used as is and on `false` it will be modified based on the mods.
172    ///
173    /// | Minimum | Maximum |
174    /// | :-----: | :-----: |
175    /// | -20     | 20      |
176    pub fn ar(mut self, ar: f32, with_mods: bool) -> Self {
177        self.difficulty = self.difficulty.ar(ar, with_mods);
178
179        self
180    }
181
182    /// Override a beatmap's set CS.
183    ///
184    /// `with_mods` determines if the given value should be used before
185    /// or after accounting for mods, e.g. on `true` the value will be
186    /// used as is and on `false` it will be modified based on the mods.
187    ///
188    /// | Minimum | Maximum |
189    /// | :-----: | :-----: |
190    /// | -20     | 20      |
191    pub fn cs(mut self, cs: f32, with_mods: bool) -> Self {
192        self.difficulty = self.difficulty.cs(cs, with_mods);
193
194        self
195    }
196
197    /// Override a beatmap's set HP.
198    ///
199    /// `with_mods` determines if the given value should be used before
200    /// or after accounting for mods, e.g. on `true` the value will be
201    /// used as is and on `false` it will be modified based on the mods.
202    ///
203    /// | Minimum | Maximum |
204    /// | :-----: | :-----: |
205    /// | -20     | 20      |
206    pub fn hp(mut self, hp: f32, with_mods: bool) -> Self {
207        self.difficulty = self.difficulty.hp(hp, with_mods);
208
209        self
210    }
211
212    /// Override a beatmap's set OD.
213    ///
214    /// `with_mods` determines if the given value should be used before
215    /// or after accounting for mods, e.g. on `true` the value will be
216    /// used as is and on `false` it will be modified based on the mods.
217    ///
218    /// | Minimum | Maximum |
219    /// | :-----: | :-----: |
220    /// | -20     | 20      |
221    pub fn od(mut self, od: f32, with_mods: bool) -> Self {
222        self.difficulty = self.difficulty.od(od, with_mods);
223
224        self
225    }
226
227    /// Adjust patterns as if the HR mod is enabled.
228    pub fn hardrock_offsets(mut self, hardrock_offsets: bool) -> Self {
229        self.difficulty = self.difficulty.hardrock_offsets(hardrock_offsets);
230
231        self
232    }
233
234    /// Provide parameters through an [`CatchScoreState`].
235    #[allow(clippy::needless_pass_by_value)]
236    pub const fn state(mut self, state: CatchScoreState) -> Self {
237        let CatchScoreState {
238            max_combo,
239            fruits: n_fruits,
240            droplets: n_droplets,
241            tiny_droplets: n_tiny_droplets,
242            tiny_droplet_misses: n_tiny_droplet_misses,
243            misses,
244        } = state;
245
246        self.combo = Some(max_combo);
247        self.fruits = Some(n_fruits);
248        self.droplets = Some(n_droplets);
249        self.tiny_droplets = Some(n_tiny_droplets);
250        self.tiny_droplet_misses = Some(n_tiny_droplet_misses);
251        self.misses = Some(misses);
252
253        self
254    }
255
256    /// Specify the accuracy of a play between `0.0` and `100.0`.
257    /// This will be used to generate matching hitresults.
258    pub fn accuracy(mut self, acc: f64) -> Self {
259        self.acc = Some(acc.clamp(0.0, 100.0) / 100.0);
260
261        self
262    }
263
264    /// Create the [`CatchScoreState`] that will be used for performance calculation.
265    #[allow(clippy::too_many_lines)]
266    pub fn generate_state(&mut self) -> Result<CatchScoreState, ConvertError> {
267        let attrs = match self.map_or_attrs {
268            MapOrAttrs::Map(ref map) => {
269                let attrs = self.difficulty.calculate_for_mode::<Catch>(map)?;
270
271                self.map_or_attrs.insert_attrs(attrs)
272            }
273            MapOrAttrs::Attrs(ref attrs) => attrs,
274        };
275
276        let misses = self
277            .misses
278            .map_or(0, |n| cmp::min(n, attrs.n_fruits + attrs.n_droplets));
279
280        let max_combo = self.combo.unwrap_or_else(|| attrs.max_combo() - misses);
281
282        let mut best_state = CatchScoreState {
283            max_combo,
284            misses,
285            ..Default::default()
286        };
287
288        let mut best_dist = f64::INFINITY;
289
290        let (n_fruits, n_droplets) = match (self.fruits, self.droplets) {
291            (Some(mut n_fruits), Some(mut n_droplets)) => {
292                let n_remaining = (attrs.n_fruits + attrs.n_droplets)
293                    .saturating_sub(n_fruits + n_droplets + misses);
294
295                let new_droplets =
296                    cmp::min(n_remaining, attrs.n_droplets.saturating_sub(n_droplets));
297                n_droplets += new_droplets;
298                n_fruits += n_remaining - new_droplets;
299
300                n_fruits = cmp::min(
301                    n_fruits,
302                    (attrs.n_fruits + attrs.n_droplets).saturating_sub(n_droplets + misses),
303                );
304                n_droplets = cmp::min(
305                    n_droplets,
306                    attrs.n_fruits + attrs.n_droplets - n_fruits - misses,
307                );
308
309                (n_fruits, n_droplets)
310            }
311            (Some(mut n_fruits), None) => {
312                let n_droplets = attrs
313                    .n_droplets
314                    .saturating_sub(misses.saturating_sub(attrs.n_fruits.saturating_sub(n_fruits)));
315
316                n_fruits = attrs.n_fruits + attrs.n_droplets - misses - n_droplets;
317
318                (n_fruits, n_droplets)
319            }
320            (None, Some(mut n_droplets)) => {
321                let n_fruits = attrs.n_fruits.saturating_sub(
322                    misses.saturating_sub(attrs.n_droplets.saturating_sub(n_droplets)),
323                );
324
325                n_droplets = attrs.n_fruits + attrs.n_droplets - misses - n_fruits;
326
327                (n_fruits, n_droplets)
328            }
329            (None, None) => {
330                let n_droplets = attrs.n_droplets.saturating_sub(misses);
331                let n_fruits =
332                    attrs.n_fruits - (misses - (attrs.n_droplets.saturating_sub(n_droplets)));
333
334                (n_fruits, n_droplets)
335            }
336        };
337
338        best_state.fruits = n_fruits;
339        best_state.droplets = n_droplets;
340
341        let mut find_best_tiny_droplets = |acc: f64| {
342            let raw_tiny_droplets = acc
343                * f64::from(attrs.n_fruits + attrs.n_droplets + attrs.n_tiny_droplets)
344                - f64::from(n_fruits + n_droplets);
345            let min_tiny_droplets =
346                cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.floor() as u32);
347            let max_tiny_droplets =
348                cmp::min(attrs.n_tiny_droplets, raw_tiny_droplets.ceil() as u32);
349
350            // Hopefully using `HitResultPriority::Fastest` wouldn't make a big
351            // difference here so let's be lazy and ignore it
352            for n_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
353                let n_tiny_droplet_misses = attrs.n_tiny_droplets - n_tiny_droplets;
354
355                let curr_acc = accuracy(
356                    n_fruits,
357                    n_droplets,
358                    n_tiny_droplets,
359                    n_tiny_droplet_misses,
360                    misses,
361                );
362                let curr_dist = (acc - curr_acc).abs();
363
364                if curr_dist < best_dist {
365                    best_dist = curr_dist;
366                    best_state.tiny_droplets = n_tiny_droplets;
367                    best_state.tiny_droplet_misses = n_tiny_droplet_misses;
368                }
369            }
370        };
371
372        #[allow(clippy::single_match_else)]
373        match (self.tiny_droplets, self.tiny_droplet_misses) {
374            (Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => match self.acc {
375                Some(acc) => {
376                    match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&attrs.n_tiny_droplets) {
377                        Ordering::Equal => {
378                            best_state.tiny_droplets = n_tiny_droplets;
379                            best_state.tiny_droplet_misses = n_tiny_droplet_misses;
380                        }
381                        Ordering::Less | Ordering::Greater => find_best_tiny_droplets(acc),
382                    }
383                }
384                None => {
385                    let n_remaining = attrs
386                        .n_tiny_droplets
387                        .saturating_sub(n_tiny_droplets + n_tiny_droplet_misses);
388
389                    best_state.tiny_droplets = n_tiny_droplets + n_remaining;
390                    best_state.tiny_droplet_misses = n_tiny_droplet_misses;
391                }
392            },
393            (Some(n_tiny_droplets), None) => {
394                best_state.tiny_droplets = cmp::min(attrs.n_tiny_droplets, n_tiny_droplets);
395                best_state.tiny_droplet_misses =
396                    attrs.n_tiny_droplets.saturating_sub(n_tiny_droplets);
397            }
398            (None, Some(n_tiny_droplet_misses)) => {
399                best_state.tiny_droplets =
400                    attrs.n_tiny_droplets.saturating_sub(n_tiny_droplet_misses);
401                best_state.tiny_droplet_misses =
402                    cmp::min(attrs.n_tiny_droplets, n_tiny_droplet_misses);
403            }
404            (None, None) => match self.acc {
405                Some(acc) => find_best_tiny_droplets(acc),
406                None => best_state.tiny_droplets = attrs.n_tiny_droplets,
407            },
408        }
409
410        self.combo = Some(best_state.max_combo);
411        self.fruits = Some(best_state.fruits);
412        self.droplets = Some(best_state.droplets);
413        self.tiny_droplets = Some(best_state.tiny_droplets);
414        self.tiny_droplet_misses = Some(best_state.tiny_droplet_misses);
415        self.misses = Some(best_state.misses);
416
417        Ok(best_state)
418    }
419
420    /// Calculate all performance related values, including pp and stars.
421    pub fn calculate(mut self) -> Result<CatchPerformanceAttributes, ConvertError> {
422        let state = self.generate_state()?;
423
424        let attrs = match self.map_or_attrs {
425            MapOrAttrs::Attrs(attrs) => attrs,
426            MapOrAttrs::Map(ref map) => self.difficulty.calculate_for_mode::<Catch>(map)?,
427        };
428
429        Ok(CatchPerformanceCalculator::new(attrs, self.difficulty.get_mods(), state).calculate())
430    }
431
432    pub(crate) const fn from_map_or_attrs(map_or_attrs: MapOrAttrs<'map, Catch>) -> Self {
433        Self {
434            map_or_attrs,
435            difficulty: Difficulty::new(),
436            acc: None,
437            combo: None,
438            fruits: None,
439            droplets: None,
440            tiny_droplets: None,
441            tiny_droplet_misses: None,
442            misses: None,
443        }
444    }
445}
446
447impl<'map> TryFrom<OsuPerformance<'map>> for CatchPerformance<'map> {
448    type Error = OsuPerformance<'map>;
449
450    /// Try to create [`CatchPerformance`] through [`OsuPerformance`].
451    ///
452    /// Returns `None` if [`OsuPerformance`] does not contain a beatmap, i.e.
453    /// if it was constructed through attributes or
454    /// [`OsuPerformance::generate_state`] was called.
455    fn try_from(mut osu: OsuPerformance<'map>) -> Result<Self, Self::Error> {
456        let mods = osu.difficulty.get_mods();
457
458        let map = match OsuPerformance::try_convert_map(osu.map_or_attrs, GameMode::Catch, mods) {
459            Ok(map) => map,
460            Err(map_or_attrs) => {
461                osu.map_or_attrs = map_or_attrs;
462
463                return Err(osu);
464            }
465        };
466
467        let OsuPerformance {
468            map_or_attrs: _,
469            difficulty,
470            acc,
471            combo,
472            large_tick_hits: _,
473            small_tick_hits: _,
474            slider_end_hits: _,
475            n300,
476            n100,
477            n50,
478            misses,
479            hitresult_priority: _,
480        } = osu;
481
482        Ok(Self {
483            map_or_attrs: MapOrAttrs::Map(map),
484            difficulty,
485            acc,
486            combo,
487            fruits: n300,
488            droplets: n100,
489            tiny_droplets: n50,
490            tiny_droplet_misses: None,
491            misses,
492        })
493    }
494}
495
496impl<'map, T: IntoModePerformance<'map, Catch>> From<T> for CatchPerformance<'map> {
497    fn from(into: T) -> Self {
498        into.into_performance()
499    }
500}
501
502fn accuracy(
503    n_fruits: u32,
504    n_droplets: u32,
505    n_tiny_droplets: u32,
506    n_tiny_droplet_misses: u32,
507    misses: u32,
508) -> f64 {
509    let numerator = n_fruits + n_droplets + n_tiny_droplets;
510    let denominator = numerator + n_tiny_droplet_misses + misses;
511
512    f64::from(numerator) / f64::from(denominator)
513}
514
515#[cfg(test)]
516mod test {
517    use std::sync::OnceLock;
518
519    use proptest::prelude::*;
520    use rosu_map::section::general::GameMode;
521
522    use crate::{
523        any::{DifficultyAttributes, PerformanceAttributes},
524        catch::CatchDifficultyAttributes,
525        osu::{OsuDifficultyAttributes, OsuPerformanceAttributes},
526        Beatmap,
527    };
528
529    use super::*;
530
531    static ATTRS: OnceLock<CatchDifficultyAttributes> = OnceLock::new();
532
533    const N_FRUITS: u32 = 728;
534    const N_DROPLETS: u32 = 2;
535    const N_TINY_DROPLETS: u32 = 263;
536
537    fn beatmap() -> Beatmap {
538        Beatmap::from_path("./resources/2118524.osu").unwrap()
539    }
540
541    fn attrs() -> CatchDifficultyAttributes {
542        ATTRS
543            .get_or_init(|| {
544                let map = beatmap();
545                let attrs = Difficulty::new().calculate_for_mode::<Catch>(&map).unwrap();
546
547                assert_eq!(N_FRUITS, attrs.n_fruits);
548                assert_eq!(N_DROPLETS, attrs.n_droplets);
549                assert_eq!(N_TINY_DROPLETS, attrs.n_tiny_droplets);
550
551                attrs
552            })
553            .to_owned()
554    }
555
556    /// Checks all remaining hitresult combinations w.r.t. the given parameters
557    /// and returns the [`CatchScoreState`] that matches `acc` the best.
558    ///
559    /// Very slow but accurate.
560    fn brute_force_best(
561        acc: f64,
562        n_fruits: Option<u32>,
563        n_droplets: Option<u32>,
564        n_tiny_droplets: Option<u32>,
565        n_tiny_droplet_misses: Option<u32>,
566        misses: u32,
567    ) -> CatchScoreState {
568        let misses = cmp::min(misses, N_FRUITS + N_DROPLETS);
569
570        let mut best_state = CatchScoreState {
571            max_combo: N_FRUITS + N_DROPLETS - misses,
572            misses,
573            ..Default::default()
574        };
575
576        let mut best_dist = f64::INFINITY;
577
578        let (new_fruits, new_droplets) = match (n_fruits, n_droplets) {
579            (Some(mut n_fruits), Some(mut n_droplets)) => {
580                let n_remaining =
581                    (N_FRUITS + N_DROPLETS).saturating_sub(n_fruits + n_droplets + misses);
582
583                let new_droplets = cmp::min(n_remaining, N_DROPLETS.saturating_sub(n_droplets));
584                n_droplets += new_droplets;
585                n_fruits += n_remaining - new_droplets;
586
587                n_fruits = cmp::min(
588                    n_fruits,
589                    (N_FRUITS + N_DROPLETS).saturating_sub(n_droplets + misses),
590                );
591                n_droplets = cmp::min(n_droplets, N_FRUITS + N_DROPLETS - n_fruits - misses);
592
593                (n_fruits, n_droplets)
594            }
595            (Some(mut n_fruits), None) => {
596                let n_droplets = N_DROPLETS
597                    .saturating_sub(misses.saturating_sub(N_FRUITS.saturating_sub(n_fruits)));
598                n_fruits = N_FRUITS + N_DROPLETS - misses - n_droplets;
599
600                (n_fruits, n_droplets)
601            }
602            (None, Some(mut n_droplets)) => {
603                let n_fruits = N_FRUITS
604                    .saturating_sub(misses.saturating_sub(N_DROPLETS.saturating_sub(n_droplets)));
605                n_droplets = N_FRUITS + N_DROPLETS - misses - n_fruits;
606
607                (n_fruits, n_droplets)
608            }
609            (None, None) => {
610                let n_droplets = N_DROPLETS.saturating_sub(misses);
611                let n_fruits = N_FRUITS - (misses - (N_DROPLETS.saturating_sub(n_droplets)));
612
613                (n_fruits, n_droplets)
614            }
615        };
616
617        best_state.fruits = new_fruits;
618        best_state.droplets = new_droplets;
619
620        let (min_tiny_droplets, max_tiny_droplets) = match (n_tiny_droplets, n_tiny_droplet_misses)
621        {
622            (Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => {
623                match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&N_TINY_DROPLETS) {
624                    Ordering::Equal => (
625                        cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
626                        cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
627                    ),
628                    Ordering::Less | Ordering::Greater => (0, N_TINY_DROPLETS),
629                }
630            }
631            (Some(n_tiny_droplets), None) => (
632                cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
633                cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
634            ),
635            (None, Some(n_tiny_droplet_misses)) => (
636                N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
637                N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
638            ),
639            (None, None) => (0, N_TINY_DROPLETS),
640        };
641
642        for new_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
643            let new_tiny_droplet_misses = N_TINY_DROPLETS - new_tiny_droplets;
644
645            let curr_acc = accuracy(
646                new_fruits,
647                new_droplets,
648                new_tiny_droplets,
649                new_tiny_droplet_misses,
650                misses,
651            );
652
653            let curr_dist = (acc - curr_acc).abs();
654
655            if curr_dist < best_dist {
656                best_dist = curr_dist;
657                best_state.tiny_droplets = new_tiny_droplets;
658                best_state.tiny_droplet_misses = new_tiny_droplet_misses;
659            }
660        }
661
662        best_state
663    }
664
665    proptest! {
666        #![proptest_config(ProptestConfig::with_cases(1000))]
667
668        #[test]
669        fn hitresults(
670            acc in 0.0..=1.0,
671            n_fruits in prop::option::weighted(0.10, 0_u32..=N_FRUITS + 10),
672            n_droplets in prop::option::weighted(0.10, 0_u32..=N_DROPLETS + 10),
673            n_tiny_droplets in prop::option::weighted(0.10, 0_u32..=N_TINY_DROPLETS + 10),
674            n_tiny_droplet_misses in prop::option::weighted(0.10, 0_u32..=N_TINY_DROPLETS + 10),
675            n_misses in prop::option::weighted(0.15, 0_u32..=N_FRUITS + N_DROPLETS + 10),
676        ) {
677            let mut state = CatchPerformance::from(attrs())
678                .accuracy(acc * 100.0);
679
680            if let Some(n_fruits) = n_fruits {
681                state = state.fruits(n_fruits);
682            }
683
684            if let Some(n_droplets) = n_droplets {
685                state = state.droplets(n_droplets);
686            }
687
688            if let Some(n_tiny_droplets) = n_tiny_droplets {
689                state = state.tiny_droplets(n_tiny_droplets);
690            }
691
692            if let Some(n_tiny_droplet_misses) = n_tiny_droplet_misses {
693                state = state.tiny_droplet_misses(n_tiny_droplet_misses);
694            }
695
696            if let Some(misses) = n_misses {
697                state = state.misses(misses);
698            }
699
700            let first = state.generate_state().unwrap();
701            let state = state.generate_state().unwrap();
702            assert_eq!(first, state);
703
704            let expected = brute_force_best(
705                acc,
706                n_fruits,
707                n_droplets,
708                n_tiny_droplets,
709                n_tiny_droplet_misses,
710                n_misses.unwrap_or(0),
711            );
712
713            assert_eq!(state, expected);
714        }
715    }
716
717    #[test]
718    fn fruits_missing_objects() {
719        let state = CatchPerformance::from(attrs())
720            .fruits(N_FRUITS - 10)
721            .droplets(N_DROPLETS - 1)
722            .tiny_droplets(N_TINY_DROPLETS - 50)
723            .tiny_droplet_misses(20)
724            .misses(2)
725            .generate_state()
726            .unwrap();
727
728        let expected = CatchScoreState {
729            max_combo: N_FRUITS + N_DROPLETS - 2,
730            fruits: N_FRUITS - 2,
731            droplets: N_DROPLETS,
732            tiny_droplets: N_TINY_DROPLETS - 20,
733            tiny_droplet_misses: 20,
734            misses: 2,
735        };
736
737        assert_eq!(state, expected);
738    }
739
740    #[test]
741    fn create() {
742        let mut map = beatmap();
743
744        let _ = CatchPerformance::new(CatchDifficultyAttributes::default());
745        let _ = CatchPerformance::new(CatchPerformanceAttributes::default());
746        let _ = CatchPerformance::new(&map);
747        let _ = CatchPerformance::new(map.clone());
748
749        let _ = CatchPerformance::try_new(CatchDifficultyAttributes::default()).unwrap();
750        let _ = CatchPerformance::try_new(CatchPerformanceAttributes::default()).unwrap();
751        let _ = CatchPerformance::try_new(DifficultyAttributes::Catch(
752            CatchDifficultyAttributes::default(),
753        ))
754        .unwrap();
755        let _ = CatchPerformance::try_new(PerformanceAttributes::Catch(
756            CatchPerformanceAttributes::default(),
757        ))
758        .unwrap();
759        let _ = CatchPerformance::try_new(&map).unwrap();
760        let _ = CatchPerformance::try_new(map.clone()).unwrap();
761
762        let _ = CatchPerformance::from(CatchDifficultyAttributes::default());
763        let _ = CatchPerformance::from(CatchPerformanceAttributes::default());
764        let _ = CatchPerformance::from(&map);
765        let _ = CatchPerformance::from(map.clone());
766
767        let _ = CatchDifficultyAttributes::default().performance();
768        let _ = CatchPerformanceAttributes::default().performance();
769
770        assert!(map
771            .convert_mut(GameMode::Osu, &GameMods::default())
772            .is_err());
773
774        assert!(CatchPerformance::try_new(OsuDifficultyAttributes::default()).is_none());
775        assert!(CatchPerformance::try_new(OsuPerformanceAttributes::default()).is_none());
776        assert!(CatchPerformance::try_new(DifficultyAttributes::Osu(
777            OsuDifficultyAttributes::default()
778        ))
779        .is_none());
780        assert!(CatchPerformance::try_new(PerformanceAttributes::Osu(
781            OsuPerformanceAttributes::default()
782        ))
783        .is_none());
784    }
785}