fix: improve taiko hitresult generation
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
# Seeds for failure cases proptest has generated in the past. It is
|
||||||
|
# automatically read and these particular cases re-run before any
|
||||||
|
# novel cases are generated.
|
||||||
|
#
|
||||||
|
# It is recommended to check this file in to source control so that
|
||||||
|
# everyone who runs the test benefits from these saved cases.
|
||||||
|
cc 1883e1e612026e7f7c8035803830ff21b6d98c946cfb92c5c144e682a5d72ad2 # shrinks to acc = 0.0, n300 = None, n100 = None, n_misses = None, best_case = false
|
||||||
|
cc 84792fb0ec8971122d0eb2e61355a637e9326ac2b7469226bba06a2defdc44dd # shrinks to acc = 0.659020825478002, n300 = None, n100 = None, n_misses = Some(99), best_case = false
|
||||||
|
cc 94fb95e246e580e010115334e5fd7de6160b3a24a735e4cb4ab4341f5f7b769e # shrinks to acc = 0.0, n300 = Some(0), n100 = Some(1), n_misses = None, best_case = false
|
||||||
@@ -725,6 +725,8 @@ fn calculate_effective_misses(attrs: &OsuDifficultyAttributes, state: &OsuScoreS
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
|
fn accuracy(n300: usize, n100: usize, n50: usize, n_misses: usize) -> f64 {
|
||||||
|
debug_assert_ne!(n300 + n100 + n50 + n_misses, 0);
|
||||||
|
|
||||||
let numerator = 6 * n300 + 2 * n100 + n50;
|
let numerator = 6 * n300 + 2 * n100 + n50;
|
||||||
let denominator = 6 * (n300 + n100 + n50 + n_misses);
|
let denominator = 6 * (n300 + n100 + n50 + n_misses);
|
||||||
|
|
||||||
|
|||||||
+194
-56
@@ -233,9 +233,11 @@ impl<'map> TaikoPP<'map> {
|
|||||||
|
|
||||||
let priority = self.hitresult_priority.unwrap_or_default();
|
let priority = self.hitresult_priority.unwrap_or_default();
|
||||||
|
|
||||||
let mut n300 = self.n300.unwrap_or(0);
|
let n_misses = self.n_misses.map_or(0, |n| n.min(total_result_count));
|
||||||
let mut n100 = self.n100.unwrap_or(0);
|
let n_remaining = total_result_count - n_misses;
|
||||||
let n_misses = self.n_misses.unwrap_or(0);
|
|
||||||
|
let mut n300 = self.n300.map_or(0, |n| n.min(n_remaining));
|
||||||
|
let mut n100 = self.n100.map_or(0, |n| n.min(n_remaining));
|
||||||
|
|
||||||
if let Some(acc) = self.acc {
|
if let Some(acc) = self.acc {
|
||||||
match (self.n300, self.n100) {
|
match (self.n300, self.n100) {
|
||||||
@@ -250,9 +252,24 @@ impl<'map> TaikoPP<'map> {
|
|||||||
(Some(_), None) => n100 += total_result_count.saturating_sub(n300 + n_misses),
|
(Some(_), None) => n100 += total_result_count.saturating_sub(n300 + n_misses),
|
||||||
(None, Some(_)) => n300 += total_result_count.saturating_sub(n100 + n_misses),
|
(None, Some(_)) => n300 += total_result_count.saturating_sub(n100 + n_misses),
|
||||||
(None, None) => {
|
(None, None) => {
|
||||||
let target_total = (acc * (total_result_count * 2) as f64).round() as usize;
|
let target_total = acc * (2 * total_result_count) as f64;
|
||||||
n300 = target_total - (total_result_count.saturating_sub(n_misses));
|
|
||||||
n100 = total_result_count.saturating_sub(n300 + n_misses);
|
let mut best_dist = f64::MAX;
|
||||||
|
|
||||||
|
let raw_n300 = target_total - n_remaining as f64;
|
||||||
|
let min_n300 = n_remaining.min(raw_n300.floor() as usize);
|
||||||
|
let max_n300 = n_remaining.min(raw_n300.ceil() as usize);
|
||||||
|
|
||||||
|
for new300 in min_n300..=max_n300 {
|
||||||
|
let new100 = n_remaining - new300;
|
||||||
|
let dist = (acc - accuracy(new300, new100, n_misses)).abs();
|
||||||
|
|
||||||
|
if dist < best_dist {
|
||||||
|
best_dist = dist;
|
||||||
|
n300 = new300;
|
||||||
|
n100 = new100;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -260,19 +277,23 @@ impl<'map> TaikoPP<'map> {
|
|||||||
|
|
||||||
match priority {
|
match priority {
|
||||||
HitResultPriority::BestCase => match (self.n300, self.n100) {
|
HitResultPriority::BestCase => match (self.n300, self.n100) {
|
||||||
(Some(_), None) => n100 = remaining,
|
|
||||||
(Some(_), Some(_)) => n300 += remaining,
|
|
||||||
(None, _) => n300 = remaining,
|
(None, _) => n300 = remaining,
|
||||||
},
|
|
||||||
HitResultPriority::WorstCase => match (self.n300, self.n100) {
|
|
||||||
(None, Some(_)) => n300 = remaining,
|
|
||||||
(Some(_), Some(_)) => n100 += remaining,
|
|
||||||
(_, None) => n100 = remaining,
|
(_, None) => n100 = remaining,
|
||||||
|
_ => n300 += remaining,
|
||||||
|
},
|
||||||
|
HitResultPriority::WorstCase => match (self.n100, self.n300) {
|
||||||
|
(None, _) => n100 = remaining,
|
||||||
|
(_, None) => n300 = remaining,
|
||||||
|
_ => n100 += remaining,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_combo = self.combo.map_or(max_combo, |combo| combo.min(max_combo));
|
let max_possible_combo = max_combo.saturating_sub(n_misses);
|
||||||
|
|
||||||
|
let max_combo = self
|
||||||
|
.combo
|
||||||
|
.map_or(max_possible_combo, |combo| combo.min(max_possible_combo));
|
||||||
|
|
||||||
TaikoScoreState {
|
TaikoScoreState {
|
||||||
max_combo,
|
max_combo,
|
||||||
@@ -437,6 +458,15 @@ impl<'map> From<OsuPP<'map>> for TaikoPP<'map> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn accuracy(n300: usize, n100: usize, n_misses: usize) -> f64 {
|
||||||
|
debug_assert_ne!(n300 + n100 + n_misses, 0);
|
||||||
|
|
||||||
|
let numerator = 2 * n300 + n100;
|
||||||
|
let denominator = 2 * (n300 + n100 + n_misses);
|
||||||
|
|
||||||
|
numerator as f64 / denominator as f64
|
||||||
|
}
|
||||||
|
|
||||||
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
/// Abstract type to provide flexibility when passing difficulty attributes to a performance calculation.
|
||||||
pub trait TaikoAttributeProvider {
|
pub trait TaikoAttributeProvider {
|
||||||
/// Provide the actual difficulty attributes.
|
/// Provide the actual difficulty attributes.
|
||||||
@@ -486,22 +516,159 @@ impl TaikoAttributeProvider for PerformanceAttributes {
|
|||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::Beatmap;
|
use crate::Beatmap;
|
||||||
|
use proptest::{option, prelude::*};
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
fn test_data() -> (Beatmap, TaikoDifficultyAttributes) {
|
static DATA: OnceLock<(Beatmap, TaikoDifficultyAttributes)> = OnceLock::new();
|
||||||
let path = "./maps/1028484.osu";
|
|
||||||
let map = Beatmap::from_path(path).unwrap();
|
|
||||||
|
|
||||||
let attrs = TaikoDifficultyAttributes {
|
const MAX_COMBO: usize = 289;
|
||||||
stamina: 1.4528845068865617,
|
|
||||||
rhythm: 0.20130047251681948,
|
fn test_data() -> (&'static Beatmap, TaikoDifficultyAttributes) {
|
||||||
colour: 1.0487315549761433,
|
let (map, attrs) = DATA.get_or_init(|| {
|
||||||
peak: 1.8881824429738323,
|
let path = "./maps/1028484.osu";
|
||||||
hit_window: 35.0,
|
let map = Beatmap::from_path(path).unwrap();
|
||||||
stars: 2.9778030386845606,
|
|
||||||
max_combo: 289,
|
let attrs = TaikoDifficultyAttributes {
|
||||||
|
stamina: 1.4528845068865617,
|
||||||
|
rhythm: 0.20130047251681948,
|
||||||
|
colour: 1.0487315549761433,
|
||||||
|
peak: 1.8881824429738323,
|
||||||
|
hit_window: 35.0,
|
||||||
|
stars: 2.9778030386845606,
|
||||||
|
max_combo: 289,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(MAX_COMBO, attrs.max_combo);
|
||||||
|
|
||||||
|
(map, attrs)
|
||||||
|
});
|
||||||
|
|
||||||
|
(map, 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<usize>,
|
||||||
|
n100: Option<usize>,
|
||||||
|
n_misses: usize,
|
||||||
|
best_case: bool,
|
||||||
|
) -> TaikoScoreState {
|
||||||
|
let n_objects = MAX_COMBO;
|
||||||
|
let n_misses = n_misses.min(n_objects);
|
||||||
|
|
||||||
|
let mut best_dist = f64::MAX;
|
||||||
|
|
||||||
|
let mut best_state = TaikoScoreState {
|
||||||
|
n_misses,
|
||||||
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
(map, attrs)
|
let mut bf_with_100 = |n300: usize, n100: usize| {
|
||||||
|
let dist = (acc - accuracy(n300, n100, n_misses)).abs();
|
||||||
|
|
||||||
|
if dist < best_dist {
|
||||||
|
best_dist = dist;
|
||||||
|
best_state.n300 = n300;
|
||||||
|
best_state.n100 = n100;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut bf_with_300 = |n300: usize| match n100 {
|
||||||
|
Some(mut n100) => {
|
||||||
|
n100 = n100
|
||||||
|
.min(n_objects - n_misses)
|
||||||
|
.max(n_objects.saturating_sub(n300 + n_misses));
|
||||||
|
|
||||||
|
bf_with_100(n300, n100);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let n100 = n_objects.saturating_sub(n300 + n_misses);
|
||||||
|
bf_with_100(n300, n100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match (n300, n100) {
|
||||||
|
(Some(mut n300), Some(n100)) => {
|
||||||
|
n300 = n300.min(n_objects - n_misses);
|
||||||
|
|
||||||
|
if best_case {
|
||||||
|
n300 = n300.max(n_objects.saturating_sub(n100 + n_misses));
|
||||||
|
}
|
||||||
|
|
||||||
|
bf_with_300(n300);
|
||||||
|
}
|
||||||
|
(Some(mut n300), None) => {
|
||||||
|
n300 = n300.min(n_objects - n_misses);
|
||||||
|
bf_with_300(n300);
|
||||||
|
}
|
||||||
|
(None, Some(n100)) => {
|
||||||
|
let n300 = n_objects.saturating_sub(n100 + n_misses);
|
||||||
|
bf_with_300(n300);
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
let n_remaining = n_objects - n_misses;
|
||||||
|
|
||||||
|
for n300 in 0..=n_remaining {
|
||||||
|
bf_with_300(n300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
best_state
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(10_000))]
|
||||||
|
#[test]
|
||||||
|
fn taiko_hitresults(
|
||||||
|
acc in 0.0..=1.0,
|
||||||
|
n300 in option::weighted(0.10, 0_usize..=MAX_COMBO),
|
||||||
|
n100 in option::weighted(0.10, 0_usize..=MAX_COMBO),
|
||||||
|
n_misses in option::weighted(0.15, 0_usize..=MAX_COMBO),
|
||||||
|
best_case in prop::bool::ANY,
|
||||||
|
) {
|
||||||
|
let (map, attrs) = test_data();
|
||||||
|
|
||||||
|
let priority = if best_case {
|
||||||
|
HitResultPriority::BestCase
|
||||||
|
} else {
|
||||||
|
HitResultPriority::WorstCase
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut state = TaikoPP::new(map)
|
||||||
|
.attributes(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 hitresults = state.generate_hitresults(MAX_COMBO);
|
||||||
|
|
||||||
|
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!(hitresults, expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -509,7 +676,7 @@ mod test {
|
|||||||
let (map, attrs) = test_data();
|
let (map, attrs) = test_data();
|
||||||
let max_combo = attrs.max_combo();
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
let state = TaikoPP::new(&map)
|
let state = TaikoPP::new(map)
|
||||||
.attributes(attrs)
|
.attributes(attrs)
|
||||||
.combo(100)
|
.combo(100)
|
||||||
.n300(150)
|
.n300(150)
|
||||||
@@ -532,7 +699,7 @@ mod test {
|
|||||||
let (map, attrs) = test_data();
|
let (map, attrs) = test_data();
|
||||||
let max_combo = attrs.max_combo();
|
let max_combo = attrs.max_combo();
|
||||||
|
|
||||||
let state = TaikoPP::new(&map)
|
let state = TaikoPP::new(map)
|
||||||
.attributes(attrs)
|
.attributes(attrs)
|
||||||
.combo(100)
|
.combo(100)
|
||||||
.n_misses(2)
|
.n_misses(2)
|
||||||
@@ -548,33 +715,4 @@ mod test {
|
|||||||
|
|
||||||
assert_eq!(state, expected);
|
assert_eq!(state, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hitresults_acc_n_misses_worst() {
|
|
||||||
let (map, attrs) = test_data();
|
|
||||||
let max_combo = attrs.max_combo();
|
|
||||||
|
|
||||||
let state = TaikoPP::new(&map)
|
|
||||||
.attributes(attrs)
|
|
||||||
.combo(100)
|
|
||||||
.accuracy(97.2)
|
|
||||||
.n_misses(2)
|
|
||||||
.hitresult_priority(HitResultPriority::WorstCase)
|
|
||||||
.generate_hitresults(max_combo);
|
|
||||||
|
|
||||||
let expected = TaikoScoreState {
|
|
||||||
max_combo: 100,
|
|
||||||
n300: 275,
|
|
||||||
n100: 12,
|
|
||||||
n_misses: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
state,
|
|
||||||
expected,
|
|
||||||
"{}% vs {}%",
|
|
||||||
state.accuracy(),
|
|
||||||
expected.accuracy()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user