fruits: added pp computation

This commit is contained in:
MaxOhn
2021-01-09 11:04:19 +01:00
parent 3ad2c2d0fb
commit 15fc4c6d12
2 changed files with 262 additions and 10 deletions
+1 -1
View File
@@ -11,5 +11,5 @@ A standalone crate to calculate star ratings and performance points for all [osu
- [x] mania sr
- [ ] osu pp
- [ ] taiko pp
- [ ] ctb pp
- [x] ctb pp (need testing!)
- [x] mania pp (need testing!)
+261 -9
View File
@@ -27,15 +27,18 @@ macro_rules! binary_search {
/// Star calculation for osu!ctb maps
// Slider parsing based on https://github.com/osufx/catch-the-pp
pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
pub fn stars(map: &Beatmap, mods: impl Mods) -> DifficultyAttributes {
if map.hit_objects.len() < 2 {
return 0.0;
return DifficultyAttributes::default();
}
let attributes = map.attributes().mods(mods);
let with_hr = mods.hr();
let mut ticks = Vec::new(); // using the same buffer for all sliders
let mut fruits = 0;
let mut droplets = 0;
// BUG: Incorrect object order on 2B maps that have fruits within sliders
let mut hit_objects = map
.hit_objects
@@ -48,6 +51,8 @@ pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
h = h.with_hr(last_pos, last_time);
}
fruits += 1;
Some(Some(FruitOrJuice::Fruit(Some(h))))
}
HitObjectKind::Slider {
@@ -167,6 +172,9 @@ pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
let pos = curve.point_at_distance(dist_end);
slider_objects.push((pos, h.start_time + duration));
fruits += 1 + *repeats;
droplets += slider_objects.len() - 1 - *repeats;
let iter = slider_objects.into_iter().map(CatchObject::new);
Some(Some(FruitOrJuice::Juice(iter)))
@@ -242,7 +250,14 @@ pub fn stars(map: &Beatmap, mods: impl Mods) -> f32 {
movement.process(&h);
movement.save_current_peak();
movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR
let stars = movement.difficulty_value().sqrt() * STAR_SCALING_FACTOR;
DifficultyAttributes {
stars,
n_fruits: fruits,
n_droplets: droplets,
max_combo: fruits + droplets,
}
}
#[inline]
@@ -278,6 +293,243 @@ impl<I: Iterator<Item = CatchObject>> Iterator for FruitOrJuice<I> {
}
}
#[derive(Default)]
pub struct DifficultyAttributes {
pub stars: f32,
pub max_combo: usize,
pub n_fruits: usize,
pub n_droplets: usize,
}
pub struct PpResult {
pub pp: f32,
pub stars: f32,
}
pub trait PpProvider {
fn pp(&self) -> PpCalculator;
}
impl PpProvider for Beatmap {
fn pp(&self) -> PpCalculator {
PpCalculator::new(self)
}
}
// TODO: Allow partial plays
pub struct PpCalculator<'m> {
map: &'m Beatmap,
attributes: Option<DifficultyAttributes>,
mods: u32,
combo: Option<usize>,
n_fruits: Option<usize>,
n_droplets: Option<usize>,
n_tiny_droplets: Option<usize>,
n_tiny_droplet_misses: Option<usize>,
n_misses: usize,
}
impl<'m> PpCalculator<'m> {
pub fn new(map: &'m Beatmap) -> Self {
Self {
map,
attributes: None,
mods: 0,
combo: None,
n_fruits: None,
n_droplets: None,
n_tiny_droplets: None,
n_tiny_droplet_misses: None,
n_misses: 0,
}
}
pub fn attributes(mut self, attributes: DifficultyAttributes) -> Self {
self.attributes.replace(attributes);
self
}
pub fn mods(mut self, mods: u32) -> Self {
self.mods = mods;
self
}
pub fn combo(mut self, combo: usize) -> Self {
self.combo.replace(combo);
self
}
pub fn fruits(mut self, n_fruits: usize) -> Self {
self.n_fruits.replace(n_fruits);
self
}
pub fn droplets(mut self, n_droplets: usize) -> Self {
self.n_droplets.replace(n_droplets);
self
}
pub fn tiny_droplets(mut self, n_tiny_droplets: usize) -> Self {
self.n_tiny_droplets.replace(n_tiny_droplets);
self
}
pub fn tiny_droplet_misses(mut self, n_tiny_droplet_misses: usize) -> Self {
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
self
}
pub fn misses(mut self, n_misses: usize) -> Self {
self.n_misses = n_misses;
self
}
/// Generate the hit results with respect to the given accuracy between `0` and `100`.
///
/// Be sure to set `misses` beforehand! Also, if available, set `attributes` beforehand.
pub fn accuracy(mut self, acc: f32) -> Self {
if self.attributes.is_none() {
self.attributes.replace(stars(self.map, self.mods));
}
let attributes = self.attributes.as_ref().unwrap();
let n_droplets = self
.n_droplets
.unwrap_or_else(|| attributes.n_droplets.saturating_sub(self.n_misses));
let n_fruits = self.n_fruits.unwrap_or_else(|| {
attributes
.max_combo
.saturating_sub(self.n_misses.saturating_sub(n_droplets))
});
let max_tiny_droplets = 0; // TODO
let n_tiny_droplets = self.n_tiny_droplets.unwrap_or_else(|| {
((acc * (attributes.max_combo + max_tiny_droplets) as f32).round() as usize)
.saturating_sub(n_fruits)
.saturating_sub(n_droplets)
});
let n_tiny_droplet_misses = max_tiny_droplets - n_tiny_droplets;
self.n_fruits.replace(n_fruits);
self.n_droplets.replace(n_droplets);
self.n_tiny_droplets.replace(n_tiny_droplets);
self.n_tiny_droplet_misses.replace(n_tiny_droplet_misses);
self
}
pub fn calculate(mut self) -> PpResult {
let attributes = self
.attributes
.take()
.unwrap_or_else(|| stars(self.map, self.mods));
let stars = attributes.stars;
// Relying heavily on aim
let mut pp = (5.0 * ((stars / 0.0049).max(1.0)) - 4.0).powi(2) / 100_000.0;
let mut combo_hits = self.combo_hits();
if combo_hits == 0 {
combo_hits = attributes.max_combo;
}
// Longer maps are worth more
let len_bonus = 0.95
+ 0.3 * (combo_hits as f32 / 2500.0).min(1.0)
+ (combo_hits > 2500) as u8 as f32 * (combo_hits as f32 / 2500.0).log10() * 0.475;
pp *= len_bonus;
// Penalize misses exponentially
pp *= 0.97_f32.powi(self.n_misses as i32);
// Combo scaling
if let Some(combo) = self.combo.filter(|_| attributes.max_combo > 0) {
pp *= (combo as f32 / attributes.max_combo as f32)
.powf(0.8)
.min(1.0);
}
// AR scaling
let ar = self.map.ar;
let mut ar_factor = 1.0;
if ar > 9.0 {
ar_factor += 0.1 * (ar - 9.0) + (ar > 10.0) as u8 as f32 * 0.1 * (ar - 10.0);
} else if ar < 8.0 {
ar_factor += 0.025 * (8.0 - ar);
}
pp *= ar_factor;
// HD bonus
if self.mods.hd() {
if ar <= 10.0 {
pp *= 1.05 + 0.075 * (10.0 - ar);
} else if ar > 10.0 {
pp *= 1.01 + 0.04 * (11.0 - ar.min(11.0));
}
}
// FL bonus
if self.mods.fl() {
pp *= 1.35 * len_bonus;
}
// Accuracy scaling
pp *= self.acc().powf(5.5);
// NF penalty
if self.mods.nf() {
pp *= 0.9;
}
PpResult { pp, stars }
}
#[inline]
fn combo_hits(&self) -> usize {
self.n_fruits.unwrap_or(0) + self.n_droplets.unwrap_or(0) + self.n_misses
}
#[inline]
fn successful_hits(&self) -> usize {
self.n_fruits.unwrap_or(0)
+ self.n_droplets.unwrap_or(0)
+ self.n_tiny_droplets.unwrap_or(0)
}
#[inline]
fn total_hits(&self) -> usize {
self.successful_hits() + self.n_tiny_droplet_misses.unwrap_or(0) + self.n_misses
}
#[inline]
fn acc(&self) -> f32 {
let total_hits = self.total_hits();
if total_hits == 0 {
0.0
} else {
(self.successful_hits() as f32 / total_hits as f32)
.max(0.0)
.min(1.0)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -301,7 +553,7 @@ mod tests {
};
let mods = 0;
let stars = stars(&map, mods);
let stars = stars(&map, mods).stars;
println!("Stars: {} [map={} | mods={}]", stars, map_id, mods);
}
@@ -323,26 +575,26 @@ mod tests {
(1974968, 1 << 6, 3.650649037957456), // DT
(1974968, 1 << 4, 3.566302788963401), // HR
(1974968, 1 << 1, 2.2029392066882654),// EZ
(2420076, 1 << 8, 4.791039358886245), // HT
(2420076, 0, 6.223136555625056), // NM
(2420076, 1 << 6, 8.908315960310958), // DT
(2420076, 1 << 4, 6.54788067620051), // HR
(2420076, 1 << 1, 6.067971540209479), // EZ
(2206596, 1 << 8, 4.767182611189798), // HT
(2206596, 0, 6.157660207091584), // NM
(2206596, 1 << 6, 8.93391286552717), // DT
(2206596, 1 << 4, 6.8639096665110735),// HR
(2206596, 1 << 1, 5.60279198088948), // EZ
// Super long juice stream towards end
// (1972149, 1 << 8, 4.671425766413811), // HT
// (1972149, 0, 6.043742871084152), // NM
// (1972149, 1 << 6, 8.469259368304225), // DT
// (1972149, 1 << 4, 6.81222485322862), // HR
// (1972149, 1 << 1, 5.289343020686747), // EZ
// Convert slider fiesta
// (1657535, 1 << 8, 3.862453635711741), // HT
// (1657535, 0, 4.792543335869686), // NM
@@ -362,7 +614,7 @@ mod tests {
Err(why) => panic!("Error while parsing map {}: {}", map_id, why),
};
let stars = stars(&map, mods);
let stars = stars(&map, mods).stars;
assert!(
(stars - expected_stars).abs() < margin,