added hitresult proptesting
This commit is contained in:
@@ -554,3 +554,219 @@ fn accuracy(
|
|||||||
|
|
||||||
f64::from(numerator) / f64::from(denominator)
|
f64::from(numerator) / f64::from(denominator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use crate::Beatmap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static ATTRS: OnceLock<CatchDifficultyAttributes> = OnceLock::new();
|
||||||
|
|
||||||
|
const N_FRUITS: u32 = 728;
|
||||||
|
const N_DROPLETS: u32 = 2;
|
||||||
|
const N_TINY_DROPLETS: u32 = 291;
|
||||||
|
|
||||||
|
fn attrs() -> CatchDifficultyAttributes {
|
||||||
|
ATTRS
|
||||||
|
.get_or_init(|| {
|
||||||
|
let converted = Beatmap::from_path("./resources/2118524.osu")
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Catch>();
|
||||||
|
|
||||||
|
let attrs = ModeDifficulty::new().calculate(&converted);
|
||||||
|
|
||||||
|
assert_eq!(N_FRUITS, attrs.n_fruits);
|
||||||
|
assert_eq!(N_DROPLETS, attrs.n_droplets);
|
||||||
|
assert_eq!(N_TINY_DROPLETS, attrs.n_tiny_droplets);
|
||||||
|
|
||||||
|
attrs
|
||||||
|
})
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks all remaining hitresult combinations w.r.t. the given parameters
|
||||||
|
/// and returns the [`CatchScoreState`] that matches `acc` the best.
|
||||||
|
///
|
||||||
|
/// Very slow but accurate.
|
||||||
|
fn brute_force_best(
|
||||||
|
acc: f64,
|
||||||
|
n_fruits: Option<u32>,
|
||||||
|
n_droplets: Option<u32>,
|
||||||
|
n_tiny_droplets: Option<u32>,
|
||||||
|
n_tiny_droplet_misses: Option<u32>,
|
||||||
|
n_misses: u32,
|
||||||
|
) -> CatchScoreState {
|
||||||
|
let n_misses = cmp::min(n_misses, N_FRUITS + N_DROPLETS);
|
||||||
|
|
||||||
|
let mut best_state = CatchScoreState {
|
||||||
|
max_combo: N_FRUITS + N_DROPLETS - n_misses,
|
||||||
|
n_misses,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut best_dist = f64::INFINITY;
|
||||||
|
|
||||||
|
let (new_fruits, new_droplets) = match (n_fruits, n_droplets) {
|
||||||
|
(Some(mut n_fruits), Some(mut n_droplets)) => {
|
||||||
|
let n_remaining =
|
||||||
|
(N_FRUITS + N_DROPLETS).saturating_sub(n_fruits + n_droplets + n_misses);
|
||||||
|
|
||||||
|
let new_droplets = cmp::min(n_remaining, N_DROPLETS.saturating_sub(n_droplets));
|
||||||
|
n_droplets += new_droplets;
|
||||||
|
n_fruits += n_remaining - new_droplets;
|
||||||
|
|
||||||
|
n_fruits = cmp::min(
|
||||||
|
n_fruits,
|
||||||
|
(N_FRUITS + N_DROPLETS).saturating_sub(n_droplets + n_misses),
|
||||||
|
);
|
||||||
|
n_droplets = cmp::min(n_droplets, N_FRUITS + N_DROPLETS - n_fruits - n_misses);
|
||||||
|
|
||||||
|
(n_fruits, n_droplets)
|
||||||
|
}
|
||||||
|
(Some(mut n_fruits), None) => {
|
||||||
|
let n_droplets = N_DROPLETS
|
||||||
|
.saturating_sub(n_misses.saturating_sub(N_FRUITS.saturating_sub(n_fruits)));
|
||||||
|
n_fruits = N_FRUITS + N_DROPLETS - n_misses - n_droplets;
|
||||||
|
|
||||||
|
(n_fruits, n_droplets)
|
||||||
|
}
|
||||||
|
(None, Some(mut n_droplets)) => {
|
||||||
|
let n_fruits = N_FRUITS
|
||||||
|
.saturating_sub(n_misses.saturating_sub(N_DROPLETS.saturating_sub(n_droplets)));
|
||||||
|
n_droplets = N_FRUITS + N_DROPLETS - n_misses - n_fruits;
|
||||||
|
|
||||||
|
(n_fruits, n_droplets)
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
let n_droplets = N_DROPLETS.saturating_sub(n_misses);
|
||||||
|
let n_fruits = N_FRUITS - (n_misses - (N_DROPLETS.saturating_sub(n_droplets)));
|
||||||
|
|
||||||
|
(n_fruits, n_droplets)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
best_state.n_fruits = new_fruits;
|
||||||
|
best_state.n_droplets = new_droplets;
|
||||||
|
|
||||||
|
let (min_tiny_droplets, max_tiny_droplets) = match (n_tiny_droplets, n_tiny_droplet_misses)
|
||||||
|
{
|
||||||
|
(Some(n_tiny_droplets), Some(n_tiny_droplet_misses)) => {
|
||||||
|
match (n_tiny_droplets + n_tiny_droplet_misses).cmp(&N_TINY_DROPLETS) {
|
||||||
|
Ordering::Equal => (
|
||||||
|
cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
|
||||||
|
cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
|
||||||
|
),
|
||||||
|
Ordering::Less | Ordering::Greater => (0, N_TINY_DROPLETS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(n_tiny_droplets), None) => (
|
||||||
|
cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
|
||||||
|
cmp::min(N_TINY_DROPLETS, n_tiny_droplets),
|
||||||
|
),
|
||||||
|
(None, Some(n_tiny_droplet_misses)) => (
|
||||||
|
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
|
||||||
|
N_TINY_DROPLETS.saturating_sub(n_tiny_droplet_misses),
|
||||||
|
),
|
||||||
|
(None, None) => (0, N_TINY_DROPLETS),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new_tiny_droplets in min_tiny_droplets..=max_tiny_droplets {
|
||||||
|
let new_tiny_droplet_misses = N_TINY_DROPLETS - new_tiny_droplets;
|
||||||
|
|
||||||
|
let curr_acc = accuracy(
|
||||||
|
new_fruits,
|
||||||
|
new_droplets,
|
||||||
|
new_tiny_droplets,
|
||||||
|
new_tiny_droplet_misses,
|
||||||
|
n_misses,
|
||||||
|
);
|
||||||
|
|
||||||
|
let curr_dist = (acc - curr_acc).abs();
|
||||||
|
|
||||||
|
if curr_dist < best_dist {
|
||||||
|
best_dist = curr_dist;
|
||||||
|
best_state.n_tiny_droplets = new_tiny_droplets;
|
||||||
|
best_state.n_tiny_droplet_misses = new_tiny_droplet_misses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_state
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(1000))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults(
|
||||||
|
acc in 0.0..=1.0,
|
||||||
|
n_fruits in prop::option::weighted(0.10, 0_u32..=N_FRUITS + 10),
|
||||||
|
n_droplets in prop::option::weighted(0.10, 0_u32..=N_DROPLETS + 10),
|
||||||
|
n_tiny_droplets in prop::option::weighted(0.10, 0_u32..=N_TINY_DROPLETS + 10),
|
||||||
|
n_tiny_droplet_misses in prop::option::weighted(0.10, 0_u32..=N_TINY_DROPLETS + 10),
|
||||||
|
n_misses in prop::option::weighted(0.15, 0_u32..=N_FRUITS + N_DROPLETS + 10),
|
||||||
|
) {
|
||||||
|
let mut state = CatchPerformance::from(attrs())
|
||||||
|
.accuracy(acc * 100.0);
|
||||||
|
|
||||||
|
if let Some(n_fruits) = n_fruits {
|
||||||
|
state = state.fruits(n_fruits);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_droplets) = n_droplets {
|
||||||
|
state = state.droplets(n_droplets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_tiny_droplets) = n_tiny_droplets {
|
||||||
|
state = state.tiny_droplets(n_tiny_droplets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_tiny_droplet_misses) = n_tiny_droplet_misses {
|
||||||
|
state = state.tiny_droplet_misses(n_tiny_droplet_misses);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_misses) = n_misses {
|
||||||
|
state = state.misses(n_misses);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = state.generate_state();
|
||||||
|
|
||||||
|
let expected = brute_force_best(
|
||||||
|
acc,
|
||||||
|
n_fruits,
|
||||||
|
n_droplets,
|
||||||
|
n_tiny_droplets,
|
||||||
|
n_tiny_droplet_misses,
|
||||||
|
n_misses.unwrap_or(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fruits_missing_objects() {
|
||||||
|
let state = CatchPerformance::from(attrs())
|
||||||
|
.fruits(N_FRUITS - 10)
|
||||||
|
.droplets(N_DROPLETS - 1)
|
||||||
|
.tiny_droplets(N_TINY_DROPLETS - 50)
|
||||||
|
.tiny_droplet_misses(20)
|
||||||
|
.misses(2)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = CatchScoreState {
|
||||||
|
max_combo: N_FRUITS + N_DROPLETS - 2,
|
||||||
|
n_fruits: N_FRUITS - 2,
|
||||||
|
n_droplets: N_DROPLETS,
|
||||||
|
n_tiny_droplets: N_TINY_DROPLETS - 20,
|
||||||
|
n_tiny_droplet_misses: 20,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+388
-24
@@ -186,14 +186,14 @@ impl<'map> ManiaPerformance<'map> {
|
|||||||
|
|
||||||
let priority = self.hitresult_priority;
|
let priority = self.hitresult_priority;
|
||||||
|
|
||||||
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
|
let n_misses = self.n_misses.map_or(0, |n| cmp::min(n, n_objects));
|
||||||
let n_remaining = n_objects - n_misses;
|
let n_remaining = n_objects - n_misses;
|
||||||
|
|
||||||
let mut n320 = self.n320.map_or(0, |n| n.min(n_remaining));
|
let mut n320 = self.n320.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
let mut n300 = self.n300.map_or(0, |n| n.min(n_remaining));
|
let mut n300 = self.n300.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
let mut n200 = self.n200.map_or(0, |n| n.min(n_remaining));
|
let mut n200 = self.n200.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
let mut n100 = self.n100.map_or(0, |n| n.min(n_remaining));
|
let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
|
let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
|
|
||||||
if let Some(acc) = self.acc {
|
if let Some(acc) = self.acc {
|
||||||
let target_total = acc * f64::from(6 * n_objects);
|
let target_total = acc * f64::from(6 * n_objects);
|
||||||
@@ -380,18 +380,21 @@ impl<'map> ManiaPerformance<'map> {
|
|||||||
let mut best_dist = f64::INFINITY;
|
let mut best_dist = f64::INFINITY;
|
||||||
let mut n3x0 = n_objects.saturating_sub(n320 + n300 + n200 + n_misses);
|
let mut n3x0 = n_objects.saturating_sub(n320 + n300 + n200 + n_misses);
|
||||||
|
|
||||||
let min_n3x0 = (((target_total - f64::from(2 * (n_remaining + n200))) / 4.0)
|
let min_n3x0 = cmp::min(
|
||||||
.floor() as u32)
|
((target_total - f64::from(2 * (n_remaining + n200))) / 4.0).floor() as u32,
|
||||||
.min(n_remaining - n200);
|
n_remaining - n200,
|
||||||
|
);
|
||||||
|
|
||||||
let max_n3x0 = (((target_total - f64::from(n_remaining + 3 * n200)) / 5.0)
|
let max_n3x0 = cmp::min(
|
||||||
.ceil() as u32)
|
((target_total - f64::from(n_remaining + 3 * n200)) / 5.0).ceil() as u32,
|
||||||
.min(n_remaining - n200);
|
n_remaining - n200,
|
||||||
|
);
|
||||||
|
|
||||||
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => (
|
||||||
(n_remaining.min(n320 + n300), n_remaining.min(n320 + n300))
|
cmp::min(n_remaining, n320 + n300),
|
||||||
}
|
cmp::min(n_remaining, n320 + n300),
|
||||||
|
),
|
||||||
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
||||||
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
||||||
(None, None) => (min_n3x0, max_n3x0),
|
(None, None) => (min_n3x0, max_n3x0),
|
||||||
@@ -451,9 +454,10 @@ impl<'map> ManiaPerformance<'map> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => (
|
||||||
(n_remaining.min(n320 + n300), n_remaining.min(n320 + n300))
|
cmp::min(n_remaining, n320 + n300),
|
||||||
}
|
cmp::min(n_remaining, n320 + n300),
|
||||||
|
),
|
||||||
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
||||||
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
||||||
(None, None) => (min_n3x0, max_n3x0),
|
(None, None) => (min_n3x0, max_n3x0),
|
||||||
@@ -513,9 +517,10 @@ impl<'map> ManiaPerformance<'map> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => (
|
||||||
(n_remaining.min(n320 + n300), n_remaining.min(n320 + n300))
|
cmp::min(n_remaining, n320 + n300),
|
||||||
}
|
cmp::min(n_remaining, n320 + n300),
|
||||||
|
),
|
||||||
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
||||||
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
||||||
(None, None) => (min_n3x0, max_n3x0),
|
(None, None) => (min_n3x0, max_n3x0),
|
||||||
@@ -587,9 +592,10 @@ impl<'map> ManiaPerformance<'map> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
let (min_n3x0, max_n3x0) = match (self.n320, self.n300) {
|
||||||
(Some(_), Some(_)) => {
|
(Some(_), Some(_)) => (
|
||||||
(n_remaining.min(n320 + n300), n_remaining.min(n320 + n300))
|
cmp::min(n_remaining, n320 + n300),
|
||||||
}
|
cmp::min(n_remaining, n320 + n300),
|
||||||
|
),
|
||||||
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
(Some(_), None) => (min_n3x0.max(n320), max_n3x0.max(n320)),
|
||||||
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
(None, Some(_)) => (min_n3x0.max(n300), max_n3x0.max(n300)),
|
||||||
(None, None) => (min_n3x0, max_n3x0),
|
(None, None) => (min_n3x0, max_n3x0),
|
||||||
@@ -931,3 +937,361 @@ fn accuracy(n320: u32, n300: u32, n200: u32, n100: u32, n50: u32, n_misses: u32)
|
|||||||
|
|
||||||
f64::from(numerator) / f64::from(denominator)
|
f64::from(numerator) / f64::from(denominator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::{cmp::Ordering, sync::OnceLock};
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use crate::Beatmap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static ATTRS: OnceLock<ManiaDifficultyAttributes> = OnceLock::new();
|
||||||
|
|
||||||
|
const N_OBJECTS: u32 = 594;
|
||||||
|
|
||||||
|
fn attrs() -> ManiaDifficultyAttributes {
|
||||||
|
ATTRS
|
||||||
|
.get_or_init(|| {
|
||||||
|
let converted = Beatmap::from_path("./resources/1638954.osu")
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Mania>();
|
||||||
|
|
||||||
|
let attrs = ModeDifficulty::new().calculate(&converted);
|
||||||
|
|
||||||
|
assert_eq!(N_OBJECTS, converted.map.hit_objects.len() as u32);
|
||||||
|
|
||||||
|
attrs
|
||||||
|
})
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks most remaining hitresult combinations w.r.t. the given parameters
|
||||||
|
/// and returns the [`ManiaScoreState`] that matches `acc` the best.
|
||||||
|
///
|
||||||
|
/// Very slow but accurate. Only slight optimizations have been applied so
|
||||||
|
/// that it doesn't run unreasonably long.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||||
|
fn brute_force_best(
|
||||||
|
acc: f64,
|
||||||
|
n320: Option<u32>,
|
||||||
|
n300: Option<u32>,
|
||||||
|
n200: Option<u32>,
|
||||||
|
n100: Option<u32>,
|
||||||
|
n50: Option<u32>,
|
||||||
|
n_misses: u32,
|
||||||
|
best_case: bool,
|
||||||
|
) -> ManiaScoreState {
|
||||||
|
let n_misses = cmp::min(n_misses, N_OBJECTS);
|
||||||
|
|
||||||
|
let mut best_state = ManiaScoreState {
|
||||||
|
n_misses,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut best_dist = f64::INFINITY;
|
||||||
|
let mut best_custom_acc = 0.0;
|
||||||
|
|
||||||
|
let n_remaining = N_OBJECTS - n_misses;
|
||||||
|
|
||||||
|
let multiple_given = (usize::from(n320.is_some())
|
||||||
|
+ usize::from(n300.is_some())
|
||||||
|
+ usize::from(n200.is_some())
|
||||||
|
+ usize::from(n100.is_some())
|
||||||
|
+ usize::from(n50.is_some()))
|
||||||
|
> 1;
|
||||||
|
|
||||||
|
let max_left = N_OBJECTS
|
||||||
|
.saturating_sub(n200.unwrap_or(0) + n100.unwrap_or(0) + n50.unwrap_or(0) + n_misses);
|
||||||
|
|
||||||
|
let min_n3x0 = cmp::min(
|
||||||
|
max_left,
|
||||||
|
(acc * f64::from(3 * N_OBJECTS) - f64::from(2 * n_remaining)).floor() as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
let max_n3x0 = cmp::min(
|
||||||
|
max_left,
|
||||||
|
((acc * f64::from(6 * N_OBJECTS) - f64::from(n_remaining)) / 5.0).ceil() as u32,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (min_n3x0, max_n3x0) = match (n320, n300) {
|
||||||
|
(Some(n320), Some(n300)) => (
|
||||||
|
cmp::min(n_remaining, n320 + n300),
|
||||||
|
cmp::min(n_remaining, n320 + n300),
|
||||||
|
),
|
||||||
|
(Some(n320), None) => (
|
||||||
|
cmp::max(cmp::min(n_remaining, n320), min_n3x0),
|
||||||
|
cmp::max(max_n3x0, cmp::min(n320, n_remaining)),
|
||||||
|
),
|
||||||
|
(None, Some(n300)) => (
|
||||||
|
cmp::max(cmp::min(n_remaining, n300), min_n3x0),
|
||||||
|
cmp::max(max_n3x0, cmp::min(n300, n_remaining)),
|
||||||
|
),
|
||||||
|
(None, None) => (min_n3x0, max_n3x0),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new3x0 in min_n3x0..=max_n3x0 {
|
||||||
|
let max_left =
|
||||||
|
n_remaining.saturating_sub(new3x0 + n100.unwrap_or(0) + n50.unwrap_or(0));
|
||||||
|
|
||||||
|
let (min_n200, max_n200) = match (n200, n100, n50) {
|
||||||
|
(Some(n200), ..) if multiple_given => {
|
||||||
|
(cmp::min(n_remaining, n200), cmp::min(n_remaining, n200))
|
||||||
|
}
|
||||||
|
(Some(n200), ..) => (cmp::min(max_left, n200), cmp::min(max_left, n200)),
|
||||||
|
(None, Some(_), Some(_)) => (max_left, max_left),
|
||||||
|
_ => (0, max_left),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new200 in min_n200..=max_n200 {
|
||||||
|
let max_left = n_remaining.saturating_sub(new3x0 + new200 + n50.unwrap_or(0));
|
||||||
|
|
||||||
|
let (min_n100, max_n100) = match (n100, n50) {
|
||||||
|
(Some(n100), _) if multiple_given => {
|
||||||
|
(cmp::min(n_remaining, n100), cmp::min(n_remaining, n100))
|
||||||
|
}
|
||||||
|
(Some(n100), _) => (cmp::min(max_left, n100), cmp::min(max_left, n100)),
|
||||||
|
(None, Some(_)) => (max_left, max_left),
|
||||||
|
(None, None) => (0, max_left),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new100 in min_n100..=max_n100 {
|
||||||
|
let max_left = n_remaining.saturating_sub(new3x0 + new200 + new100);
|
||||||
|
|
||||||
|
let new50 = match n50 {
|
||||||
|
Some(n50) if multiple_given => cmp::min(n_remaining, n50),
|
||||||
|
Some(n50) => cmp::min(max_left, n50),
|
||||||
|
None => max_left,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (new320, new300) = match (n320, n300) {
|
||||||
|
(Some(n320), Some(n300)) => {
|
||||||
|
(cmp::min(n_remaining, n320), cmp::min(n_remaining, n300))
|
||||||
|
}
|
||||||
|
(Some(n320), None) => (
|
||||||
|
cmp::min(n320, n_remaining),
|
||||||
|
new3x0 - cmp::min(n320, n_remaining),
|
||||||
|
),
|
||||||
|
(None, Some(n300)) => (
|
||||||
|
new3x0 - cmp::min(n300, n_remaining),
|
||||||
|
cmp::min(n300, n_remaining),
|
||||||
|
),
|
||||||
|
(None, None) if best_case => (new3x0, 0),
|
||||||
|
(None, None) => (0, new3x0),
|
||||||
|
};
|
||||||
|
|
||||||
|
let curr_acc = accuracy(new320, new300, new200, new100, new50, n_misses);
|
||||||
|
let curr_dist = (acc - curr_acc).abs();
|
||||||
|
|
||||||
|
let curr_custom_acc =
|
||||||
|
custom_accuracy(new320, new300, new200, new100, new50, N_OBJECTS);
|
||||||
|
|
||||||
|
match curr_dist.partial_cmp(&best_dist).expect("non-NaN") {
|
||||||
|
Ordering::Less => {
|
||||||
|
best_dist = curr_dist;
|
||||||
|
best_custom_acc = curr_custom_acc;
|
||||||
|
best_state.n320 = new320;
|
||||||
|
best_state.n300 = new300;
|
||||||
|
best_state.n200 = new200;
|
||||||
|
best_state.n100 = new100;
|
||||||
|
best_state.n50 = new50;
|
||||||
|
}
|
||||||
|
Ordering::Equal if curr_custom_acc < best_custom_acc => {
|
||||||
|
best_custom_acc = curr_custom_acc;
|
||||||
|
best_state.n320 = new320;
|
||||||
|
best_state.n300 = new300;
|
||||||
|
best_state.n200 = new200;
|
||||||
|
best_state.n100 = new100;
|
||||||
|
best_state.n50 = new50;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_state.n320 + best_state.n300 + best_state.n200 + best_state.n100 + best_state.n50
|
||||||
|
< n_remaining
|
||||||
|
{
|
||||||
|
let n_remaining = n_remaining
|
||||||
|
- (best_state.n320
|
||||||
|
+ best_state.n300
|
||||||
|
+ best_state.n200
|
||||||
|
+ best_state.n100
|
||||||
|
+ best_state.n50);
|
||||||
|
|
||||||
|
if best_case {
|
||||||
|
match (n320, n300, n200, n100, n50) {
|
||||||
|
(None, ..) => best_state.n320 += n_remaining,
|
||||||
|
(_, None, ..) => best_state.n300 += n_remaining,
|
||||||
|
(_, _, None, ..) => best_state.n200 += n_remaining,
|
||||||
|
(.., None, _) => best_state.n100 += n_remaining,
|
||||||
|
(.., None) => best_state.n50 += n_remaining,
|
||||||
|
_ => best_state.n320 += n_remaining,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match (n50, n100, n200, n300, n320) {
|
||||||
|
(None, ..) => best_state.n50 += n_remaining,
|
||||||
|
(_, None, ..) => best_state.n100 += n_remaining,
|
||||||
|
(_, _, None, ..) => best_state.n200 += n_remaining,
|
||||||
|
(.., None, _) => best_state.n300 += n_remaining,
|
||||||
|
(.., None) => best_state.n320 += n_remaining,
|
||||||
|
_ => best_state.n50 += n_remaining,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_case {
|
||||||
|
if n320.is_none() && n200.is_none() && n100.is_none() {
|
||||||
|
let n = best_state.n200 / 2;
|
||||||
|
best_state.n320 += n;
|
||||||
|
best_state.n200 -= 2 * n;
|
||||||
|
best_state.n100 += n;
|
||||||
|
}
|
||||||
|
|
||||||
|
if n100.is_none() && n50.is_none() {
|
||||||
|
let n = if n320.is_none() && n300.is_none() {
|
||||||
|
let n = cmp::min(best_state.n320 + best_state.n300, best_state.n50 / 4);
|
||||||
|
|
||||||
|
let removed320 = cmp::min(best_state.n320, n);
|
||||||
|
let removed300 = n - removed320;
|
||||||
|
|
||||||
|
best_state.n320 -= removed320;
|
||||||
|
best_state.n300 -= removed300;
|
||||||
|
|
||||||
|
n
|
||||||
|
} else if n320.is_none() {
|
||||||
|
let n = cmp::min(best_state.n320, best_state.n50 / 4);
|
||||||
|
best_state.n320 -= n;
|
||||||
|
|
||||||
|
n
|
||||||
|
} else if n300.is_none() {
|
||||||
|
let n = cmp::min(best_state.n300, best_state.n50 / 4);
|
||||||
|
best_state.n300 -= n;
|
||||||
|
|
||||||
|
n
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
best_state.n100 += 5 * n;
|
||||||
|
best_state.n50 -= 4 * n;
|
||||||
|
}
|
||||||
|
} else if n320.is_none() && n200.is_none() && n100.is_none() {
|
||||||
|
let n = cmp::min(best_state.n320, best_state.n100);
|
||||||
|
best_state.n320 -= n;
|
||||||
|
best_state.n200 += 2 * n;
|
||||||
|
best_state.n100 -= n;
|
||||||
|
}
|
||||||
|
|
||||||
|
best_state
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(100))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mania_hitresults(
|
||||||
|
acc in 0.0..=1.0,
|
||||||
|
n320 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n200 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n100 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n50 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10),
|
||||||
|
best_case in prop::bool::ANY,
|
||||||
|
) {
|
||||||
|
let priority = if best_case {
|
||||||
|
HitResultPriority::BestCase
|
||||||
|
} else {
|
||||||
|
HitResultPriority::WorstCase
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = ManiaPerformance::from(attrs())
|
||||||
|
.accuracy(acc * 100.0)
|
||||||
|
.hitresult_priority(priority);
|
||||||
|
|
||||||
|
if let Some(n320) = n320 {
|
||||||
|
state = state.n320(n320);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n300) = n300 {
|
||||||
|
state = state.n300(n300);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n200) = n200 {
|
||||||
|
state = state.n200(n200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n100) = n100 {
|
||||||
|
state = state.n100(n100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n50) = n50 {
|
||||||
|
state = state.n50(n50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_misses) = n_misses {
|
||||||
|
state = state.n_misses(n_misses);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = state.generate_state();
|
||||||
|
|
||||||
|
let expected = brute_force_best(
|
||||||
|
acc,
|
||||||
|
n320,
|
||||||
|
n300,
|
||||||
|
n200,
|
||||||
|
n100,
|
||||||
|
n50,
|
||||||
|
n_misses.unwrap_or(0),
|
||||||
|
best_case,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n320_n_misses_best() {
|
||||||
|
let state = ManiaPerformance::from(attrs())
|
||||||
|
.n320(500)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::BestCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = ManiaScoreState {
|
||||||
|
n320: 500,
|
||||||
|
n300: 92,
|
||||||
|
n200: 0,
|
||||||
|
n100: 0,
|
||||||
|
n50: 0,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n100_n50_n_misses_worst() {
|
||||||
|
let state = ManiaPerformance::from(attrs())
|
||||||
|
.n100(200)
|
||||||
|
.n50(50)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::WorstCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = ManiaScoreState {
|
||||||
|
n320: 0,
|
||||||
|
n300: 0,
|
||||||
|
n200: 342,
|
||||||
|
n100: 200,
|
||||||
|
n50: 50,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+290
-21
@@ -224,12 +224,12 @@ impl<'map> OsuPerformance<'map> {
|
|||||||
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
|
let n_objects = self.passed_objects.unwrap_or(attrs.n_objects());
|
||||||
let priority = self.hitresult_priority;
|
let priority = self.hitresult_priority;
|
||||||
|
|
||||||
let n_misses = self.n_misses.map_or(0, |n| n.min(n_objects));
|
let n_misses = self.n_misses.map_or(0, |n| cmp::min(n, n_objects));
|
||||||
let n_remaining = n_objects - n_misses;
|
let n_remaining = n_objects - n_misses;
|
||||||
|
|
||||||
let mut n300 = self.n300.map_or(0, |n| n.min(n_remaining));
|
let mut n300 = self.n300.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
let mut n100 = self.n100.map_or(0, |n| n.min(n_remaining));
|
let mut n100 = self.n100.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
let mut n50 = self.n50.map_or(0, |n| n.min(n_remaining));
|
let mut n50 = self.n50.map_or(0, |n| cmp::min(n, n_remaining));
|
||||||
|
|
||||||
if let Some(acc) = self.acc {
|
if let Some(acc) = self.acc {
|
||||||
let target_total = acc * f64::from(6 * n_objects);
|
let target_total = acc * f64::from(6 * n_objects);
|
||||||
@@ -249,12 +249,12 @@ impl<'map> OsuPerformance<'map> {
|
|||||||
(Some(_), None, None) => {
|
(Some(_), None, None) => {
|
||||||
let mut best_dist = f64::MAX;
|
let mut best_dist = f64::MAX;
|
||||||
|
|
||||||
n300 = n300.min(n_remaining);
|
n300 = cmp::min(n300, n_remaining);
|
||||||
let n_remaining = n_remaining - n300;
|
let n_remaining = n_remaining - n300;
|
||||||
|
|
||||||
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
|
let raw_n100 = target_total - f64::from(n_remaining + 6 * n300);
|
||||||
let min_n100 = n_remaining.min(raw_n100.floor() as u32);
|
let min_n100 = cmp::min(n_remaining, raw_n100.floor() as u32);
|
||||||
let max_n100 = n_remaining.min(raw_n100.ceil() as u32);
|
let max_n100 = cmp::min(n_remaining, raw_n100.ceil() as u32);
|
||||||
|
|
||||||
for new100 in min_n100..=max_n100 {
|
for new100 in min_n100..=max_n100 {
|
||||||
let new50 = n_remaining - new100;
|
let new50 = n_remaining - new100;
|
||||||
@@ -270,12 +270,12 @@ impl<'map> OsuPerformance<'map> {
|
|||||||
(None, Some(_), None) => {
|
(None, Some(_), None) => {
|
||||||
let mut best_dist = f64::MAX;
|
let mut best_dist = f64::MAX;
|
||||||
|
|
||||||
n100 = n100.min(n_remaining);
|
n100 = cmp::min(n100, n_remaining);
|
||||||
let n_remaining = n_remaining - n100;
|
let n_remaining = n_remaining - n100;
|
||||||
|
|
||||||
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
|
let raw_n300 = (target_total - f64::from(n_remaining + 2 * n100)) / 5.0;
|
||||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
|
||||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
|
||||||
|
|
||||||
for new300 in min_n300..=max_n300 {
|
for new300 in min_n300..=max_n300 {
|
||||||
let new50 = n_remaining - new300;
|
let new50 = n_remaining - new300;
|
||||||
@@ -291,15 +291,15 @@ impl<'map> OsuPerformance<'map> {
|
|||||||
(None, None, Some(_)) => {
|
(None, None, Some(_)) => {
|
||||||
let mut best_dist = f64::MAX;
|
let mut best_dist = f64::MAX;
|
||||||
|
|
||||||
n50 = n50.min(n_remaining);
|
n50 = cmp::min(n50, n_remaining);
|
||||||
let n_remaining = n_remaining - n50;
|
let n_remaining = n_remaining - n50;
|
||||||
|
|
||||||
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
|
let raw_n300 = (target_total + f64::from(2 * n_misses + n50)
|
||||||
- f64::from(2 * n_objects))
|
- f64::from(2 * n_objects))
|
||||||
/ 4.0;
|
/ 4.0;
|
||||||
|
|
||||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
|
||||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
|
||||||
|
|
||||||
for new300 in min_n300..=max_n300 {
|
for new300 in min_n300..=max_n300 {
|
||||||
let new100 = n_remaining - new300;
|
let new100 = n_remaining - new300;
|
||||||
@@ -340,7 +340,7 @@ impl<'map> OsuPerformance<'map> {
|
|||||||
match priority {
|
match priority {
|
||||||
HitResultPriority::BestCase => {
|
HitResultPriority::BestCase => {
|
||||||
// Shift n50 to n100 by sacrificing n300
|
// Shift n50 to n100 by sacrificing n300
|
||||||
let n = n300.min(n50 / 4);
|
let n = cmp::min(n300, n50 / 4);
|
||||||
n300 -= n;
|
n300 -= n;
|
||||||
n100 += 5 * n;
|
n100 += 5 * n;
|
||||||
n50 -= 4 * n;
|
n50 -= 4 * n;
|
||||||
@@ -376,9 +376,9 @@ impl<'map> OsuPerformance<'map> {
|
|||||||
|
|
||||||
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
||||||
|
|
||||||
let max_combo = self
|
let max_combo = self.combo.map_or(max_possible_combo, |combo| {
|
||||||
.combo
|
cmp::min(combo, max_possible_combo)
|
||||||
.map_or(max_possible_combo, |combo| combo.min(max_possible_combo));
|
});
|
||||||
|
|
||||||
OsuScoreState {
|
OsuScoreState {
|
||||||
max_combo,
|
max_combo,
|
||||||
@@ -623,10 +623,10 @@ impl OsuPerformanceInner {
|
|||||||
let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
|
let estimate_diff_sliders = f64::from(self.attrs.n_sliders) * 0.15;
|
||||||
|
|
||||||
if self.attrs.n_sliders > 0 {
|
if self.attrs.n_sliders > 0 {
|
||||||
let estimate_slider_ends_dropped = f64::from(
|
let estimate_slider_ends_dropped = f64::from(cmp::min(
|
||||||
(self.state.n100 + self.state.n50 + self.state.n_misses)
|
self.state.n100 + self.state.n50 + self.state.n_misses,
|
||||||
.min(self.attrs.max_combo.saturating_sub(self.state.max_combo)),
|
self.attrs.max_combo.saturating_sub(self.state.max_combo),
|
||||||
)
|
))
|
||||||
.clamp(0.0, estimate_diff_sliders);
|
.clamp(0.0, estimate_diff_sliders);
|
||||||
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
|
let slider_nerf_factor = (1.0 - self.attrs.slider_factor)
|
||||||
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
|
* (1.0 - estimate_slider_ends_dropped / estimate_diff_sliders).powi(3)
|
||||||
@@ -833,3 +833,272 @@ fn accuracy(n300: u32, n100: u32, n50: u32, n_misses: u32) -> f64 {
|
|||||||
|
|
||||||
f64::from(numerator) / f64::from(denominator)
|
f64::from(numerator) / f64::from(denominator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use crate::Beatmap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static ATTRS: OnceLock<OsuDifficultyAttributes> = OnceLock::new();
|
||||||
|
|
||||||
|
const N_OBJECTS: u32 = 601;
|
||||||
|
|
||||||
|
fn attrs() -> OsuDifficultyAttributes {
|
||||||
|
ATTRS
|
||||||
|
.get_or_init(|| {
|
||||||
|
let converted = Beatmap::from_path("./resources/2785319.osu")
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Osu>();
|
||||||
|
|
||||||
|
let attrs = ModeDifficulty::new().calculate(&converted);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(attrs.n_circles, attrs.n_sliders, attrs.n_spinners),
|
||||||
|
(307, 293, 1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
attrs.n_circles + attrs.n_sliders + attrs.n_spinners,
|
||||||
|
N_OBJECTS,
|
||||||
|
);
|
||||||
|
|
||||||
|
attrs
|
||||||
|
})
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks all remaining hitresult combinations w.r.t. the given parameters
|
||||||
|
/// and returns the [`OsuScoreState`] that matches `acc` the best.
|
||||||
|
///
|
||||||
|
/// Very slow but accurate.
|
||||||
|
fn brute_force_best(
|
||||||
|
acc: f64,
|
||||||
|
n300: Option<u32>,
|
||||||
|
n100: Option<u32>,
|
||||||
|
n50: Option<u32>,
|
||||||
|
n_misses: u32,
|
||||||
|
best_case: bool,
|
||||||
|
) -> OsuScoreState {
|
||||||
|
let n_misses = cmp::min(n_misses, N_OBJECTS);
|
||||||
|
|
||||||
|
let mut best_state = OsuScoreState {
|
||||||
|
n_misses,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut best_dist = f64::INFINITY;
|
||||||
|
|
||||||
|
let n_remaining = N_OBJECTS - n_misses;
|
||||||
|
|
||||||
|
let (min_n300, max_n300) = match (n300, n100, n50) {
|
||||||
|
(Some(n300), ..) => (cmp::min(n_remaining, n300), cmp::min(n_remaining, n300)),
|
||||||
|
(None, Some(n100), Some(n50)) => (
|
||||||
|
n_remaining.saturating_sub(n100 + n50),
|
||||||
|
n_remaining.saturating_sub(n100 + n50),
|
||||||
|
),
|
||||||
|
(None, ..) => (
|
||||||
|
0,
|
||||||
|
n_remaining.saturating_sub(n100.unwrap_or(0) + n50.unwrap_or(0)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new300 in min_n300..=max_n300 {
|
||||||
|
let (min_n100, max_n100) = match (n100, n50) {
|
||||||
|
(Some(n100), _) => (cmp::min(n_remaining, n100), cmp::min(n_remaining, n100)),
|
||||||
|
(None, Some(n50)) => (
|
||||||
|
n_remaining.saturating_sub(new300 + n50),
|
||||||
|
n_remaining.saturating_sub(new300 + n50),
|
||||||
|
),
|
||||||
|
(None, None) => (0, n_remaining - new300),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new100 in min_n100..=max_n100 {
|
||||||
|
let new50 = match n50 {
|
||||||
|
Some(n50) => cmp::min(n_remaining, n50),
|
||||||
|
None => n_remaining.saturating_sub(new300 + new100),
|
||||||
|
};
|
||||||
|
|
||||||
|
let curr_acc = accuracy(new300, new100, new50, n_misses);
|
||||||
|
let curr_dist = (acc - curr_acc).abs();
|
||||||
|
|
||||||
|
if curr_dist < best_dist {
|
||||||
|
best_dist = curr_dist;
|
||||||
|
best_state.n300 = new300;
|
||||||
|
best_state.n100 = new100;
|
||||||
|
best_state.n50 = new50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_state.n300 + best_state.n100 + best_state.n50 < n_remaining {
|
||||||
|
let remaining = n_remaining - (best_state.n300 + best_state.n100 + best_state.n50);
|
||||||
|
|
||||||
|
if best_case {
|
||||||
|
best_state.n300 += remaining;
|
||||||
|
} else {
|
||||||
|
best_state.n50 += remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if n300.is_none() && n100.is_none() && n50.is_none() {
|
||||||
|
if best_case {
|
||||||
|
let n = cmp::min(best_state.n300, best_state.n50 / 4);
|
||||||
|
best_state.n300 -= n;
|
||||||
|
best_state.n100 += 5 * n;
|
||||||
|
best_state.n50 -= 4 * n;
|
||||||
|
} else {
|
||||||
|
let n = best_state.n100 / 5;
|
||||||
|
best_state.n300 += n;
|
||||||
|
best_state.n100 -= 5 * n;
|
||||||
|
best_state.n50 += 4 * n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_state
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(1000))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults(
|
||||||
|
acc in 0.0..=1.0,
|
||||||
|
n300 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n100 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n50 in prop::option::weighted(0.10, 0_u32..=N_OBJECTS + 10),
|
||||||
|
n_misses in prop::option::weighted(0.15, 0_u32..=N_OBJECTS + 10),
|
||||||
|
best_case in prop::bool::ANY,
|
||||||
|
) {
|
||||||
|
let attrs = attrs();
|
||||||
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
|
let priority = if best_case {
|
||||||
|
HitResultPriority::BestCase
|
||||||
|
} else {
|
||||||
|
HitResultPriority::WorstCase
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = OsuPerformance::from(attrs)
|
||||||
|
.accuracy(acc * 100.0)
|
||||||
|
.hitresult_priority(priority);
|
||||||
|
|
||||||
|
if let Some(n300) = n300 {
|
||||||
|
state = state.n300(n300);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n100) = n100 {
|
||||||
|
state = state.n100(n100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n50) = n50 {
|
||||||
|
state = state.n50(n50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_misses) = n_misses {
|
||||||
|
state = state.n_misses(n_misses);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = state.generate_state();
|
||||||
|
|
||||||
|
let mut expected = brute_force_best(
|
||||||
|
acc,
|
||||||
|
n300,
|
||||||
|
n100,
|
||||||
|
n50,
|
||||||
|
n_misses.unwrap_or(0),
|
||||||
|
best_case,
|
||||||
|
);
|
||||||
|
expected.max_combo = max_combo.saturating_sub(n_misses.map_or(0, |n| cmp::min(n, N_OBJECTS)));
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n300_n100_n_misses_best() {
|
||||||
|
let state = OsuPerformance::from(attrs())
|
||||||
|
.combo(500)
|
||||||
|
.n300(300)
|
||||||
|
.n100(20)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::BestCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = OsuScoreState {
|
||||||
|
max_combo: 500,
|
||||||
|
n300: 300,
|
||||||
|
n100: 20,
|
||||||
|
n50: 279,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n300_n50_n_misses_best() {
|
||||||
|
let state = OsuPerformance::from(attrs())
|
||||||
|
.combo(500)
|
||||||
|
.n300(300)
|
||||||
|
.n50(10)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::BestCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = OsuScoreState {
|
||||||
|
max_combo: 500,
|
||||||
|
n300: 300,
|
||||||
|
n100: 289,
|
||||||
|
n50: 10,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n50_n_misses_worst() {
|
||||||
|
let state = OsuPerformance::from(attrs())
|
||||||
|
.combo(500)
|
||||||
|
.n50(10)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::WorstCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = OsuScoreState {
|
||||||
|
max_combo: 500,
|
||||||
|
n300: 0,
|
||||||
|
n100: 589,
|
||||||
|
n50: 10,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n300_n100_n50_n_misses_worst() {
|
||||||
|
let state = OsuPerformance::from(attrs())
|
||||||
|
.combo(500)
|
||||||
|
.n300(300)
|
||||||
|
.n100(50)
|
||||||
|
.n50(10)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::WorstCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = OsuScoreState {
|
||||||
|
max_combo: 500,
|
||||||
|
n300: 300,
|
||||||
|
n100: 50,
|
||||||
|
n50: 249,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -190,8 +190,8 @@ impl<'map> TaikoPerformance<'map> {
|
|||||||
let mut best_dist = f64::MAX;
|
let mut best_dist = f64::MAX;
|
||||||
|
|
||||||
let raw_n300 = target_total - f64::from(n_remaining);
|
let raw_n300 = target_total - f64::from(n_remaining);
|
||||||
let min_n300 = n_remaining.min(raw_n300.floor() as u32);
|
let min_n300 = cmp::min(n_remaining, raw_n300.floor() as u32);
|
||||||
let max_n300 = n_remaining.min(raw_n300.ceil() as u32);
|
let max_n300 = cmp::min(n_remaining, raw_n300.ceil() as u32);
|
||||||
|
|
||||||
for new300 in min_n300..=max_n300 {
|
for new300 in min_n300..=max_n300 {
|
||||||
let new100 = n_remaining - new300;
|
let new100 = n_remaining - new300;
|
||||||
@@ -224,9 +224,9 @@ impl<'map> TaikoPerformance<'map> {
|
|||||||
|
|
||||||
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
||||||
|
|
||||||
let max_combo = self
|
let max_combo = self.combo.map_or(max_possible_combo, |combo| {
|
||||||
.combo
|
cmp::min(combo, max_possible_combo)
|
||||||
.map_or(max_possible_combo, |combo| combo.min(max_possible_combo));
|
});
|
||||||
|
|
||||||
TaikoScoreState {
|
TaikoScoreState {
|
||||||
max_combo,
|
max_combo,
|
||||||
@@ -522,3 +522,180 @@ fn accuracy(n300: u32, n100: u32, n_misses: u32) -> f64 {
|
|||||||
|
|
||||||
f64::from(numerator) / f64::from(denominator)
|
f64::from(numerator) / f64::from(denominator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
use proptest::prelude::*;
|
||||||
|
|
||||||
|
use crate::Beatmap;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static ATTRS: OnceLock<TaikoDifficultyAttributes> = OnceLock::new();
|
||||||
|
|
||||||
|
const MAX_COMBO: u32 = 289;
|
||||||
|
|
||||||
|
fn attrs() -> TaikoDifficultyAttributes {
|
||||||
|
ATTRS
|
||||||
|
.get_or_init(|| {
|
||||||
|
let converted = Beatmap::from_path("./resources/1028484.osu")
|
||||||
|
.unwrap()
|
||||||
|
.unchecked_into_converted::<Taiko>();
|
||||||
|
|
||||||
|
let attrs = ModeDifficulty::new().calculate(&converted);
|
||||||
|
|
||||||
|
assert_eq!(MAX_COMBO, attrs.max_combo);
|
||||||
|
|
||||||
|
attrs
|
||||||
|
})
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks all remaining hitresult combinations w.r.t. the given parameters
|
||||||
|
/// and returns the [`TaikoScoreState`] that matches `acc` the best.
|
||||||
|
///
|
||||||
|
/// Very slow but accurate.
|
||||||
|
fn brute_force_best(
|
||||||
|
acc: f64,
|
||||||
|
n300: Option<u32>,
|
||||||
|
n100: Option<u32>,
|
||||||
|
n_misses: u32,
|
||||||
|
best_case: bool,
|
||||||
|
) -> TaikoScoreState {
|
||||||
|
let n_misses = cmp::min(n_misses, MAX_COMBO);
|
||||||
|
|
||||||
|
let mut best_state = TaikoScoreState {
|
||||||
|
n_misses,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut best_dist = f64::INFINITY;
|
||||||
|
|
||||||
|
let n_objects = MAX_COMBO;
|
||||||
|
let n_remaining = n_objects - n_misses;
|
||||||
|
|
||||||
|
let (min_n300, max_n300) = match (n300, n100) {
|
||||||
|
(Some(n300), _) => (cmp::min(n_remaining, n300), cmp::min(n_remaining, n300)),
|
||||||
|
(None, Some(n100)) => (
|
||||||
|
n_remaining.saturating_sub(n100),
|
||||||
|
n_remaining.saturating_sub(n100),
|
||||||
|
),
|
||||||
|
(None, None) => (0, n_remaining),
|
||||||
|
};
|
||||||
|
|
||||||
|
for new300 in min_n300..=max_n300 {
|
||||||
|
let new100 = match n100 {
|
||||||
|
Some(n100) => cmp::min(n_remaining, n100),
|
||||||
|
None => n_remaining - new300,
|
||||||
|
};
|
||||||
|
|
||||||
|
let curr_acc = accuracy(new300, new100, n_misses);
|
||||||
|
let curr_dist = (acc - curr_acc).abs();
|
||||||
|
|
||||||
|
if curr_dist < best_dist {
|
||||||
|
best_dist = curr_dist;
|
||||||
|
best_state.n300 = new300;
|
||||||
|
best_state.n100 = new100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_state.n300 + best_state.n100 < n_remaining {
|
||||||
|
let remaining = n_remaining - (best_state.n300 + best_state.n100);
|
||||||
|
|
||||||
|
if best_case {
|
||||||
|
best_state.n300 += remaining;
|
||||||
|
} else {
|
||||||
|
best_state.n100 += remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_state
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(1000))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults(
|
||||||
|
acc in 0.0..=1.0,
|
||||||
|
n300 in prop::option::weighted(0.10, 0_u32..=MAX_COMBO + 10),
|
||||||
|
n100 in prop::option::weighted(0.10, 0_u32..=MAX_COMBO + 10),
|
||||||
|
n_misses in prop::option::weighted(0.15, 0_u32..=MAX_COMBO + 10),
|
||||||
|
best_case in prop::bool::ANY,
|
||||||
|
) {
|
||||||
|
let priority = if best_case {
|
||||||
|
HitResultPriority::BestCase
|
||||||
|
} else {
|
||||||
|
HitResultPriority::WorstCase
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = TaikoPerformance::from(attrs())
|
||||||
|
.accuracy(acc * 100.0)
|
||||||
|
.hitresult_priority(priority);
|
||||||
|
|
||||||
|
if let Some(n300) = n300 {
|
||||||
|
state = state.n300(n300);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n100) = n100 {
|
||||||
|
state = state.n100(n100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(n_misses) = n_misses {
|
||||||
|
state = state.n_misses(n_misses);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = state.generate_state();
|
||||||
|
|
||||||
|
let mut expected = brute_force_best(
|
||||||
|
acc,
|
||||||
|
n300,
|
||||||
|
n100,
|
||||||
|
n_misses.unwrap_or(0),
|
||||||
|
best_case,
|
||||||
|
);
|
||||||
|
expected.max_combo = MAX_COMBO.saturating_sub(n_misses.unwrap_or(0));
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n300_n_misses_best() {
|
||||||
|
let state = TaikoPerformance::from(attrs())
|
||||||
|
.combo(100)
|
||||||
|
.n300(150)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::BestCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = TaikoScoreState {
|
||||||
|
max_combo: 100,
|
||||||
|
n300: 150,
|
||||||
|
n100: 137,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hitresults_n_misses_best() {
|
||||||
|
let state = TaikoPerformance::from(attrs())
|
||||||
|
.combo(100)
|
||||||
|
.n_misses(2)
|
||||||
|
.hitresult_priority(HitResultPriority::BestCase)
|
||||||
|
.generate_state();
|
||||||
|
|
||||||
|
let expected = TaikoScoreState {
|
||||||
|
max_combo: 100,
|
||||||
|
n300: 287,
|
||||||
|
n100: 0,
|
||||||
|
n_misses: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(state, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user