Skip to main content

rosu_pp/catch/performance/hitresult_generator/
closest.rs

1use crate::{
2    any::{
3        HitResultGenerator,
4        hitresult_generator::{Closest, Fast},
5    },
6    catch::{Catch, CatchHitResults, performance::inspect::InspectCatchPerformance},
7};
8
9impl HitResultGenerator<Catch> for Closest {
10    fn generate_hitresults(inspect: InspectCatchPerformance) -> CatchHitResults {
11        <Fast as HitResultGenerator<Catch>>::generate_hitresults(inspect)
12    }
13}
14
15#[cfg(test)]
16mod tests {
17    use crate::{
18        Difficulty,
19        any::HitResultGenerator,
20        catch::{
21            Catch, CatchDifficultyAttributes, CatchHitResults,
22            performance::inspect::InspectCatchPerformance,
23        },
24    };
25
26    use super::*;
27
28    /// Helper function to verify that a result is truly the closest to target accuracy.
29    /// Tests neighboring states to ensure none are closer.
30    fn verify_is_closest(inspect: &InspectCatchPerformance, result: &CatchHitResults) {
31        let actual_acc = result.accuracy();
32        let target_acc = inspect.acc.unwrap();
33        let current_dist = (actual_acc - target_acc).abs();
34
35        let n_fruits = inspect.attrs.n_fruits;
36        let n_droplets = inspect.attrs.n_droplets;
37        let n_tiny_droplets = inspect.attrs.n_tiny_droplets;
38        let misses = inspect.misses();
39
40        // Test all possible single-step variations
41        let variations = [
42            // Increase fruits, decrease droplets (within pool constraint)
43            (1, -1, 0, 0),
44            // Increase fruits, decrease tiny_droplets
45            (1, 0, -1, 0),
46            // Increase droplets, decrease fruits
47            (-1, 1, 0, 0),
48            // Increase droplets, decrease tiny_droplets
49            (0, 1, -1, 0),
50            // Increase tiny_droplets, decrease fruits
51            (-1, 0, 1, 0),
52            // Increase tiny_droplets, decrease droplets
53            (0, -1, 1, 0),
54            // Increase tiny_droplets, decrease tiny_droplet_misses
55            (0, 0, 1, -1),
56            // Increase tiny_droplet_misses, decrease tiny_droplets
57            (0, 0, -1, 1),
58        ];
59
60        for (d_fruits, d_droplets, d_tiny_droplets, d_tiny_droplet_misses) in variations {
61            let new_fruits = (result.fruits as i32 + d_fruits).max(0) as u32;
62            let new_droplets = (result.droplets as i32 + d_droplets).max(0) as u32;
63            let new_tiny_droplets = (result.tiny_droplets as i32 + d_tiny_droplets).max(0) as u32;
64            let new_tiny_droplet_misses =
65                (result.tiny_droplet_misses as i32 + d_tiny_droplet_misses).max(0) as u32;
66
67            // Skip if exceeds limits
68            if new_fruits > n_fruits
69                || new_droplets > n_droplets
70                || new_tiny_droplets > n_tiny_droplets
71                || new_tiny_droplet_misses > n_tiny_droplets
72            {
73                continue;
74            }
75
76            // Skip if violates pool constraints
77            if new_fruits + new_droplets + misses != n_fruits + n_droplets {
78                continue;
79            }
80
81            if new_tiny_droplets + new_tiny_droplet_misses != n_tiny_droplets {
82                continue;
83            }
84
85            // Skip if this violates user constraints
86            if let Some(n) = inspect.fruits {
87                if new_fruits != n {
88                    continue;
89                }
90            }
91
92            if let Some(n) = inspect.droplets {
93                if new_droplets != n {
94                    continue;
95                }
96            }
97
98            if let Some(n) = inspect.tiny_droplets {
99                if new_tiny_droplets != n {
100                    continue;
101                }
102            }
103
104            if let Some(n) = inspect.tiny_droplet_misses {
105                if new_tiny_droplet_misses != n {
106                    continue;
107                }
108            }
109
110            let neighbor = CatchHitResults {
111                fruits: new_fruits,
112                droplets: new_droplets,
113                tiny_droplets: new_tiny_droplets,
114                tiny_droplet_misses: new_tiny_droplet_misses,
115                misses: result.misses,
116            };
117
118            let neighbor_acc = neighbor.accuracy();
119            let neighbor_dist = (neighbor_acc - target_acc).abs();
120
121            assert!(
122                current_dist <= neighbor_dist + 1e-10,
123                "Found closer neighbor! \
124                Current: {result:?} (acc={actual_acc:.6}, dist={current_dist:.6}), \
125                Neighbor: {neighbor:?} (acc={neighbor_acc:.6}, dist={neighbor_dist:.6})",
126            );
127        }
128    }
129
130    #[test]
131    fn perfect_accuracy() {
132        const N_FRUITS: u32 = 100;
133        const N_DROPLETS: u32 = 50;
134        const N_TINY_DROPLETS: u32 = 200;
135
136        let inspect = InspectCatchPerformance {
137            attrs: &CatchDifficultyAttributes {
138                n_fruits: N_FRUITS,
139                n_droplets: N_DROPLETS,
140                n_tiny_droplets: N_TINY_DROPLETS,
141                ..Default::default()
142            },
143            difficulty: &Difficulty::new(),
144            acc: Some(1.0),
145            combo: None,
146            fruits: None,
147            droplets: None,
148            tiny_droplets: None,
149            tiny_droplet_misses: None,
150            misses: Some(0),
151        };
152
153        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
154
155        assert_eq!(result.fruits, N_FRUITS);
156        assert_eq!(result.droplets, N_DROPLETS);
157        assert_eq!(result.tiny_droplets, N_TINY_DROPLETS);
158        assert_eq!(result.tiny_droplet_misses, 0);
159        assert_eq!(result.misses, 0);
160        assert_eq!(result.accuracy(), 1.0);
161
162        verify_is_closest(&inspect, &result);
163    }
164
165    #[test]
166    fn high_accuracy_with_misses() {
167        const N_FRUITS: u32 = 80;
168        const N_DROPLETS: u32 = 40;
169        const N_TINY_DROPLETS: u32 = 100;
170        const ACC: f64 = 0.95;
171
172        let inspect = InspectCatchPerformance {
173            attrs: &CatchDifficultyAttributes {
174                n_fruits: N_FRUITS,
175                n_droplets: N_DROPLETS,
176                n_tiny_droplets: N_TINY_DROPLETS,
177                ..Default::default()
178            },
179            difficulty: &Difficulty::new(),
180            acc: Some(ACC),
181            combo: None,
182            fruits: None,
183            droplets: None,
184            tiny_droplets: None,
185            tiny_droplet_misses: None,
186            misses: Some(5),
187        };
188
189        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
190
191        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
192        assert_eq!(result.misses, 5);
193
194        let actual_acc = result.accuracy();
195        assert!(
196            (actual_acc - ACC).abs() < 0.01,
197            "Expected ~{ACC}, got {actual_acc}"
198        );
199
200        verify_is_closest(&inspect, &result);
201    }
202
203    #[test]
204    fn medium_accuracy() {
205        const N_FRUITS: u32 = 60;
206        const N_DROPLETS: u32 = 30;
207        const N_TINY_DROPLETS: u32 = 150;
208        const ACC: f64 = 0.75;
209
210        let inspect = InspectCatchPerformance {
211            attrs: &CatchDifficultyAttributes {
212                n_fruits: N_FRUITS,
213                n_droplets: N_DROPLETS,
214                n_tiny_droplets: N_TINY_DROPLETS,
215                ..Default::default()
216            },
217            difficulty: &Difficulty::new(),
218            acc: Some(ACC),
219            combo: None,
220            fruits: None,
221            droplets: None,
222            tiny_droplets: None,
223            tiny_droplet_misses: None,
224            misses: Some(15),
225        };
226
227        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
228
229        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
230        assert_eq!(result.misses, 15);
231
232        verify_is_closest(&inspect, &result);
233    }
234
235    #[test]
236    fn low_accuracy() {
237        const N_FRUITS: u32 = 40;
238        const N_DROPLETS: u32 = 20;
239        const N_TINY_DROPLETS: u32 = 80;
240        const ACC: f64 = 0.50;
241
242        let inspect = InspectCatchPerformance {
243            attrs: &CatchDifficultyAttributes {
244                n_fruits: N_FRUITS,
245                n_droplets: N_DROPLETS,
246                n_tiny_droplets: N_TINY_DROPLETS,
247                ..Default::default()
248            },
249            difficulty: &Difficulty::new(),
250            acc: Some(ACC),
251            combo: None,
252            fruits: None,
253            droplets: None,
254            tiny_droplets: None,
255            tiny_droplet_misses: None,
256            misses: Some(20),
257        };
258
259        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
260
261        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
262        assert_eq!(result.misses, 20);
263
264        verify_is_closest(&inspect, &result);
265    }
266
267    #[test]
268    fn fruits_provided() {
269        const N_FRUITS: u32 = 50;
270        const N_DROPLETS: u32 = 25;
271        const N_TINY_DROPLETS: u32 = 100;
272        const ACC: f64 = 0.90;
273
274        let inspect = InspectCatchPerformance {
275            attrs: &CatchDifficultyAttributes {
276                n_fruits: N_FRUITS,
277                n_droplets: N_DROPLETS,
278                n_tiny_droplets: N_TINY_DROPLETS,
279                ..Default::default()
280            },
281            difficulty: &Difficulty::new(),
282            acc: Some(ACC),
283            combo: None,
284            fruits: Some(45),
285            droplets: None,
286            tiny_droplets: None,
287            tiny_droplet_misses: None,
288            misses: Some(3),
289        };
290
291        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
292
293        // Fruits will be adjusted to 47 to satisfy pool constraint
294        assert_eq!(result.fruits, 47);
295        assert_eq!(result.misses, 3);
296        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
297
298        verify_is_closest(&inspect, &result);
299    }
300
301    #[test]
302    fn droplets_provided() {
303        const N_FRUITS: u32 = 50;
304        const N_DROPLETS: u32 = 25;
305        const N_TINY_DROPLETS: u32 = 100;
306        const ACC: f64 = 0.85;
307
308        let inspect = InspectCatchPerformance {
309            attrs: &CatchDifficultyAttributes {
310                n_fruits: N_FRUITS,
311                n_droplets: N_DROPLETS,
312                n_tiny_droplets: N_TINY_DROPLETS,
313                ..Default::default()
314            },
315            difficulty: &Difficulty::new(),
316            acc: Some(ACC),
317            combo: None,
318            fruits: None,
319            droplets: Some(20),
320            tiny_droplets: None,
321            tiny_droplet_misses: None,
322            misses: Some(8),
323        };
324
325        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
326
327        assert_eq!(result.droplets, 20);
328        assert_eq!(result.misses, 8);
329        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
330
331        verify_is_closest(&inspect, &result);
332    }
333
334    #[test]
335    fn tiny_droplets_provided() {
336        const N_FRUITS: u32 = 40;
337        const N_DROPLETS: u32 = 20;
338        const N_TINY_DROPLETS: u32 = 80;
339        const ACC: f64 = 0.80;
340
341        let inspect = InspectCatchPerformance {
342            attrs: &CatchDifficultyAttributes {
343                n_fruits: N_FRUITS,
344                n_droplets: N_DROPLETS,
345                n_tiny_droplets: N_TINY_DROPLETS,
346                ..Default::default()
347            },
348            difficulty: &Difficulty::new(),
349            acc: Some(ACC),
350            combo: None,
351            fruits: None,
352            droplets: None,
353            tiny_droplets: Some(60),
354            tiny_droplet_misses: None,
355            misses: Some(10),
356        };
357
358        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
359
360        assert_eq!(result.tiny_droplets, 60);
361        assert_eq!(result.misses, 10);
362        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
363
364        verify_is_closest(&inspect, &result);
365    }
366
367    #[test]
368    fn all_values_provided() {
369        const N_FRUITS: u32 = 30;
370        const N_DROPLETS: u32 = 15;
371        const N_TINY_DROPLETS: u32 = 60;
372
373        let inspect = InspectCatchPerformance {
374            attrs: &CatchDifficultyAttributes {
375                n_fruits: N_FRUITS,
376                n_droplets: N_DROPLETS,
377                n_tiny_droplets: N_TINY_DROPLETS,
378                ..Default::default()
379            },
380            difficulty: &Difficulty::new(),
381            acc: Some(0.85),
382            combo: None,
383            fruits: Some(25),
384            droplets: Some(12),
385            tiny_droplets: Some(50),
386            tiny_droplet_misses: Some(10),
387            misses: Some(5),
388        };
389
390        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
391
392        // When all values provided but don't sum correctly:
393        // Priority: misses > fruits > droplets
394        // fruits=25, droplets=12, misses=5 sum to 42, but pool needs 45
395        // So: keep misses=5, keep fruits=25, adjust droplets to 15
396        assert_eq!(result.misses, 5);
397        assert_eq!(result.fruits, 25);
398        assert_eq!(result.droplets, 15); // Adjusted from 12 to satisfy pool constraint
399        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
400
401        verify_is_closest(&inspect, &result);
402    }
403
404    #[test]
405    fn zero_accuracy() {
406        const N_FRUITS: u32 = 20;
407        const N_DROPLETS: u32 = 10;
408        const N_TINY_DROPLETS: u32 = 40;
409
410        let inspect = InspectCatchPerformance {
411            attrs: &CatchDifficultyAttributes {
412                n_fruits: N_FRUITS,
413                n_droplets: N_DROPLETS,
414                n_tiny_droplets: N_TINY_DROPLETS,
415                ..Default::default()
416            },
417            difficulty: &Difficulty::new(),
418            acc: Some(0.0),
419            combo: None,
420            fruits: None,
421            droplets: None,
422            tiny_droplets: None,
423            tiny_droplet_misses: None,
424            misses: Some(30),
425        };
426
427        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
428
429        assert_eq!(result.fruits, 0);
430        assert_eq!(result.droplets, 0);
431        assert_eq!(result.tiny_droplets, 0);
432        assert_eq!(result.tiny_droplet_misses, N_TINY_DROPLETS);
433        assert_eq!(result.misses, 30);
434        assert_eq!(result.accuracy(), 0.0);
435
436        verify_is_closest(&inspect, &result);
437    }
438
439    #[test]
440    fn accuracy_very_close_to_one() {
441        const N_FRUITS: u32 = 35;
442        const N_DROPLETS: u32 = 18;
443        const N_TINY_DROPLETS: u32 = 70;
444        const ACC: f64 = 0.9878;
445
446        let inspect = InspectCatchPerformance {
447            attrs: &CatchDifficultyAttributes {
448                n_fruits: N_FRUITS,
449                n_droplets: N_DROPLETS,
450                n_tiny_droplets: N_TINY_DROPLETS,
451                ..Default::default()
452            },
453            difficulty: &Difficulty::new(),
454            acc: Some(ACC),
455            combo: None,
456            fruits: None,
457            droplets: None,
458            tiny_droplets: None,
459            tiny_droplet_misses: None,
460            misses: Some(1),
461        };
462
463        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
464
465        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
466        assert_eq!(result.misses, 1);
467
468        let actual_acc = result.accuracy();
469        assert!(
470            (actual_acc - ACC).abs() < 0.01,
471            "Expected ~{ACC}, got {actual_acc}"
472        );
473
474        verify_is_closest(&inspect, &result);
475    }
476
477    #[test]
478    fn small_map() {
479        const N_FRUITS: u32 = 10;
480        const N_DROPLETS: u32 = 5;
481        const N_TINY_DROPLETS: u32 = 20;
482        const ACC: f64 = 0.70;
483
484        let inspect = InspectCatchPerformance {
485            attrs: &CatchDifficultyAttributes {
486                n_fruits: N_FRUITS,
487                n_droplets: N_DROPLETS,
488                n_tiny_droplets: N_TINY_DROPLETS,
489                ..Default::default()
490            },
491            difficulty: &Difficulty::new(),
492            acc: Some(ACC),
493            combo: None,
494            fruits: None,
495            droplets: None,
496            tiny_droplets: None,
497            tiny_droplet_misses: None,
498            misses: Some(3),
499        };
500
501        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
502
503        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
504        assert_eq!(result.misses, 3);
505
506        verify_is_closest(&inspect, &result);
507    }
508
509    #[test]
510    fn large_map() {
511        const N_FRUITS: u32 = 500;
512        const N_DROPLETS: u32 = 250;
513        const N_TINY_DROPLETS: u32 = 1000;
514        const ACC: f64 = 0.87;
515
516        let inspect = InspectCatchPerformance {
517            attrs: &CatchDifficultyAttributes {
518                n_fruits: N_FRUITS,
519                n_droplets: N_DROPLETS,
520                n_tiny_droplets: N_TINY_DROPLETS,
521                ..Default::default()
522            },
523            difficulty: &Difficulty::new(),
524            acc: Some(ACC),
525            combo: None,
526            fruits: None,
527            droplets: None,
528            tiny_droplets: None,
529            tiny_droplet_misses: None,
530            misses: Some(42),
531        };
532
533        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
534
535        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
536        assert_eq!(result.misses, 42);
537
538        let actual_acc = result.accuracy();
539        assert!(
540            (actual_acc - ACC).abs() < 0.01,
541            "Expected ~{ACC}, got {actual_acc}"
542        );
543
544        verify_is_closest(&inspect, &result);
545    }
546
547    #[test]
548    fn mostly_tiny_droplets() {
549        const N_FRUITS: u32 = 20;
550        const N_DROPLETS: u32 = 10;
551        const N_TINY_DROPLETS: u32 = 200;
552        const ACC: f64 = 0.65;
553
554        let inspect = InspectCatchPerformance {
555            attrs: &CatchDifficultyAttributes {
556                n_fruits: N_FRUITS,
557                n_droplets: N_DROPLETS,
558                n_tiny_droplets: N_TINY_DROPLETS,
559                ..Default::default()
560            },
561            difficulty: &Difficulty::new(),
562            acc: Some(ACC),
563            combo: None,
564            fruits: None,
565            droplets: None,
566            tiny_droplets: None,
567            tiny_droplet_misses: None,
568            misses: Some(8),
569        };
570
571        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
572
573        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
574        assert_eq!(result.misses, 8);
575
576        verify_is_closest(&inspect, &result);
577    }
578
579    #[test]
580    fn accuracy_requiring_rounding() {
581        const N_FRUITS: u32 = 33;
582        const N_DROPLETS: u32 = 17;
583        const N_TINY_DROPLETS: u32 = 66;
584        const ACC: f64 = 0.7241379; // Odd accuracy that requires careful rounding
585
586        let inspect = InspectCatchPerformance {
587            attrs: &CatchDifficultyAttributes {
588                n_fruits: N_FRUITS,
589                n_droplets: N_DROPLETS,
590                n_tiny_droplets: N_TINY_DROPLETS,
591                ..Default::default()
592            },
593            difficulty: &Difficulty::new(),
594            acc: Some(ACC),
595            combo: None,
596            fruits: None,
597            droplets: None,
598            tiny_droplets: None,
599            tiny_droplet_misses: None,
600            misses: Some(7),
601        };
602
603        let result = <Closest as HitResultGenerator<Catch>>::generate_hitresults(inspect.clone());
604
605        assert_eq!(result.total_hits(), N_FRUITS + N_DROPLETS + N_TINY_DROPLETS);
606        assert_eq!(result.misses, 7);
607
608        verify_is_closest(&inspect, &result);
609    }
610}