feat: use rosu-mods (#36)

* use rosu-mods

* apply values from DifficultyAdjust mod

* minor doc addition

* consider Blinds mod in perf calc
This commit is contained in:
Badewanne3
2024-07-08 11:35:07 +02:00
committed by GitHub
parent 5c951b8bd4
commit 26547d7dea
19 changed files with 576 additions and 238 deletions
+199 -105
View File
@@ -1,6 +1,6 @@
use rosu_map::section::general::GameMode;
use crate::{any::difficulty::ModsDependent, util::mods::Mods, Difficulty};
use crate::{any::difficulty::ModsDependent, model::mods::GameMods, Difficulty};
use super::{converted::Converted, Beatmap};
@@ -36,11 +36,11 @@ pub struct HitWindows {
pub struct BeatmapAttributesBuilder {
mode: GameMode,
is_convert: bool,
ar: ModsDependent,
od: ModsDependent,
cs: ModsDependent,
hp: ModsDependent,
mods: u32,
ar: ModsDependentKind,
od: ModsDependentKind,
cs: ModsDependentKind,
hp: ModsDependentKind,
mods: GameMods,
clock_rate: Option<f64>,
}
@@ -60,24 +60,24 @@ impl BeatmapAttributesBuilder {
Self {
mode: GameMode::Osu,
is_convert: false,
ar: ModsDependent::new(5.0),
od: ModsDependent::new(5.0),
cs: ModsDependent::new(5.0),
hp: ModsDependent::new(5.0),
mods: 0,
ar: ModsDependentKind::DEFAULT,
od: ModsDependentKind::DEFAULT,
cs: ModsDependentKind::DEFAULT,
hp: ModsDependentKind::DEFAULT,
mods: GameMods::DEFAULT,
clock_rate: None,
}
}
/// Use the given [`Beatmap`]'s attributes, mode, and convert status.
pub const fn map(self, map: &Beatmap) -> Self {
pub fn map(self, map: &Beatmap) -> Self {
Self {
mode: map.mode,
ar: ModsDependent::new(map.ar),
od: ModsDependent::new(map.od),
cs: ModsDependent::new(map.cs),
hp: ModsDependent::new(map.hp),
mods: 0,
ar: ModsDependentKind::Default(ModsDependent::new(map.ar)),
od: ModsDependentKind::Default(ModsDependent::new(map.od)),
cs: ModsDependentKind::Default(ModsDependent::new(map.cs)),
hp: ModsDependentKind::Default(ModsDependent::new(map.hp)),
mods: GameMods::DEFAULT,
clock_rate: None,
is_convert: map.is_convert,
}
@@ -88,14 +88,13 @@ impl BeatmapAttributesBuilder {
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
pub const fn ar(self, ar: f32, with_mods: bool) -> Self {
Self {
ar: ModsDependent {
value: ar,
with_mods,
},
..self
}
pub const fn ar(mut self, ar: f32, with_mods: bool) -> Self {
self.ar = ModsDependentKind::Custom(ModsDependent {
value: ar,
with_mods,
});
self
}
/// Specify the overall difficulty.
@@ -103,14 +102,13 @@ impl BeatmapAttributesBuilder {
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
pub const fn od(self, od: f32, with_mods: bool) -> Self {
Self {
od: ModsDependent {
value: od,
with_mods,
},
..self
}
pub const fn od(mut self, od: f32, with_mods: bool) -> Self {
self.od = ModsDependentKind::Custom(ModsDependent {
value: od,
with_mods,
});
self
}
/// Specify the circle size.
@@ -118,14 +116,13 @@ impl BeatmapAttributesBuilder {
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
pub const fn cs(self, cs: f32, with_mods: bool) -> Self {
Self {
cs: ModsDependent {
value: cs,
with_mods,
},
..self
}
pub const fn cs(mut self, cs: f32, with_mods: bool) -> Self {
self.cs = ModsDependentKind::Custom(ModsDependent {
value: cs,
with_mods,
});
self
}
/// Specify the drain rate.
@@ -133,36 +130,44 @@ impl BeatmapAttributesBuilder {
/// `with_mods` determines if the given value should be used before
/// or after accounting for mods, e.g. on `true` the value will be
/// used as is and on `false` it will be modified based on the mods.
pub const fn hp(self, hp: f32, with_mods: bool) -> Self {
Self {
hp: ModsDependent {
value: hp,
with_mods,
},
..self
}
pub const fn hp(mut self, hp: f32, with_mods: bool) -> Self {
self.hp = ModsDependentKind::Custom(ModsDependent {
value: hp,
with_mods,
});
self
}
/// Specify the mods.
pub const fn mods(self, mods: u32) -> Self {
Self { mods, ..self }
/// Specify mods.
///
/// Accepted types are
/// - `u32`
/// - [`rosu_mods::GameModsLegacy`]
/// - [`rosu_mods::GameMods`]
/// - [`rosu_mods::GameModsIntermode`]
/// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
///
/// See <https://github.com/ppy/osu-api/wiki#mods>
pub fn mods(mut self, mods: impl Into<GameMods>) -> Self {
self.mods = mods.into();
self
}
/// Specify a custom clock rate.
pub const fn clock_rate(self, clock_rate: f64) -> Self {
Self {
clock_rate: Some(clock_rate),
..self
}
pub const fn clock_rate(mut self, clock_rate: f64) -> Self {
self.clock_rate = Some(clock_rate);
self
}
/// Specify a [`GameMode`] and whether it's a converted map.
pub const fn mode(self, mode: GameMode, is_convert: bool) -> Self {
Self {
mode,
is_convert,
..self
}
pub const fn mode(mut self, mode: GameMode, is_convert: bool) -> Self {
self.mode = mode;
self.is_convert = is_convert;
self
}
/// Specify all settings through [`Difficulty`].
@@ -170,22 +175,33 @@ impl BeatmapAttributesBuilder {
Self {
mode: self.mode,
is_convert: self.is_convert,
ar: difficulty.get_ar().unwrap_or(self.ar),
od: difficulty.get_od().unwrap_or(self.od),
cs: difficulty.get_cs().unwrap_or(self.cs),
hp: difficulty.get_hp().unwrap_or(self.hp),
mods: difficulty.get_mods(),
ar: difficulty
.get_ar()
.map_or(self.ar, ModsDependentKind::Custom),
od: difficulty
.get_od()
.map_or(self.od, ModsDependentKind::Custom),
cs: difficulty
.get_cs()
.map_or(self.cs, ModsDependentKind::Custom),
hp: difficulty
.get_hp()
.map_or(self.hp, ModsDependentKind::Custom),
mods: difficulty.get_mods().clone(),
clock_rate: Some(difficulty.get_clock_rate()),
}
}
/// Calculate the AR and OD hit windows.
pub fn hit_windows(&self) -> HitWindows {
let mods = self.mods;
let mods = &self.mods;
let clock_rate = self.clock_rate.unwrap_or(mods.clock_rate());
let ar_clock_rate = if self.ar.with_mods { 1.0 } else { clock_rate };
let od_clock_rate = if self.od.with_mods { 1.0 } else { clock_rate };
let clock_rate = self
.clock_rate
.unwrap_or_else(|| f64::from(mods.clock_rate()));
let ar_clock_rate = if self.ar.with_mods() { 1.0 } else { clock_rate };
let od_clock_rate = if self.od.with_mods() { 1.0 } else { clock_rate };
let mod_mult = |val: f32| {
if mods.hr() {
@@ -197,10 +213,10 @@ impl BeatmapAttributesBuilder {
}
};
let raw_ar = if self.ar.with_mods {
self.ar.value
let raw_ar = if self.ar.with_mods() {
self.ar.value(mods, GameMods::ar)
} else {
mod_mult(self.ar.value)
mod_mult(self.ar.value(mods, GameMods::ar))
};
let preempt = difficulty_range(f64::from(raw_ar), 1800.0, 1200.0, 450.0) / ar_clock_rate;
@@ -208,10 +224,10 @@ impl BeatmapAttributesBuilder {
// OD
let hit_window = match self.mode {
GameMode::Osu | GameMode::Catch => {
let raw_od = if self.od.with_mods {
self.od.value
let raw_od = if self.od.with_mods() {
self.od.value(mods, GameMods::od)
} else {
mod_mult(self.od.value)
mod_mult(self.od.value(mods, GameMods::od))
};
difficulty_range(
@@ -222,10 +238,10 @@ impl BeatmapAttributesBuilder {
) / od_clock_rate
}
GameMode::Taiko => {
let raw_od = if self.od.with_mods {
self.od.value
let raw_od = if self.od.with_mods() {
self.od.value(mods, GameMods::od)
} else {
mod_mult(self.od.value)
mod_mult(self.od.value(mods, GameMods::od))
};
let diff_range = difficulty_range(
@@ -239,14 +255,14 @@ impl BeatmapAttributesBuilder {
}
GameMode::Mania => {
let mut value = if !self.is_convert {
34.0 + 3.0 * (10.0 - self.od.value).clamp(0.0, 10.0)
} else if self.od.value.round_ties_even() > 4.0 {
34.0 + 3.0 * (10.0 - self.od.value(mods, GameMods::od)).clamp(0.0, 10.0)
} else if self.od.value(mods, GameMods::od).round_ties_even() > 4.0 {
34.0
} else {
47.0
};
if !self.od.with_mods {
if !self.od.with_mods() {
if mods.hr() {
value /= 1.4;
} else if mods.ez() {
@@ -266,22 +282,24 @@ impl BeatmapAttributesBuilder {
/// Calculate the [`BeatmapAttributes`].
pub fn build(&self) -> BeatmapAttributes {
let mods = self.mods;
let clock_rate = self.clock_rate.unwrap_or_else(|| mods.clock_rate());
let mods = &self.mods;
let clock_rate = self
.clock_rate
.unwrap_or_else(|| f64::from(mods.clock_rate()));
// HP
let mut hp = self.hp.value;
let mut hp = self.hp.value(mods, GameMods::hp);
if !self.hp.with_mods {
if !self.hp.with_mods() {
hp *= mods.od_ar_hp_multiplier() as f32;
}
hp = hp.min(10.0);
// CS
let mut cs = self.cs.value;
let mut cs = self.cs.value(mods, GameMods::cs);
if !self.cs.with_mods {
if !self.cs.with_mods() {
if mods.hr() {
cs = (cs * 1.3).min(10.0);
} else if mods.ez() {
@@ -303,7 +321,7 @@ impl BeatmapAttributesBuilder {
let od = match self.mode {
GameMode::Osu => (Self::OSU_MIN - od) / 6.0,
GameMode::Taiko => (Self::TAIKO_MIN - od) / (Self::TAIKO_MIN - Self::TAIKO_AVG) * 5.0,
GameMode::Catch | GameMode::Mania => f64::from(self.od.value),
GameMode::Catch | GameMode::Mania => f64::from(self.od.value(mods, GameMods::od)),
};
BeatmapAttributes {
@@ -345,33 +363,109 @@ impl Default for BeatmapAttributesBuilder {
}
}
#[derive(Clone, Debug, PartialEq)]
enum ModsDependentKind {
Default(ModsDependent),
Custom(ModsDependent),
}
impl ModsDependentKind {
const DEFAULT: Self = Self::Default(ModsDependent::new(5.0));
const fn with_mods(&self) -> bool {
match self {
ModsDependentKind::Default(inner) | ModsDependentKind::Custom(inner) => inner.with_mods,
}
}
fn value(&self, mods: &GameMods, mods_fn: impl Fn(&GameMods) -> Option<f32>) -> f32 {
match self {
ModsDependentKind::Default(inner) => mods_fn(mods).unwrap_or(inner.value),
ModsDependentKind::Custom(inner) => inner.value,
}
}
}
#[cfg(test)]
mod tests {
use crate::util::float_ext::FloatExt;
use rosu_mods::{generated_mods::DifficultyAdjustOsu, GameMod, GameMods};
use super::*;
#[test]
fn consider_mods() {
let attrs = BeatmapAttributesBuilder::new()
.ar(8.5, false)
.mods(64)
.build();
fn default_ar() {
let gamemod = GameMod::HiddenOsu(Default::default());
let diff = Difficulty::new().mods(GameMods::from(gamemod));
let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build();
let expected = 10.0;
assert!(attrs.ar.eq(expected), "{} != {expected}", attrs.ar);
assert_eq!(attrs.ar, 5.0);
}
#[test]
fn skip_mods() {
fn custom_ar_without_mods() {
let gamemod = GameMod::DoubleTimeOsu(Default::default());
let diff = Difficulty::new().mods(GameMods::from(gamemod));
let attrs = BeatmapAttributesBuilder::new()
.ar(8.5, true)
.mods(64)
.ar(8.5, false)
.difficulty(&diff)
.build();
let expected = 8.5;
assert_eq!(attrs.ar, 10.0);
}
assert!(attrs.ar.eq(expected), "{} != {expected}", attrs.ar);
#[test]
fn custom_ar_with_mods() {
let gamemod = GameMod::DoubleTimeOsu(Default::default());
let diff = Difficulty::new().mods(GameMods::from(gamemod));
let attrs = BeatmapAttributesBuilder::new()
.ar(8.5, true)
.difficulty(&diff)
.build();
assert_eq!(attrs.ar, 8.5);
}
#[test]
fn custom_mods_ar() {
let mut mods = GameMods::new();
mods.insert(GameMod::DoubleTimeCatch(Default::default()));
mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
approach_rate: Some(7.0),
..Default::default()
}));
let diff = Difficulty::new().mods(mods);
let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build();
assert_eq!(attrs.ar, 9.0);
}
#[test]
fn custom_ar_custom_mods_ar_without_mods() {
let mut mods = GameMods::new();
mods.insert(GameMod::DoubleTimeCatch(Default::default()));
mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
approach_rate: Some(9.0),
..Default::default()
}));
let diff = Difficulty::new().mods(mods).ar(8.5, false);
let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build();
assert_eq!(attrs.ar, 10.0);
}
#[test]
fn custom_ar_custom_mods_ar_with_mods() {
let mut mods = GameMods::new();
mods.insert(GameMod::DoubleTimeCatch(Default::default()));
mods.insert(GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu {
approach_rate: Some(9.0),
..Default::default()
}));
let diff = Difficulty::new().mods(mods).ar(8.5, true);
let attrs = BeatmapAttributesBuilder::new().difficulty(&diff).build();
assert_eq!(attrs.ar, 8.5);
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ impl Beatmap {
/// Returns a [`BeatmapAttributesBuilder`] to calculate modified beatmap
/// attributes.
pub const fn attributes(&self) -> BeatmapAttributesBuilder {
pub fn attributes(&self) -> BeatmapAttributesBuilder {
BeatmapAttributesBuilder::new().map(self)
}
+2
View File
@@ -9,3 +9,5 @@ pub mod hit_object;
/// Gamemode related types.
pub mod mode;
pub(crate) mod mods;
+225
View File
@@ -0,0 +1,225 @@
use std::fmt::{Debug, Formatter, Result as FmtResult};
use rosu_mods::{
generated_mods::{
DifficultyAdjustCatch, DifficultyAdjustMania, DifficultyAdjustOsu, DifficultyAdjustTaiko,
},
GameMod, GameModIntermode, GameMods as GameModsLazer, GameModsIntermode, GameModsLegacy,
};
/// Collection of game mods.
///
/// This type can be created through its `From<T>` implementations where `T`
/// can be
/// - `u32`
/// - [`rosu_mods::GameModsLegacy`]
/// - [`rosu_mods::GameMods`]
/// - [`rosu_mods::GameModsIntermode`]
/// - [`&rosu_mods::GameModsIntermode`](rosu_mods::GameModsIntermode)
///
/// # Example
///
/// ```
/// use rosu_pp::GameMods;
/// use rosu_mods::{GameModsIntermode, GameModsLegacy, GameMods as GameModsLazer};
///
/// let int = GameMods::from(64 + 8);
/// let legacy = GameMods::from(GameModsLegacy::Hidden | GameModsLegacy::Easy);
/// let lazer = GameMods::from(GameModsLazer::new());
/// let intermode = GameMods::from(GameModsIntermode::new());
/// ```
#[derive(Clone, PartialEq)]
pub struct GameMods {
inner: GameModsInner,
}
impl Debug for GameMods {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self.inner {
GameModsInner::Lazer(ref mods) => Debug::fmt(mods, f),
GameModsInner::Intermode(ref mods) => Debug::fmt(mods, f),
GameModsInner::Legacy(ref mods) => Debug::fmt(mods, f),
}
}
}
/// Inner type of [`GameMods`] so that remote types contained in variants don't
/// need to be re-exported.
#[derive(Clone, PartialEq)]
enum GameModsInner {
Lazer(GameModsLazer),
Intermode(GameModsIntermode),
Legacy(GameModsLegacy),
}
impl GameMods {
pub(crate) const DEFAULT: Self = Self {
inner: GameModsInner::Legacy(GameModsLegacy::NoMod),
};
/// Returns the mods' clock rate.
///
/// In case of variable clock rates like for `WindUp`, this will return
/// `1.0`.
pub(crate) fn clock_rate(&self) -> f32 {
match self.inner {
GameModsInner::Lazer(ref mods) => mods.clock_rate().unwrap_or(1.0),
GameModsInner::Intermode(ref mods) => mods.legacy_clock_rate(),
GameModsInner::Legacy(mods) => mods.clock_rate(),
}
}
pub(crate) fn od_ar_hp_multiplier(&self) -> f64 {
if self.hr() {
1.4
} else if self.ez() {
0.5
} else {
1.0
}
}
/// Check whether the mods enable `hardrock_offsets`.
pub(crate) fn hardrock_offsets(&self) -> bool {
fn custom_hardrock_offsets(mods: &GameMods) -> Option<bool> {
match mods.inner {
GameModsInner::Lazer(ref mods) => mods.iter().find_map(|gamemod| match gamemod {
GameMod::DifficultyAdjustCatch(DifficultyAdjustCatch {
hard_rock_offsets,
..
}) => *hard_rock_offsets,
_ => None,
}),
GameModsInner::Intermode(_) | GameModsInner::Legacy(_) => None,
}
}
custom_hardrock_offsets(self).unwrap_or_else(|| self.hr())
}
}
macro_rules! impl_map_attr {
( $( $fn:ident: $field:ident [ $( $mode:ident ),* ] [$s:literal] ;)* ) => {
impl GameMods {
$(
#[doc = "Check whether the mods specify a custom "]
#[doc = $s]
#[doc = "value."]
pub(crate) fn $fn(&self) -> Option<f32> {
match self.inner {
GameModsInner::Lazer(ref mods) => mods.iter().find_map(|gamemod| match gamemod {
$( impl_map_attr!( @ $mode $field) => *$field, )*
_ => None,
}),
GameModsInner::Intermode(_) | GameModsInner::Legacy(_) => None,
}
}
)*
}
};
( @ Osu $field:ident) => { GameMod::DifficultyAdjustOsu(DifficultyAdjustOsu { $field, .. }) };
( @ Taiko $field:ident) => { GameMod::DifficultyAdjustTaiko(DifficultyAdjustTaiko { $field, .. }) };
( @ Catch $field:ident) => { GameMod::DifficultyAdjustCatch(DifficultyAdjustCatch { $field, .. }) };
( @ Mania $field:ident) => { GameMod::DifficultyAdjustMania(DifficultyAdjustMania { $field, .. }) };
}
impl_map_attr! {
ar: approach_rate [Osu, Catch] ["ar"];
cs: circle_size [Osu, Catch] ["cs"];
hp: drain_rate [Osu, Taiko, Catch, Mania] ["hp"];
od: overall_difficulty [Osu, Taiko, Catch, Mania] ["od"];
}
macro_rules! impl_has_mod {
( $( $fn:ident: $sign:tt $name:ident [ $s:literal ], )* ) => {
impl GameMods {
$(
// workaround for <https://github.com/rust-lang/rust-analyzer/issues/8092>
#[doc = "Check whether [`GameMods`] contain `"]
#[doc = $s]
#[doc = "`."]
pub(crate) fn $fn(&self) -> bool {
match self.inner {
GameModsInner::Lazer(ref mods) => {
mods.contains_intermode(GameModIntermode::$name)
},
GameModsInner::Intermode(ref mods) => {
mods.contains(GameModIntermode::$name)
},
GameModsInner::Legacy(_mods) => {
impl_has_mod!(LEGACY $sign $name _mods)
},
}
}
)*
}
};
( LEGACY + $name:ident $mods:ident ) => {
$mods.contains(GameModsLegacy::$name)
};
( LEGACY - $name:ident $mods:ident ) => {
false
};
}
impl_has_mod! {
nf: + NoFail ["NoFail"],
ez: + Easy ["Easy"],
td: + TouchDevice ["TouchDevice"],
hd: + Hidden ["Hidden"],
hr: + HardRock ["HardRock"],
rx: + Relax ["Relax"],
fl: + Flashlight ["Flashlight"],
so: + SpunOut ["SpunOut"],
bl: - Blinds ["Blinds"],
}
impl Default for GameMods {
fn default() -> Self {
Self::DEFAULT
}
}
impl From<GameModsLazer> for GameMods {
fn from(mods: GameModsLazer) -> Self {
Self {
inner: GameModsInner::Lazer(mods),
}
}
}
impl From<GameModsIntermode> for GameMods {
fn from(mods: GameModsIntermode) -> Self {
Self {
inner: GameModsInner::Intermode(mods),
}
}
}
impl From<&GameModsIntermode> for GameMods {
fn from(mods: &GameModsIntermode) -> Self {
// If only legacy mods are set, use `GameModsLegacy` and thus avoid
// allocating an owned `GameModsIntermode` instance.
match mods.checked_bits() {
Some(bits) => bits.into(),
None => mods.to_owned().into(),
}
}
}
impl From<GameModsLegacy> for GameMods {
fn from(mods: GameModsLegacy) -> Self {
Self {
inner: GameModsInner::Legacy(mods),
}
}
}
impl From<u32> for GameMods {
fn from(bits: u32) -> Self {
GameModsLegacy::from_bits(bits).into()
}
}