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#[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 pub fn new(map_or_attrs: impl IntoModePerformance<'map, Catch>) -> Self {
54 map_or_attrs.into_performance()
55 }
56
57 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 pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
86 self.difficulty = self.difficulty.mods(mods);
87
88 self
89 }
90
91 pub const fn combo(mut self, combo: u32) -> Self {
93 self.combo = Some(combo);
94
95 self
96 }
97
98 pub const fn fruits(mut self, n_fruits: u32) -> Self {
100 self.fruits = Some(n_fruits);
101
102 self
103 }
104
105 pub const fn droplets(mut self, n_droplets: u32) -> Self {
107 self.droplets = Some(n_droplets);
108
109 self
110 }
111
112 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 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 pub const fn misses(mut self, n_misses: u32) -> Self {
128 self.misses = Some(n_misses);
129
130 self
131 }
132
133 pub fn difficulty(mut self, difficulty: Difficulty) -> Self {
135 self.difficulty = difficulty;
136
137 self
138 }
139
140 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 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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}