diff --git a/Cargo.lock b/Cargo.lock index 5f0acf5..717f6ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,24 +136,24 @@ dependencies = [ [[package]] name = "rosu-map" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c55926c8f0fed1db12fbe96f7a6083a2c4186443dd32532ab34e6902467a4f3" +checksum = "21de4f8c5cd2738e1c9a2ed477aad2579dacd5acf3845d80738fcec160d0bd8a" [[package]] name = "rosu-mods" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69daf02885f7477085403a6eada6215f44333c7b54355ea1c4e276a02263bde" +checksum = "2a0fb1475e860c987673e8abc1df5d23f22c4e1ae62e9459e656cff4d82f603b" dependencies = [ "serde", ] [[package]] name = "rosu-pp" -version = "1.1.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "002a6b12cedcb185f4051f0b3d0466e0b61ff414a9ca8375f09be581c0e70f06" +checksum = "1f62afdb7d6eb23f73057d4c0e38ee842bb653ffa702ef3218ca69776e45ddc1" dependencies = [ "rosu-map", "rosu-mods", diff --git a/Cargo.toml b/Cargo.toml index 59675a0..ecae8d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,9 +14,9 @@ crate-type = ["cdylib"] [dependencies] pyo3 = { version = "0.22", features = ["extension-module", "macros"] } -rosu-mods = { version = "0.1.0", default-features = false, features = ["serde"] } -rosu-pp = { version = "1.0.0", features = ["sync"] } -serde = "1.0.203" +rosu-mods = { version = "0.2.0", default-features = false, features = ["serde"] } +rosu-pp = { version = "2.0.0", features = ["sync"] } +serde = { version = "1.0.203" } [profile.release] lto = true diff --git a/README.md b/README.md index a0383a4..52fc1d0 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,8 @@ import rosu_pp_py as rosu # either `path`, `bytes`, or `content` must be specified when parsing a map map = rosu.Beatmap(path = "/path/to/file.osu") -# Optionally convert to a specific mode -map.convert(rosu.GameMode.Mania) +# Optionally convert to a specific mode for optionally given mods +map.convert(rosu.GameMode.Mania, "6K") perf = rosu.Performance( # various kwargs available diff --git a/rosu_pp_py.pyi b/rosu_pp_py.pyi index d474a5e..11f0d9f 100644 --- a/rosu_pp_py.pyi +++ b/rosu_pp_py.pyi @@ -47,13 +47,13 @@ class Beatmap: def __init__(self, **kwargs) -> None: ... - def convert(self, mode: GameMode) -> None: + def convert(self, mode: GameMode, mods: Optional[GameMods]) -> None: """ Convert the beatmap to the specified mode ## Raises - Throws an exception if the specified mode is incompatible with the map's mode + Throws an exception if conversion fails or mods are invalid """ @property @@ -178,6 +178,11 @@ class Difficulty: Adjust patterns as if the HR mod is enabled. Only relevant for osu!catch. + `'lazer': bool` + Whether the calculated attributes belong to an osu!lazer or + osu!stable score. + + Defaults to `true`. """ def __init__(self, **kwargs) -> None: ... @@ -266,6 +271,8 @@ class Difficulty: def set_hardrock_offsets(self, hardrock_offsets: Optional[bool]) -> None: ... + def set_lazer(self, lazer: Optional[bool]) -> None: ... + class Performance: """ Builder for a performance calculation @@ -337,12 +344,39 @@ class Performance: Adjust patterns as if the HR mod is enabled. Only relevant for osu!catch. + `'lazer': bool` + Whether the calculated attributes belong to an osu!lazer or + osu!stable score. + + Defaults to `true`. `'accuracy': float` Set the accuracy between `0.0` and `100.0`. `'combo': int` Specify the max combo of the play. Irrelevant for osu!mania. + `'large_tick_hits': int` + The amount of "large tick" hits. + + Only relevant for osu!standard. + + The meaning depends on the kind of score: + - if set on osu!stable, this value is irrelevant and can be `0` + - if set on osu!lazer *without* `CL`, this value is the amount of hit + slider ticks and repeats + - if set on osu!lazer *with* `CL`, this value is the amount of hit + slider heads, ticks, and repeats + `'small_tick_hits': int` + The amount of "small tick" hits. + + These are essentially the slider end hits for lazer scores without + slider accuracy. + + Only relevant for osu!standard. + `'slider_end_hits': int` + The amount of slider end hits. + + Only relevant for osu!standard in lazer. `'n_geki': int` Specify the amount of gekis of a play. @@ -445,10 +479,18 @@ class Performance: def set_hardrock_offsets(self, hardrock_offsets: Optional[bool]) -> None: ... + def set_lazer(self, lazer: Optional[bool]) -> None: ... + def set_accuracy(self, accuracy: Optional[float]) -> None: ... def set_combo(self, combo: Optional[int]) -> None: ... + def set_large_tick_hits(self, n_large_ticks: Optional[int]) -> None: ... + + def set_small_tick_hits(self, n_large_ticks: Optional[int]) -> None: ... + + def set_slider_end_hits(self, n_slider_ends: Optional[int]) -> None: ... + def set_n_geki(self, n_geki: Optional[int]) -> None: ... def set_n_katu(self, n_katu: Optional[int]) -> None: ... @@ -667,6 +709,33 @@ class ScoreState: Irrelevant for osu!mania. """ + osu_large_tick_hits: int + """ + "Large tick" hits for osu!standard. + + The meaning depends on the kind of score: + - if set on osu!stable, this field is irrelevant and can be `0` + - if set on osu!lazer *without* `CL`, this field is the amount of hit + slider ticks and repeats + - if set on osu!lazer *with* `CL`, this field is the amount of hit + slider heads, ticks, and repeats + """ + + osu_small_tick_hits: int + """ + "Small tick" hits for osu!standard. + + These are essentially the slider end hits for lazer scores without + slider accuracy. + """ + + slider_end_hits: int + """ + Amount of successfully hit slider ends. + + Only relevant for osu!standard in lazer. + """ + n_geki: int """ Amount of current gekis (n320 for osu!mania). @@ -760,6 +829,22 @@ class DifficultyAttributes: Only available for osu!. """ + @property + def aim_difficult_strain_count(self) -> Optional[float]: + """ + Weighted sum of aim strains. + + Only available for osu!. + """ + + @property + def speed_difficult_strain_count(self) -> Optional[float]: + """ + Weighted sum of speed strains. + + Only available for osu!. + """ + @property def od(self) -> Optional[float]: """ @@ -792,6 +877,21 @@ class DifficultyAttributes: Only available for osu!. """ + @property + def n_large_ticks(self) -> Optional[int]: + """ + The amount of "large tick" hits. + + Only relevant for osu!standard. + + The meaning depends on the kind of score: + - if set on osu!stable, this value is irrelevant and can be `0` + - if set on osu!lazer *without* `CL`, this value is the amount of hit + slider ticks and repeats + - if set on osu!lazer *with* `CL`, this value is the amount of hit + slider heads, ticks, and repeats + """ + @property def n_spinners(self) -> Optional[int]: """ @@ -808,6 +908,14 @@ class DifficultyAttributes: Only available for osu!taiko. """ + @property + def single_color_stamina(self) -> Optional[float]: + """ + The difficulty of the single color stamina skill. + + Only available for osu!taiko. + """ + @property def rhythm(self) -> Optional[float]: """ @@ -864,6 +972,14 @@ class DifficultyAttributes: Only available for osu!mania. """ + @property + def n_hold_notes(self) -> Optional[int]: + """ + The amount of hold notes in the map. + + Only available for osu!mania. + """ + @property def ar(self) -> Optional[float]: """ @@ -873,13 +989,21 @@ class DifficultyAttributes: """ @property - def hit_window(self) -> Optional[float]: + def great_hit_window(self) -> Optional[float]: """ The perceived hit window for an n300 inclusive of rate-adjusting mods (DT/HT/etc) Only available for osu!taiko and osu!mania. """ + @property + def ok_hit_window(self) -> Optional[float]: + """ + The perceived hit window for an n100 inclusive of rate-adjusting mods (DT/HT/etc) + + Only available for osu!taiko. + """ + @property def max_combo(self) -> int: """ @@ -943,6 +1067,14 @@ class PerformanceAttributes: Only available for osu! and osu!taiko. """ + @property + def estimated_unstable_rate(self) -> Optional[float]: + """ + Upper bound on the player's tap deviation. + + Only *optionally* available for osu!taiko. + """ + @property def pp_difficulty(self) -> Optional[float]: """ @@ -1019,6 +1151,12 @@ class Strains: Strain peaks of the stamina skill in osu!taiko. """ + @property + def single_color_stamina(self) -> Optional[List[float]]: + """ + Strain peaks of the single color stamina skill in osu!taiko. + """ + @property def movement(self) -> Optional[List[float]]: """ @@ -1058,7 +1196,15 @@ class BeatmapAttributes: """ @property - def od_hitwindow(self) -> float: + def od_great_hitwindow(self) -> float: """ Hit window for overall difficulty i.e. time to hit a 300 ("Great") in milliseconds. """ + + @property + def od_ok_hitwindow(self) -> float: + """ + Hit window for overall difficulty i.e. time to hit a 100 ("Ok") in milliseconds. + + Not available for osu!mania. + """ diff --git a/src/attributes/beatmap.rs b/src/attributes/beatmap.rs index 4ef13a4..836dac8 100644 --- a/src/attributes/beatmap.rs +++ b/src/attributes/beatmap.rs @@ -229,7 +229,8 @@ define_class! { pub hp: f64!, pub clock_rate: f64!, pub ar_hitwindow: f64!, - pub od_hitwindow: f64!, + pub od_great_hitwindow: f64!, + pub od_ok_hitwindow: f64?, } } @@ -244,7 +245,8 @@ impl From for PyBeatmapAttributes { hit_windows: HitWindows { ar: ar_hitwindow, - od: od_hitwindow, + od_great: od_great_hitwindow, + od_ok: od_ok_hitwindow, }, } = attrs; @@ -255,7 +257,8 @@ impl From for PyBeatmapAttributes { hp, clock_rate, ar_hitwindow, - od_hitwindow, + od_great_hitwindow, + od_ok_hitwindow, } } } diff --git a/src/attributes/difficulty.rs b/src/attributes/difficulty.rs index 5c2aace..19f6a0f 100644 --- a/src/attributes/difficulty.rs +++ b/src/attributes/difficulty.rs @@ -18,10 +18,13 @@ define_class! { pub flashlight: f64?, pub slider_factor: f64?, pub speed_note_count: f64?, + pub aim_difficult_strain_count: f64?, + pub speed_difficult_strain_count: f64?, pub od: f64?, pub hp: f64?, pub n_circles: u32?, pub n_sliders: u32?, + pub n_large_ticks: u32?, pub n_spinners: u32?, pub stamina: f64?, pub rhythm: f64?, @@ -31,8 +34,11 @@ define_class! { pub n_droplets: u32?, pub n_tiny_droplets: u32?, pub n_objects: u32?, + pub n_hold_notes: u32?, pub ar: f64?, - pub hit_window: f64?, + pub great_hit_window: f64?, + pub ok_hit_window: f64?, + pub mono_stamina_factor: f64?, pub max_combo: u32!, } } @@ -45,11 +51,14 @@ impl From for PyDifficultyAttributes { flashlight, slider_factor, speed_note_count, + aim_difficult_strain_count, + speed_difficult_strain_count, ar, od, hp, n_circles, n_sliders, + n_large_ticks, n_spinners, stars, max_combo, @@ -64,11 +73,14 @@ impl From for PyDifficultyAttributes { flashlight: Some(flashlight), slider_factor: Some(slider_factor), speed_note_count: Some(speed_note_count), + aim_difficult_strain_count: Some(aim_difficult_strain_count), + speed_difficult_strain_count: Some(speed_difficult_strain_count), ar: Some(ar), od: Some(od), hp: Some(hp), n_circles: Some(n_circles), n_sliders: Some(n_sliders), + n_large_ticks: Some(n_large_ticks), n_spinners: Some(n_spinners), max_combo, ..Self::default() @@ -83,7 +95,9 @@ impl From for PyDifficultyAttributes { rhythm, color, peak, - hit_window, + great_hit_window, + ok_hit_window, + mono_stamina_factor, stars, max_combo, is_convert, @@ -97,7 +111,9 @@ impl From for PyDifficultyAttributes { rhythm: Some(rhythm), color: Some(color), peak: Some(peak), - hit_window: Some(hit_window), + great_hit_window: Some(great_hit_window), + ok_hit_window: Some(ok_hit_window), + mono_stamina_factor: Some(mono_stamina_factor), max_combo, ..Self::default() } @@ -137,6 +153,7 @@ impl From for PyDifficultyAttributes { stars, hit_window, n_objects, + n_hold_notes, max_combo, is_convert, } = attrs; @@ -145,8 +162,9 @@ impl From for PyDifficultyAttributes { mode: PyGameMode::Mania, stars, is_convert, - hit_window: Some(hit_window), + great_hit_window: Some(hit_window), n_objects: Some(n_objects), + n_hold_notes: Some(n_hold_notes), max_combo, ..Self::default() } @@ -177,10 +195,13 @@ impl TryFrom for DifficultyAttributes { flashlight, slider_factor, speed_note_count, + aim_difficult_strain_count, + speed_difficult_strain_count, od, hp, n_circles, n_sliders, + n_large_ticks, n_spinners, stamina, rhythm, @@ -190,8 +211,11 @@ impl TryFrom for DifficultyAttributes { n_droplets, n_tiny_droplets, n_objects, + n_hold_notes, ar, - hit_window, + great_hit_window, + ok_hit_window, + mono_stamina_factor, max_combo, } = attrs; @@ -203,11 +227,14 @@ impl TryFrom for DifficultyAttributes { Some(flashlight), Some(slider_factor), Some(speed_note_count), + Some(aim_difficult_strain_count), + Some(speed_difficult_strain_count), Some(ar), Some(od), Some(hp), Some(n_circles), Some(n_sliders), + Some(n_large_ticks), Some(n_spinners), ) = ( aim, @@ -215,11 +242,14 @@ impl TryFrom for DifficultyAttributes { flashlight, slider_factor, speed_note_count, + aim_difficult_strain_count, + speed_difficult_strain_count, ar, od, hp, n_circles, n_sliders, + n_large_ticks, n_spinners, ) { return Ok(Self::Osu(OsuDifficultyAttributes { @@ -228,11 +258,14 @@ impl TryFrom for DifficultyAttributes { flashlight, slider_factor, speed_note_count, + aim_difficult_strain_count, + speed_difficult_strain_count, ar, od, hp, n_circles, n_sliders, + n_large_ticks, n_spinners, stars, max_combo, @@ -240,15 +273,31 @@ impl TryFrom for DifficultyAttributes { } } PyGameMode::Taiko => { - if let (Some(stamina), Some(rhythm), Some(color), Some(peak), Some(hit_window)) = - (stamina, rhythm, color, peak, hit_window) - { + if let ( + Some(stamina), + Some(rhythm), + Some(color), + Some(peak), + Some(great_hit_window), + Some(ok_hit_window), + Some(mono_stamina_factor), + ) = ( + stamina, + rhythm, + color, + peak, + great_hit_window, + ok_hit_window, + mono_stamina_factor, + ) { return Ok(Self::Taiko(TaikoDifficultyAttributes { stamina, rhythm, color, peak, - hit_window, + great_hit_window, + ok_hit_window, + mono_stamina_factor, stars, max_combo, is_convert, @@ -270,11 +319,14 @@ impl TryFrom for DifficultyAttributes { } } PyGameMode::Mania => { - if let (Some(hit_window), Some(n_objects)) = (hit_window, n_objects) { + if let (Some(hit_window), Some(n_objects), Some(n_hold_notes)) = + (great_hit_window, n_objects, n_hold_notes) + { return Ok(Self::Mania(ManiaDifficultyAttributes { stars, hit_window, n_objects, + n_hold_notes, max_combo, is_convert, })); diff --git a/src/attributes/performance.rs b/src/attributes/performance.rs index fb16aa4..9c086c5 100644 --- a/src/attributes/performance.rs +++ b/src/attributes/performance.rs @@ -21,6 +21,7 @@ define_class! { pub pp_speed: f64?, pub pp_accuracy: f64?, pub effective_miss_count: f64?, + pub estimated_unstable_rate: f64?, pub pp_difficulty: f64?, } } @@ -58,6 +59,7 @@ impl From for PyPerformanceAttributes { pp_acc, pp_difficulty, effective_miss_count, + estimated_unstable_rate, } = attrs; Self { @@ -66,6 +68,7 @@ impl From for PyPerformanceAttributes { pp_accuracy: Some(pp_acc), pp_difficulty: Some(pp_difficulty), effective_miss_count: Some(effective_miss_count), + estimated_unstable_rate, ..Self::default() } } diff --git a/src/beatmap.rs b/src/beatmap.rs index 245244c..89fc667 100644 --- a/src/beatmap.rs +++ b/src/beatmap.rs @@ -7,16 +7,14 @@ use pyo3::{ Bound, PyResult, }; use rosu_pp::{ - model::{ - hit_object::HitObjectKind, - mode::{ConvertStatus, GameMode}, - }, - Beatmap, + model::{hit_object::HitObjectKind, mode::GameMode}, + Beatmap, GameMods, }; use crate::{ error::{ArgsError, ConvertError, ParseError}, mode::PyGameMode, + mods::PyGameMods, }; #[pyclass(name = "Beatmap")] @@ -100,13 +98,19 @@ impl PyBeatmap { Ok(Self { inner: map }) } - fn convert(&mut self, mode: PyGameMode) -> PyResult<()> { - let mode = GameMode::from(mode); + #[pyo3(signature = (mode, mods=None))] + fn convert(&mut self, mode: PyGameMode, mods: Option) -> PyResult<()> { + let mods = match mods { + None => GameMods::default(), + Some(PyGameMods::Lazer(mods)) => mods.into(), + Some(PyGameMods::Intermode(mods)) => mods.into(), + Some(PyGameMods::Legacy(mods)) => mods.into(), + }; - if let ConvertStatus::Incompatible = self.inner.convert_in_place(mode) { - let err = format!("Cannot convert {:?} to {mode:?}", self.inner.mode); + let mode = GameMode::from(mode); - return Err(ConvertError::new_err(err)); + if let Err(err) = self.inner.convert_mut(mode, &mods) { + return Err(ConvertError::new_err(err.to_string())); } Ok(()) diff --git a/src/difficulty.rs b/src/difficulty.rs index 0555270..ae20dfb 100644 --- a/src/difficulty.rs +++ b/src/difficulty.rs @@ -31,6 +31,7 @@ pub struct PyDifficulty { pub(crate) od_with_mods: bool, pub(crate) passed_objects: Option, pub(crate) hardrock_offsets: Option, + pub(crate) lazer: Option, } #[pymethods] @@ -45,85 +46,21 @@ impl PyDifficulty { }; for (key, value) in kwargs { - match key.extract()? { - "mods" => { - this.mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'mods': must be GameMods"))? - } - "clock_rate" => { - this.clock_rate = - Some(value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'clock_rate': must be a float") - })?) - } - "ar" => { - this.ar = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'ar': must be a float"))?, - ) - } - "ar_with_mods" => { - this.ar_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'ar_with_mods': must be a bool"))? - } - "cs" => { - this.cs = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'cs': must be a float"))?, - ) - } - "cs_with_mods" => { - this.cs_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'cs_with_mods': must be a bool"))? - } - "hp" => { - this.hp = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'hp': must be a float"))?, - ) - } - "hp_with_mods" => { - this.hp_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'hp_with_mods': must be a bool"))? - } - "od" => { - this.od = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'od': must be a float"))?, - ) - } - "od_with_mods" => { - this.od_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'od_with_mods': must be a bool"))? - } - "passed_objects" => { - this.passed_objects = Some(value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'passed_objects': must be an int") - })?) - } - "hardrock_offsets" => { - this.hardrock_offsets = Some(value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'hardrock_offsets': must be a bool") - })?) - } - kwarg => { - let err = format!( - "unexpected kwarg '{kwarg}': expected 'mods', \n\ - 'clock_rate', 'ar', 'ar_with_mods', 'cs', 'cs_with_mods', \n\ - 'hp', 'hp_with_mods', 'od', 'od_with_mods', \n\ - 'passed_objects', or 'hardrock_offsets'" - ); - - return Err(ArgsError::new_err(err)); + extract_args! { + this.key = value { + mods: GameMods, + clock_rate: float, + ar: float, + ar_with_mods: bool, + cs: float, + cs_with_mods: bool, + hp: float, + hp_with_mods: bool, + od: float, + od_with_mods: bool, + passed_objects: int, + hardrock_offsets: bool, + lazer: bool, } } } @@ -153,6 +90,7 @@ impl PyDifficulty { od_with_mods, passed_objects, hardrock_offsets, + lazer, } = self; PyPerformance { @@ -168,6 +106,7 @@ impl PyDifficulty { od_with_mods: *od_with_mods, passed_objects: *passed_objects, hardrock_offsets: *hardrock_offsets, + lazer: *lazer, ..PyPerformance::default() } } @@ -185,6 +124,11 @@ impl PyDifficulty { self.mods = mods.unwrap_or_default(); } + #[pyo3(signature = (lazer=None))] + fn set_lazer(&mut self, lazer: Option) { + self.lazer = lazer; + } + #[pyo3(signature = (clock_rate=None))] fn set_clock_rate(&mut self, clock_rate: Option) { self.clock_rate = clock_rate; diff --git a/src/macros.rs b/src/macros.rs index 75a9845..b860e15 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -76,3 +76,36 @@ macro_rules! define_class { Option<$ty> }; } + +macro_rules! extract_args { + ( $this:ident . $key:ident = $value:ident { + $( $field:ident: $ty:ident, )+ + } ) => { + match $key.extract()? { + $( + stringify!($field) => { + $this.$field = $value + .extract() + .map_err(|_| PyTypeError::new_err(concat!( + "kwarg '", + stringify!($field), + "': must be ", + stringify!($ty), + )))? + } + ),* + kwarg => { + return Err(ArgsError::new_err(extract_args!( + @ERR kwarg: $( $field ),* + ))); + } + } + }; + (@ERR $kwarg:ident: $first_field:ident $(, $field:ident )*) => { + format!(concat!( + "unexpected kwarg '{}': expected ", + stringify!($first_field), + $( ", ", stringify!($field), )* + ), $kwarg) + }; +} diff --git a/src/performance.rs b/src/performance.rs index 4bb08b6..ce641e4 100644 --- a/src/performance.rs +++ b/src/performance.rs @@ -32,8 +32,12 @@ pub struct PyPerformance { pub(crate) od_with_mods: bool, pub(crate) passed_objects: Option, pub(crate) hardrock_offsets: Option, + pub(crate) lazer: Option, pub(crate) accuracy: Option, pub(crate) combo: Option, + pub(crate) large_tick_hits: Option, + pub(crate) small_tick_hits: Option, + pub(crate) slider_end_hits: Option, pub(crate) n_geki: Option, pub(crate) n_katu: Option, pub(crate) n300: Option, @@ -55,149 +59,33 @@ impl PyPerformance { }; for (key, value) in kwargs { - match key.extract()? { - "mods" => { - this.mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'mods': must be GameMods"))? - } - "clock_rate" => { - this.clock_rate = - Some(value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'clock_rate': must be a float") - })?) - } - "ar" => { - this.ar = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'ar': must be a float"))?, - ) - } - "ar_with_mods" => { - this.ar_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'ar_with_mods': must be a bool"))? - } - "cs" => { - this.cs = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'cs': must be a float"))?, - ) - } - "cs_with_mods" => { - this.cs_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'cs_with_mods': must be a bool"))? - } - "hp" => { - this.hp = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'hp': must be a float"))?, - ) - } - "hp_with_mods" => { - this.hp_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'hp_with_mods': must be a bool"))? - } - "od" => { - this.od = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'od': must be a float"))?, - ) - } - "od_with_mods" => { - this.od_with_mods = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'od_with_mods': must be a bool"))? - } - "passed_objects" => { - this.passed_objects = value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'passed_objects': must be an int") - })? - } - "hardrock_offsets" => { - this.hardrock_offsets = value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'hardrock_offsets': must be a bool") - })? - } - "accuracy" => { - this.accuracy = - Some(value.extract().map_err(|_| { - PyTypeError::new_err("kwarg 'accuracy': must be a float") - })?) - } - "combo" => { - this.combo = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'combo': must be an int"))?, - ) - } - "n_geki" => { - this.n_geki = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n_geki': must be an int"))?, - ) - } - "n_katu" => { - this.n_katu = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n_katu': must be an int"))?, - ) - } - "n300" => { - this.n300 = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n300': must be an int"))?, - ) - } - "n100" => { - this.n100 = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n100': must be an int"))?, - ) - } - "n50" => { - this.n50 = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n50': must be an int"))?, - ) - } - "misses" => { - this.misses = Some( - value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'misses': must be an int"))?, - ) - } - "hitresult_priority" => { - this.hitresult_priority = value.extract().map_err(|_| { - PyTypeError::new_err( - "kwarg 'hitresult_priority': must be a HitResultPriority", - ) - })?; - } - kwarg => { - let err = format!( - "unexpected kwarg '{kwarg}': expected 'mods', \n\ - 'clock_rate', 'ar', 'ar_with_mods', 'cs', \n\ - 'cs_with_mods', 'hp', 'hp_with_mods', 'od', \n\ - 'od_with_mods', 'passed_objects', 'hardrock_offsets', \n\ - 'accuracy', 'combo', 'n_geki', 'n_katu', 'n300', 'n100', \n\ - 'n50', 'misses', or 'hitresult_priority'" - ); - - return Err(ArgsError::new_err(err)); + extract_args! { + this.key = value { + mods: GameMods, + clock_rate: float, + ar: float, + ar_with_mods: bool, + cs: float, + cs_with_mods: bool, + hp: float, + hp_with_mods: bool, + od: float, + od_with_mods: bool, + passed_objects: int, + hardrock_offsets: bool, + lazer: bool, + accuracy: float, + combo: int, + large_tick_hits: int, + small_tick_hits: int, + slider_end_hits: int, + n_geki: int, + n_katu: int, + n300: int, + n100: int, + n50: int, + misses: int, + hitresult_priority: HitResultPriority, } } } @@ -244,6 +132,7 @@ impl PyPerformance { od_with_mods, passed_objects, hardrock_offsets, + lazer, .. } = self; @@ -260,6 +149,7 @@ impl PyPerformance { od_with_mods: *od_with_mods, passed_objects: *passed_objects, hardrock_offsets: *hardrock_offsets, + lazer: *lazer, } } @@ -268,6 +158,11 @@ impl PyPerformance { self.mods = mods.unwrap_or_default(); } + #[pyo3(signature = (lazer=None))] + fn set_lazer(&mut self, lazer: Option) { + self.lazer = lazer; + } + #[pyo3(signature = (clock_rate=None))] fn set_clock_rate(&mut self, clock_rate: Option) { self.clock_rate = clock_rate; @@ -317,6 +212,21 @@ impl PyPerformance { self.combo = combo; } + #[pyo3(signature = (n_large_ticks=None))] + fn set_large_tick_hits(&mut self, n_large_ticks: Option) { + self.large_tick_hits = n_large_ticks; + } + + #[pyo3(signature = (n_small_ticks=None))] + fn set_small_tick_hits(&mut self, n_small_ticks: Option) { + self.small_tick_hits = n_small_ticks; + } + + #[pyo3(signature = (n_slider_ends=None))] + fn set_slider_end_hits(&mut self, n_slider_ends: Option) { + self.slider_end_hits = n_slider_ends; + } + #[pyo3(signature = (n_geki=None))] fn set_n_geki(&mut self, n_geki: Option) { self.n_geki = n_geki; @@ -363,6 +273,18 @@ impl PyPerformance { perf = perf.combo(combo); } + if let Some(slider_end_hits) = self.slider_end_hits { + perf = perf.slider_end_hits(slider_end_hits); + } + + if let Some(large_tick_hits) = self.large_tick_hits { + perf = perf.large_tick_hits(large_tick_hits); + } + + if let Some(small_tick_hits) = self.small_tick_hits { + perf = perf.small_tick_hits(small_tick_hits); + } + if let Some(n_geki) = self.n_geki { perf = perf.n_geki(n_geki); } diff --git a/src/score_state.rs b/src/score_state.rs index aa05774..f42436a 100644 --- a/src/score_state.rs +++ b/src/score_state.rs @@ -16,6 +16,12 @@ pub struct PyScoreState { #[pyo3(get, set)] max_combo: u32, #[pyo3(get, set)] + osu_large_tick_hits: u32, + #[pyo3(get, set)] + osu_small_tick_hits: u32, + #[pyo3(get, set)] + slider_end_hits: u32, + #[pyo3(get, set)] n_geki: u32, #[pyo3(get, set)] n_katu: u32, @@ -41,49 +47,18 @@ impl PyScoreState { }; for (key, value) in kwargs { - match key.extract()? { - "max_combo" => { - this.max_combo = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'max_combo': must be an int"))? - } - "n_geki" => { - this.n_geki = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n_geki': must be an int"))? - } - "n_katu" => { - this.n_katu = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n_katu': must be an int"))? - } - "n300" => { - this.n300 = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n300': must be an int"))? - } - "n100" => { - this.n100 = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n100': must be an int"))? - } - "n50" => { - this.n50 = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'n50': must be an int"))? - } - "misses" => { - this.misses = value - .extract() - .map_err(|_| PyTypeError::new_err("kwarg 'misses': must be an int"))? - } - kwarg => { - let err = format!( - "unexpected kwarg '{kwarg}': expected 'max_combo', \n\ - 'n_geki', 'n_katu', 'n300', 'n100', 'n50' or 'misses'", - ); - - return Err(ArgsError::new_err(err)); + extract_args! { + this.key = value { + max_combo: int, + osu_large_tick_hits: int, + osu_small_tick_hits: int, + slider_end_hits: int, + n_geki: int, + n_katu: int, + n300: int, + n100: int, + n50: int, + misses: int, } } } @@ -100,6 +75,9 @@ impl Debug for PyScoreState { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { let Self { max_combo, + osu_large_tick_hits, + osu_small_tick_hits, + slider_end_hits, n_geki, n_katu, n300, @@ -110,6 +88,9 @@ impl Debug for PyScoreState { f.debug_struct("ScoreState") .field("max_combo", max_combo) + .field("osu_large_tick_hits", osu_large_tick_hits) + .field("osu_small_tick_hits", osu_small_tick_hits) + .field("slider_end_hits", slider_end_hits) .field("n_geki", n_geki) .field("n_katu", n_katu) .field("n300", n300) @@ -130,6 +111,9 @@ impl From<&PyScoreState> for ScoreState { fn from(state: &PyScoreState) -> Self { Self { max_combo: state.max_combo, + osu_large_tick_hits: state.osu_large_tick_hits, + osu_small_tick_hits: state.osu_small_tick_hits, + slider_end_hits: state.slider_end_hits, n_geki: state.n_geki, n_katu: state.n_katu, n300: state.n300, @@ -144,6 +128,9 @@ impl From for PyScoreState { fn from(state: ScoreState) -> Self { Self { max_combo: state.max_combo, + osu_large_tick_hits: state.osu_large_tick_hits, + osu_small_tick_hits: state.osu_small_tick_hits, + slider_end_hits: state.slider_end_hits, n_geki: state.n_geki, n_katu: state.n_katu, n300: state.n300, diff --git a/src/strains.rs b/src/strains.rs index 07ccdfd..09252af 100644 --- a/src/strains.rs +++ b/src/strains.rs @@ -20,6 +20,7 @@ define_class! { pub color: DoubleList?, pub rhythm: DoubleList?, pub stamina: DoubleList?, + pub single_color_stamina: DoubleList?, pub movement: DoubleList?, pub strains: DoubleList?, } @@ -52,6 +53,7 @@ impl From for PyStrains { color, rhythm, stamina, + single_color_stamina, } = strains; Self { @@ -60,6 +62,7 @@ impl From for PyStrains { color: Some(color), rhythm: Some(rhythm), stamina: Some(stamina), + single_color_stamina: Some(single_color_stamina), ..Self::default() } }