gradual calc for taiko

This commit is contained in:
MaxOhn
2024-02-23 12:52:55 +01:00
parent ae14815140
commit b4f843fac1
11 changed files with 622 additions and 55 deletions
+324
View File
@@ -0,0 +1,324 @@
use std::{cell::RefCell, mem, rc::Rc, slice::Iter};
use crate::{
model::{beatmap::HitWindows, hit_object::HitObject},
taiko::TaikoBeatmap,
ModeDifficulty,
};
use super::{
object::{TaikoDifficultyObject, TaikoDifficultyObjects},
skills::peaks::{Peaks, PeaksSkill},
DifficultyValues, TaikoDifficultyAttributes,
};
/// Gradually calculate the difficulty attributes of an osu!taiko map.
///
/// Note that this struct implements [`Iterator`]. On every call of
/// [`Iterator::next`], the map's next hit object will be processed and the
/// [`TaikoDifficultyAttributes`] will be updated and returned.
///
/// If you want to calculate performance attributes, use
/// [`TaikoGradualPerformance`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, ModeDifficulty};
/// use rosu_pp::taiko::{Taiko, TaikoGradualDifficulty};
///
/// let map = Beatmap::from_path("./resources/1028484.osu")
/// .unwrap()
/// .unchecked_into_converted::<Taiko>();
///
/// let difficulty = ModeDifficulty::new().mods(64); // DT
/// let mut iter = TaikoGradualDifficulty::new(&difficulty, &converted);
///
/// // the difficulty of the map after the first hit object
/// let attrs1 = iter.next();
/// // ... after the second hit object
/// let attrs2 = iter.next();
///
/// // Remaining hit objects
/// for difficulty in iter {
/// // ...
/// }
/// ```
///
/// [`TaikoGradualPerformance`]: crate::taiko::TaikoGradualPerformance
pub struct TaikoGradualDifficulty {
pub(crate) idx: usize,
pub(crate) mods: u32,
pub(crate) clock_rate: f64,
attrs: TaikoDifficultyAttributes,
diff_objects: TaikoDifficultyObjects,
diff_objects_iter: Iter<'static, Rc<RefCell<TaikoDifficultyObject>>>,
peaks: Peaks,
total_hits: usize,
first_combos: FirstTwoCombos,
}
#[derive(Copy, Clone, Debug)]
enum FirstTwoCombos {
None,
OnlyFirst,
OnlySecond,
Both,
}
impl TaikoGradualDifficulty {
/// Create a new difficulty attributes iterator for osu!taiko maps.
pub fn new(difficulty: &ModeDifficulty, converted: &TaikoBeatmap<'_>) -> Self {
let take = difficulty.get_passed_objects();
let mods = difficulty.get_mods();
let clock_rate = difficulty.get_clock_rate();
let first_combos = match (
converted.map.hit_objects.first().map(HitObject::is_circle),
converted.map.hit_objects.get(1).map(HitObject::is_circle),
) {
(None, _) | (Some(false), Some(false) | None) => FirstTwoCombos::None,
(Some(true), Some(false) | None) => FirstTwoCombos::OnlyFirst,
(Some(false), Some(true)) => FirstTwoCombos::OnlySecond,
(Some(true), Some(true)) => FirstTwoCombos::Both,
};
let HitWindows { od: hit_window, .. } = converted
.attributes()
.mods(mods)
.clock_rate(clock_rate)
.hit_windows();
let mut n_diff_objects = 0;
let mut max_combo = 0;
let diff_objects = DifficultyValues::create_difficulty_objects(
converted,
take as u32,
clock_rate,
&mut max_combo,
&mut n_diff_objects,
);
let peaks = Peaks::new();
let attrs = TaikoDifficultyAttributes {
hit_window,
is_convert: converted.is_convert,
..Default::default()
};
let total_hits = converted
.map
.hit_objects
.iter()
.filter(|h| h.is_circle())
.count();
let diff_objects_iter = extend_lifetime(diff_objects.iter());
Self {
idx: 0,
mods,
clock_rate,
diff_objects,
diff_objects_iter,
peaks,
attrs,
total_hits,
first_combos,
}
}
}
fn extend_lifetime(
iter: Iter<'_, Rc<RefCell<TaikoDifficultyObject>>>,
) -> Iter<'static, Rc<RefCell<TaikoDifficultyObject>>> {
// SAFETY: The underlying data will never be moved.
unsafe { mem::transmute(iter) }
}
impl Iterator for TaikoGradualDifficulty {
type Item = TaikoDifficultyAttributes;
fn next(&mut self) -> Option<Self::Item> {
// The first difficulty object belongs to the third note since each
// difficulty object requires the current, the last, and the second to
// last note. Hence, if we're still on the first or second object, we
// don't have a difficulty object yet and just skip processing.
if self.idx >= 2 {
loop {
let curr = self.diff_objects_iter.next()?;
let borrowed = curr.borrow();
PeaksSkill::new(&mut self.peaks, &self.diff_objects).process(&borrowed);
if borrowed.base_hit_type.is_hit() {
self.attrs.max_combo += 1;
break;
}
}
} else if self.diff_objects.is_empty() {
return None;
} else {
match self.first_combos {
FirstTwoCombos::OnlyFirst => self.attrs.max_combo = 1,
FirstTwoCombos::OnlySecond if self.idx == 1 => self.attrs.max_combo = 1,
FirstTwoCombos::Both if self.idx == 0 => self.attrs.max_combo = 1,
FirstTwoCombos::Both if self.idx == 1 => self.attrs.max_combo = 2,
_ => {}
}
}
self.idx += 1;
let color = self.peaks.color_difficulty_value();
let rhythm = self.peaks.rhythm_difficulty_value();
let stamina = self.peaks.stamina_difficulty_value();
let combined = self.peaks.clone().difficulty_value();
let mut attrs = self.attrs.clone();
DifficultyValues::eval(&mut attrs, color, rhythm, stamina, combined);
Some(attrs)
}
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.len();
(len, Some(len))
}
fn nth(&mut self, n: usize) -> Option<Self::Item> {
let mut take = n.min(self.len().saturating_sub(1));
// The first two notes have no difficulty object but might add to combo
match (take, self.idx) {
(_, 2..) | (0, _) => {}
(1, 0) => {
take -= 1;
self.idx += 1;
match self.first_combos {
FirstTwoCombos::None => {}
FirstTwoCombos::OnlyFirst => self.attrs.max_combo = 1,
FirstTwoCombos::OnlySecond => {}
FirstTwoCombos::Both => self.attrs.max_combo = 1,
}
}
(_, 0) => {
take -= 2;
self.idx += 2;
match self.first_combos {
FirstTwoCombos::None => {}
FirstTwoCombos::OnlyFirst => self.attrs.max_combo = 1,
FirstTwoCombos::OnlySecond => self.attrs.max_combo = 1,
FirstTwoCombos::Both => self.attrs.max_combo = 2,
}
}
(_, 1) => {
take -= 1;
self.idx += 1;
match self.first_combos {
FirstTwoCombos::None => {}
FirstTwoCombos::OnlyFirst => self.attrs.max_combo = 1,
FirstTwoCombos::OnlySecond => self.attrs.max_combo = 1,
FirstTwoCombos::Both => self.attrs.max_combo = 2,
}
}
}
let mut peaks = PeaksSkill::new(&mut self.peaks, &self.diff_objects);
for _ in 0..take {
loop {
let curr = self.diff_objects_iter.next()?;
let borrowed = curr.borrow();
peaks.process(&borrowed);
if borrowed.base_hit_type.is_hit() {
self.attrs.max_combo += 1;
self.idx += 1;
break;
}
}
}
self.next()
}
}
impl ExactSizeIterator for TaikoGradualDifficulty {
fn len(&self) -> usize {
self.total_hits - self.idx
}
}
#[cfg(test)]
mod tests {
use crate::Beatmap;
use super::*;
#[test]
fn empty() {
let converted = Beatmap::from_bytes(&[]).unwrap().unchecked_into_converted();
let difficulty = ModeDifficulty::new();
let mut gradual = TaikoGradualDifficulty::new(&difficulty, &converted);
assert!(gradual.next().is_none());
}
#[test]
fn next_and_nth() {
let converted = Beatmap::from_path("./resources/1028484.osu")
.unwrap()
.unchecked_into_converted();
let difficulty = ModeDifficulty::new();
let mut gradual = TaikoGradualDifficulty::new(&difficulty, &converted);
let mut gradual_2nd = TaikoGradualDifficulty::new(&difficulty, &converted);
let mut gradual_3rd = TaikoGradualDifficulty::new(&difficulty, &converted);
let hit_objects_len = converted.map.hit_objects.len();
let n_hits = converted
.map
.hit_objects
.iter()
.filter(|h| h.is_circle())
.count();
for i in 1.. {
let Some(next_gradual) = gradual.next() else {
assert_eq!(i, n_hits + 1);
assert!(gradual_2nd.last().is_some() || hit_objects_len % 2 == 0);
assert!(gradual_3rd.last().is_some() || hit_objects_len % 3 == 0);
break;
};
if i % 2 == 0 {
let next_gradual_2nd = gradual_2nd.nth(1).unwrap();
assert_eq!(next_gradual, next_gradual_2nd);
}
if i % 3 == 0 {
let next_gradual_3rd = gradual_3rd.nth(2).unwrap();
assert_eq!(next_gradual, next_gradual_3rd);
}
let expected = ModeDifficulty::new()
.passed_objects(i as u32)
.calculate(&converted);
assert_eq!(next_gradual, expected);
}
}
}
+91 -50
View File
@@ -15,6 +15,7 @@ use self::skills::peaks::Peaks;
use super::{attributes::TaikoDifficultyAttributes, convert::TaikoBeatmap};
mod color;
pub mod gradual;
mod object;
mod rhythm;
mod skills;
@@ -36,35 +37,27 @@ pub fn difficulty(
let DifficultyValues { peaks, max_combo } = DifficultyValues::calculate(difficulty, converted);
let color_rating = peaks.color_difficulty_value() * DIFFICULTY_MULTIPLIER;
let rhythm_rating = peaks.rhythm_difficulty_value() * DIFFICULTY_MULTIPLIER;
let stamina_rating = peaks.stamina_difficulty_value() * DIFFICULTY_MULTIPLIER;
let combined_rating = peaks.difficulty_value() * DIFFICULTY_MULTIPLIER;
let mut star_rating = rescale(combined_rating * 1.4);
// * TODO: This is temporary measure as we don't detect abuse of multiple-input
// * playstyles of converts within the current system.
if converted.is_convert {
star_rating *= 0.925;
// * For maps with low colour variance and high stamina requirement,
// * multiple inputs are more likely to be abused.
if color_rating < 2.0 && stamina_rating > 8.0 {
star_rating *= 0.8;
}
}
TaikoDifficultyAttributes {
stamina: stamina_rating,
rhythm: rhythm_rating,
color: color_rating,
peak: combined_rating,
let mut attrs = TaikoDifficultyAttributes {
hit_window,
stars: star_rating,
max_combo,
is_convert: converted.is_convert,
}
..Default::default()
};
let color_rating = peaks.color_difficulty_value();
let rhythm_rating = peaks.rhythm_difficulty_value();
let stamina_rating = peaks.stamina_difficulty_value();
let combined_rating = peaks.difficulty_value();
DifficultyValues::eval(
&mut attrs,
color_rating,
rhythm_rating,
stamina_rating,
combined_rating,
);
attrs
}
fn rescale(stars: f64) -> f64 {
@@ -88,6 +81,73 @@ impl DifficultyValues {
let mut n_diff_objects = 0;
let mut max_combo = 0;
let diff_objects = Self::create_difficulty_objects(
converted,
take as u32,
clock_rate,
&mut max_combo,
&mut n_diff_objects,
);
// The first two hit objects have no difficulty object
n_diff_objects = n_diff_objects.saturating_sub(2);
let mut peaks = Peaks::new();
{
let mut peaks = PeaksSkill::new(&mut peaks, &diff_objects);
for hit_object in diff_objects.iter().take(n_diff_objects) {
peaks.process(&hit_object.borrow());
}
}
Self {
peaks,
max_combo: max_combo as u32,
}
}
pub fn eval(
attrs: &mut TaikoDifficultyAttributes,
color_difficulty_value: f64,
rhythm_difficulty_value: f64,
stamina_difficulty_value: f64,
peaks_difficulty_value: f64,
) {
let color_rating = color_difficulty_value * DIFFICULTY_MULTIPLIER;
let rhythm_rating = rhythm_difficulty_value * DIFFICULTY_MULTIPLIER;
let stamina_rating = stamina_difficulty_value * DIFFICULTY_MULTIPLIER;
let combined_rating = peaks_difficulty_value * DIFFICULTY_MULTIPLIER;
let mut star_rating = rescale(combined_rating * 1.4);
// * TODO: This is temporary measure as we don't detect abuse of multiple-input
// * playstyles of converts within the current system.
if attrs.is_convert {
star_rating *= 0.925;
// * For maps with low colour variance and high stamina requirement,
// * multiple inputs are more likely to be abused.
if color_rating < 2.0 && stamina_rating > 8.0 {
star_rating *= 0.8;
}
}
attrs.stamina = stamina_rating;
attrs.rhythm = rhythm_rating;
attrs.color = color_rating;
attrs.peak = combined_rating;
attrs.stars = star_rating;
}
pub fn create_difficulty_objects(
converted: &TaikoBeatmap<'_>,
take: u32,
clock_rate: f64,
max_combo: &mut u32,
n_diff_objects: &mut usize,
) -> TaikoDifficultyObjects {
let mut hit_objects_iter = converted
.map
.hit_objects
@@ -95,18 +155,15 @@ impl DifficultyValues {
.zip(converted.map.hit_sounds.iter())
.map(|(h, s)| TaikoObject::new(h, *s))
.inspect(|h| {
if max_combo < take {
n_diff_objects += 1;
max_combo += usize::from(h.is_hit());
if *max_combo < take {
*n_diff_objects += 1;
*max_combo += u32::from(h.is_hit());
}
});
let Some((mut last_last, mut last)) = hit_objects_iter.next().zip(hit_objects_iter.next())
else {
return Self {
peaks: Peaks::new(),
max_combo: max_combo as u32,
};
return TaikoDifficultyObjects::with_capacity(0);
};
let mut diff_objects =
@@ -130,22 +187,6 @@ impl DifficultyValues {
ColorDifficultyPreprocessor::process_and_assign(&diff_objects);
// The first two hit objects have no difficulty object
n_diff_objects -= 2;
let mut peaks = Peaks::new();
{
let mut peaks = PeaksSkill::new(&mut peaks, &diff_objects);
for hit_object in diff_objects.iter().take(n_diff_objects) {
peaks.process(&hit_object.borrow());
}
}
Self {
peaks,
max_combo: max_combo as u32,
}
diff_objects
}
}
+4
View File
@@ -105,6 +105,10 @@ impl TaikoDifficultyObjects {
self.objects.push(hit_object);
}
pub fn is_empty(&self) -> bool {
self.objects.is_empty()
}
pub fn iter(&self) -> Iter<'_, Rc<RefCell<TaikoDifficultyObject>>> {
self.objects.iter()
}
+1 -1
View File
@@ -21,7 +21,7 @@ use crate::{
const SKILL_MULTIPLIER: f64 = 0.12;
const STRAIN_DECAY_BASE: f64 = 0.8;
#[derive(Default)]
#[derive(Clone, Default)]
pub struct Color {
inner: StrainDecaySkill,
}
+1
View File
@@ -11,6 +11,7 @@ const STAMINA_SKILL_MULTIPLIER: f64 = 0.375 * FINAL_MULTIPLIER;
const FINAL_MULTIPLIER: f64 = 0.0625;
#[derive(Clone)]
pub struct Peaks {
pub color: Color,
pub rhythm: Rhythm,
+1 -1
View File
@@ -20,7 +20,7 @@ const STRAIN_DECAY: f64 = 0.96;
const RHYTHM_HISTORY_MAX_LEN: usize = 8;
#[allow(clippy::struct_field_names)]
#[derive(Default)]
#[derive(Clone, Default)]
pub struct Rhythm {
inner: StrainDecaySkill,
rhythm_history: LimitedQueue<RhythmHistoryElement, RHYTHM_HISTORY_MAX_LEN>,
+1 -1
View File
@@ -12,7 +12,7 @@ use crate::{
const SKILL_MULTIPLIER: f64 = 1.1;
const STRAIN_DECAY_BASE: f64 = 0.4;
#[derive(Default)]
#[derive(Clone, Default)]
pub struct Stamina {
inner: StrainDecaySkill,
}
+2 -1
View File
@@ -11,7 +11,8 @@ use crate::{
pub use self::{
attributes::{TaikoDifficultyAttributes, TaikoPerformanceAttributes},
convert::TaikoBeatmap,
performance::TaikoPerformance,
difficulty::gradual::TaikoGradualDifficulty,
performance::{gradual::TaikoGradualPerformance, TaikoPerformance},
score_state::TaikoScoreState,
strains::TaikoStrains,
};
+7 -1
View File
@@ -22,7 +22,7 @@ impl TaikoObject {
}
pub const fn is_hit(&self) -> bool {
!matches!(self.hit_type, HitType::NonHit)
self.hit_type.is_hit()
}
}
@@ -32,3 +32,9 @@ pub enum HitType {
Rim,
NonHit,
}
impl HitType {
pub const fn is_hit(self) -> bool {
!matches!(self, Self::NonHit)
}
}
+188
View File
@@ -0,0 +1,188 @@
use crate::{
taiko::{difficulty::gradual::TaikoGradualDifficulty, TaikoBeatmap, TaikoScoreState},
ModeDifficulty,
};
use super::TaikoPerformanceAttributes;
/// Gradually calculate the performance attributes of an osu!taiko map.
///
/// After each hit object you can call [`next`] and it will return the
/// resulting current [`TaikoPerformanceAttributes`]. To process multiple
/// objects at once, use [`nth`] instead.
///
/// Both methods require a [`TaikoScoreState`] that contains the current
/// hitresults as well as the maximum combo so far.
///
/// If you only want to calculate difficulty attributes use
/// [`TaikoGradualDifficulty`] instead.
///
/// # Example
///
/// ```
/// use rosu_pp::{Beatmap, ModeDifficulty};
/// use rosu_pp::taiko::{Taiko, TaikoGradualPerformance, TaikoScoreState};
///
/// let map = Beatmap::from_path("./resources/1028484.osu")
/// .unwrap()
/// .unchecked_into_converted::<Taiko>();
///
/// let difficulty = ModeDifficulty::new().mods(64); // DT
/// let mut gradual_perf = TaikoGradualPerformance::new(&difficulty, &converted);
/// let mut state = TaikoScoreState::new(); // empty state, everything is on 0.
///
/// // The first 10 hitresults are 300s
/// for _ in 0..10 {
/// state.n300 += 1;
/// state.max_combo += 1;
///
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
/// }
///
/// // Then comes a miss.
/// // Note that state's max combo won't be incremented for
/// // the next few objects because the combo is reset.
/// state.n_misses += 1;
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
///
/// // The next 10 objects will be a mixture of 300s and 100s.
/// // Notice how all 10 objects will be processed in one go.
/// state.n300 += 3;
/// state.n100 += 7;
/// // The `nth` method takes a zero-based value.
/// let performance = gradual_perf.nth(state.clone(), 9).unwrap();
/// println!("PP: {}", performance.pp);
///
/// // Now comes another 300. Note that the max combo gets incremented again.
/// state.n300 += 1;
/// state.max_combo += 1;
/// let performance = gradual_perf.next(state.clone()).unwrap();
/// println!("PP: {}", performance.pp);
///
/// // Skip to the end
/// # /*
/// state.max_combo = ...
/// state.n300 = ...
/// state.n100 = ...
/// state.n_misses = ...
/// # */
/// let final_performance = gradual_perf.nth(state.clone(), usize::MAX).unwrap();
/// println!("PP: {}", performance.pp);
///
/// // Once the final performance was calculated,
/// // attempting to process further objects will return `None`.
/// assert!(gradual_perf.next(state).is_none());
/// ```
///
/// [`next`]: TaikoGradualPerformance::next
/// [`nth`]: TaikoGradualPerformance::nth
pub struct TaikoGradualPerformance {
difficulty: TaikoGradualDifficulty,
}
impl TaikoGradualPerformance {
/// Create a new gradual performance calculator for osu!taiko maps.
pub fn new(difficulty: &ModeDifficulty, converted: &TaikoBeatmap<'_>) -> Self {
let difficulty = TaikoGradualDifficulty::new(difficulty, converted);
Self { difficulty }
}
/// Process the next hit object and calculate the performance attributes
/// for the resulting score.
pub fn next(&mut self, state: TaikoScoreState) -> Option<TaikoPerformanceAttributes> {
self.nth(state, 0)
}
/// Process all remaining hit objects and calculate the final performance
/// attributes.
pub fn last(&mut self, state: TaikoScoreState) -> Option<TaikoPerformanceAttributes> {
self.nth(state, usize::MAX)
}
/// Process everything up the the next `n`th hit object and calculate the
/// performance attributes for the resulting score state.
///
/// Note that the count is zero-indexed, so `n=0` will process 1 object,
/// `n=1` will process 2, and so on.
pub fn nth(&mut self, state: TaikoScoreState, n: usize) -> Option<TaikoPerformanceAttributes> {
let performance = self
.difficulty
.nth(n)?
.performance()
.state(state)
.mods(self.difficulty.mods)
.clock_rate(self.difficulty.clock_rate)
.passed_objects(self.difficulty.idx as u32)
.calculate();
Some(performance)
}
}
#[cfg(test)]
mod tests {
use crate::{taiko::TaikoPerformance, Beatmap};
use super::*;
#[test]
fn next_and_nth() {
let converted = Beatmap::from_path("./resources/1028484.osu")
.unwrap()
.unchecked_into_converted();
let mods = 88; // HDHRDT
let difficulty = ModeDifficulty::new().mods(88);
let mut gradual = TaikoGradualPerformance::new(&difficulty, &converted);
let mut gradual_2nd = TaikoGradualPerformance::new(&difficulty, &converted);
let mut gradual_3rd = TaikoGradualPerformance::new(&difficulty, &converted);
let mut state = TaikoScoreState::default();
let hit_objects_len = converted.map.hit_objects.len();
let n_hits = converted
.map
.hit_objects
.iter()
.filter(|h| h.is_circle())
.count();
for i in 1.. {
state.n_misses += 1;
let Some(next_gradual) = gradual.next(state.clone()) else {
assert_eq!(i, n_hits + 1);
assert!(gradual_2nd.last(state.clone()).is_some() || hit_objects_len % 2 == 0);
assert!(gradual_3rd.last(state.clone()).is_some() || hit_objects_len % 3 == 0);
break;
};
if i % 2 == 0 {
let next_gradual_2nd = gradual_2nd.nth(state.clone(), 1).unwrap();
assert_eq!(next_gradual, next_gradual_2nd);
}
if i % 3 == 0 {
let next_gradual_3rd = gradual_3rd.nth(state.clone(), 2).unwrap();
assert_eq!(next_gradual, next_gradual_3rd);
}
let mut regular_calc = TaikoPerformance::new(converted.as_owned())
.mods(mods)
.passed_objects(i as u32)
.state(state.clone());
let regular_state = regular_calc.generate_state();
assert_eq!(state, regular_state);
let expected = regular_calc.calculate();
assert_eq!(next_gradual, expected);
}
}
}
@@ -14,6 +14,8 @@ use super::{
Taiko,
};
pub mod gradual;
/// Performance calculator on osu!taiko maps.
#[derive(Clone, Debug, PartialEq)]
#[must_use]