diff --git a/.github/workflows/build_test.yml b/.github/workflows/build_test.yml index 014ebb8..f21d898 100644 --- a/.github/workflows/build_test.yml +++ b/.github/workflows/build_test.yml @@ -15,10 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.83.0 with: - toolchain: 1.78.0 - components: rustfmt + components: rustfmt - name: Run rustfmt run: cargo fmt -- --check check: @@ -26,18 +25,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.83.0 id: cargo with: - toolchain: 1.78.0 - components: clippy - - uses: actions/cache@v2 + components: clippy + - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-rust-${{ steps.cargo.outputs.rustc_hash }}-${{ hashFiles('**/Cargo.lock') }}-lint + key: ${{ runner.os }}-rust-${{ steps.cargo.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }}-lint - name: Run cargo check run: cargo check env: @@ -50,19 +48,18 @@ jobs: name: Test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.83.0 id: cargo with: - toolchain: 1.78.0 - components: clippy - - uses: actions/cache@v2 + components: clippy + - uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git target - key: ${{ runner.os }}-rust-${{ steps.cargo.outputs.rustc_hash }}-${{ hashFiles('**/Cargo.lock') }}-debug-build + key: ${{ runner.os }}-rust-${{ steps.cargo.outputs.cachekey }}-${{ hashFiles('**/Cargo.lock') }}-debug-build - name: Run cargo test run: cargo test env: @@ -75,11 +72,6 @@ jobs: - uses: cachix/install-nix-action@v27 with: github_access_token: ${{ secrets.GITHUB_TOKEN }} - - uses: dtolnay/rust-toolchain@stable - id: cargo - with: - toolchain: 1.78.0 - components: clippy - uses: cachix/cachix-action@v15 with: name: natsukagami diff --git a/rust-toolchain b/rust-toolchain index b3a8c61..6b4de0a 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1 +1 @@ -1.79.0 +1.83.0 diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index a8fa3d3..3c8c225 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -149,7 +149,7 @@ mod scores { use youmubot_prelude::table_format::{table_formatting, Align}; use youmubot_prelude::*; - use crate::discord::oppai_cache::Accuracy; + use crate::discord::oppai_cache::Stats; use crate::discord::{Beatmap, BeatmapInfo, OsuEnv}; use crate::models::{Mode, Score}; @@ -211,9 +211,9 @@ mod scores { let beatmap = meta_cache.get_beatmap(play.beatmap_id, mode).await?; let info = { let b = oppai.get_beatmap(beatmap.beatmap_id).await?; - b.get_info_with(mode, &play.mods).ok() + b.get_info_with(mode, &play.mods) }; - Ok((beatmap, info)) as Result<(Beatmap, Option)> + Ok((beatmap, info)) as Result<(Beatmap, BeatmapInfo)> }) .collect::>() .map(|v| v.ok()) @@ -226,28 +226,18 @@ mod scores { Some(v) => Ok(v), None => { let b = oppai.get_beatmap(p.beatmap_id).await?; - let r: Result<_> = Ok({ - b.get_pp_from( - mode, - Some(p.max_combo as usize), - Accuracy::ByCount( - p.count_300, - p.count_100, - p.count_50, - p.count_miss, - ), - &p.mods, - ) - .ok() - .map(|pp| format!("{:.2}[?]", pp)) - } - .unwrap_or_else(|| "-".to_owned())); - r + let pp = b.get_pp_from( + mode, + Some(p.max_combo), + Stats::Raw(&p.statistics), + &p.mods, + ); + Ok(format!("{:.2}[?]", pp)) } } }) .collect::>() - .map(|v| v.unwrap_or_else(|_| "-".to_owned())) + .map(|v: Result<_>| v.unwrap_or_else(|_| "-".to_owned())) .collect::>(); let (beatmaps, pps) = future::join(beatmaps, pps).await; @@ -259,7 +249,7 @@ mod scores { match p.rank { crate::models::Rank::F => beatmaps[i] .as_ref() - .and_then(|(_, i)| i.map(|i| i.objects)) + .map(|(_, i)| i.object_count) .map(|total| { (p.count_300 + p.count_100 + p.count_50 + p.count_miss) as f64 / (total as f64) @@ -287,7 +277,7 @@ mod scores { b.map(|(beatmap, info)| { format!( "[{:.1}*] {} - {} [{}] ({})", - info.map(|i| i.stars).unwrap_or(beatmap.difficulty.stars), + info.attrs.stars(), beatmap.artist, beatmap.title, beatmap.difficulty_name, @@ -406,7 +396,7 @@ mod beatmapset { env.oppai .get_beatmap(b.beatmap_id) .await - .and_then(move |v| v.get_possible_pp_with(self.mode.unwrap_or(b.mode), &self.mods)) + .map(move |v| v.get_possible_pp_with(self.mode.unwrap_or(b.mode), &self.mods)) } } @@ -435,11 +425,10 @@ mod beatmapset { let map = &self.maps[page]; let info = match &self.infos[page] { - Some(info) => *info, + Some(info) => info, None => { let info = self.get_beatmap_info(ctx, map).await?; - self.infos[page] = Some(info); - info + self.infos[page].insert(info) } }; msg.edit(ctx, diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 0d64f95..e19e8c7 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -1,9 +1,11 @@ -use super::{BeatmapWithMode, UserExtras}; +use super::{oppai_cache::Stats, BeatmapWithMode, UserExtras}; use crate::{ - discord::oppai_cache::{Accuracy, BeatmapContent, BeatmapInfoWithPP}, + discord::oppai_cache::{BeatmapContent, BeatmapInfoWithPP}, models::{Beatmap, Difficulty, Mode, Mods, Rank, Score, User}, UserHeader, }; +use rosu_pp::osu::{OsuPerformanceAttributes, OsuScoreOrigin}; +use rosu_v2::prelude::GameModIntermode; use serenity::{ all::CreateAttachment, builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter}, @@ -80,7 +82,7 @@ pub fn beatmap_offline_embed( ) -> Result<(CreateEmbed, Vec)> { let bm = b.content.clone(); let metadata = b.metadata.clone(); - let (info, pp) = b.get_possible_pp_with(m, mods)?; + let (info, pp) = b.get_possible_pp_with(m, mods); let total_length = if !bm.hit_objects.is_empty() { Duration::from_millis( @@ -105,7 +107,7 @@ pub fn beatmap_offline_embed( }; let diff = Difficulty { - stars: info.stars, + stars: info.attrs.stars(), aim: None, // TODO: this is currently unused speed: None, // TODO: this is currently unused cs: bm.cs as f64, @@ -115,12 +117,12 @@ pub fn beatmap_offline_embed( count_normal: circles, count_slider: sliders, count_spinner: spinners, - max_combo: Some(info.max_combo as u64), + max_combo: Some(info.attrs.max_combo() as u64), bpm: bm.bpm(), drain_length: total_length, // It's hard to calculate so maybe just skip... total_length, } - .apply_mods(mods, info.stars); + .apply_mods(mods, info.attrs.stars()); let mut embed = CreateEmbed::new() .title(beatmap_title( &metadata.artist, @@ -179,8 +181,13 @@ fn beatmap_title( .build() } -pub fn beatmap_embed(b: &'_ Beatmap, m: Mode, mods: &Mods, info: BeatmapInfoWithPP) -> CreateEmbed { - let diff = b.difficulty.apply_mods(mods, info.0.stars); +pub fn beatmap_embed( + b: &'_ Beatmap, + m: Mode, + mods: &Mods, + info: &BeatmapInfoWithPP, +) -> CreateEmbed { + let diff = b.difficulty.apply_mods(mods, info.0.attrs.stars()); CreateEmbed::new() .title(beatmap_title(&b.artist, &b.title, &b.difficulty_name, mods)) .author( @@ -306,23 +313,18 @@ impl<'a> ScoreEmbedBuilder<'a> { let content = self.content; let u = &self.u; let accuracy = s.accuracy(mode); - let info = content.get_info_with(mode, &s.mods).ok(); - let stars = info - .as_ref() - .map(|info| info.stars) - .unwrap_or(b.difficulty.stars); + let info = content.get_info_with(mode, &s.mods); + let stars = info.attrs.stars(); let score_line = match s.rank { Rank::SS | Rank::SSH => "SS".to_string(), _ if s.perfect => format!("{:.2}% FC", accuracy), Rank::F => { - let display = info - .map(|info| { - ((s.count_300 + s.count_100 + s.count_50 + s.count_miss) as f64) - / (info.objects as f64) - * 100.0 - }) - .map(|p| format!("FAILED @ {:.2}%", p)) - .unwrap_or_else(|| "FAILED".to_owned()); + let display = { + let p = ((s.count_300 + s.count_100 + s.count_50 + s.count_miss) as f64) + / (info.object_count as f64) + * 100.0; + format!("FAILED @ {:.2}%", p) + }; format!("{:.2}% {} combo [{}]", accuracy, s.max_combo, display) } v => format!( @@ -330,34 +332,30 @@ impl<'a> ScoreEmbedBuilder<'a> { accuracy, s.max_combo, s.count_miss, v ), }; - let pp = s.pp.map(|pp| (pp, format!("{:.2}pp", pp))).or_else(|| { - content - .get_pp_from( - mode, - Some(s.max_combo as usize), - Accuracy::ByCount(s.count_300, s.count_100, s.count_50, s.count_miss), - &s.mods, - ) - .ok() - .map(|pp| (pp, format!("{:.2}pp [?]", pp))) - }); + let pp = + s.pp.map(|pp| (pp, format!("{:.2}pp", pp))) + .unwrap_or_else(|| { + let pp = content.get_pp_from( + mode, + Some(s.max_combo), + Stats::Raw(&s.statistics), + &s.mods, + ); + (pp, format!("{:.2}pp [?]", pp)) + }); let pp = if !s.perfect { - content - .get_pp_from( - mode, - None, - Accuracy::ByCount(s.count_300 + s.count_miss, s.count_100, s.count_50, 0), - &s.mods, - ) - .ok() - .filter(|&v| pp.as_ref().map(|&(origin, _)| origin < v).unwrap_or(false)) - .and_then(|value| { - pp.as_ref() - .map(|(_, original)| format!("{} ({:.2}pp if FC?)", original, value)) + let mut fc_stats = s.statistics.clone(); + fc_stats.great += fc_stats.miss; + fc_stats.miss = 0; + Some(content.get_pp_from(mode, None, Stats::Raw(&fc_stats), &s.mods)) + .filter(|&v| pp.0 < v) /* must be larger than real pp */ + .map(|value| { + let (_, original) = &pp; + format!("{} ({:.2}pp if FC?)", original, value) }) - .or_else(|| pp.map(|v| v.1)) + .unwrap_or(pp.1) } else { - pp.map(|v| v.1) + pp.1 }; let pp_gained = { let effective_pp = s.effective_pp.or_else(|| { @@ -373,9 +371,7 @@ impl<'a> ScoreEmbedBuilder<'a> { _ => None, } }; - let score_line = pp - .map(|pp| format!("{} | {}", &score_line, pp)) - .unwrap_or(score_line); + let score_line = format!("{} | {}", &score_line, pp); let max_combo = b .difficulty .max_combo @@ -438,7 +434,7 @@ impl<'a> ScoreEmbedBuilder<'a> { "Score stats", format!( "**{}** | {} | **{:.2}%**", - grouped_number(s.score.unwrap_or(s.normalized_score as u64)), + grouped_number(s.score), max_combo, accuracy ), @@ -468,78 +464,90 @@ pub(crate) struct FakeScore<'a> { pub bm: &'a BeatmapWithMode, pub content: &'a BeatmapContent, pub mods: Mods, - - pub count_300: usize, - pub count_100: usize, - pub count_50: usize, - pub count_miss: usize, - - pub count_slider_ends_missed: Option, // lazer only - - pub max_combo: Option, + pub n300: u32, + pub n100: u32, + pub n50: u32, + pub nmiss: u32, + pub max_combo: Option, } impl<'a> FakeScore<'a> { - fn is_ss(&self, map_max_combo: usize) -> bool { - self.is_fc(map_max_combo) - && self.count_100 - + self.count_50 - + self.count_miss - + self.count_slider_ends_missed.unwrap_or(0) - == 0 - } - fn is_fc(&self, map_max_combo: usize) -> bool { - match self.max_combo { - None => self.count_miss == 0, - Some(combo) => combo == map_max_combo - self.count_slider_ends_missed.unwrap_or(0), + fn score_origin(&self, attrs: &OsuPerformanceAttributes) -> OsuScoreOrigin { + if !self.mods.is_lazer { + OsuScoreOrigin::Stable + } else if self + .mods + .inner + .contains_intermode(GameModIntermode::Classic) + { + OsuScoreOrigin::WithoutSliderAcc { + max_large_ticks: attrs.difficulty.n_large_ticks, + max_small_ticks: attrs.difficulty.n_sliders, + } + } else { + OsuScoreOrigin::WithSliderAcc { + max_large_ticks: attrs.difficulty.n_large_ticks, + max_slider_ends: attrs.difficulty.n_sliders, + } } } - fn accuracy(&self) -> f64 { - 100.0 - * (self.count_300 as f64 * 300.0 - + self.count_100 as f64 * 100.0 - + self.count_50 as f64 * 50.0) - / ((self.count_300 + self.count_100 + self.count_50 + self.count_miss) as f64 * 300.0) + + fn is_ss(&self, map_max_combo: u32) -> bool { + self.is_fc(map_max_combo) && self.n100.max(self.n50).max(self.nmiss) == 0 } + + fn is_fc(&self, map_max_combo: u32) -> bool { + self.max_combo.is_none_or(|x| x == map_max_combo) && self.nmiss == 0 + } + + // fn accuracy(&self) -> f64 { + // self.state.accuracy(self.score_origin()) + // } + pub fn embed(self, ctx: &Context) -> Result { let BeatmapWithMode(b, mode) = self.bm; - let info = self.content.get_info_with(*mode, &self.mods)?; - let max_combo = self.max_combo.unwrap_or( - info.max_combo - self.count_miss - self.count_slider_ends_missed.unwrap_or(0), - ); - let acc = format!("{:.2}%", self.accuracy()); - let score_line: Cow = if self.is_ss(info.max_combo) { + let info = self.content.get_info_with(*mode, &self.mods); + let attrs = match &info.attrs { + rosu_pp::any::PerformanceAttributes::Osu(osu_performance_attributes) => { + osu_performance_attributes + } + _ => unreachable!(), + }; + let max_combo = self + .max_combo + .unwrap_or(info.attrs.max_combo() - self.nmiss); + let mut perf = attrs + .clone() + .performance() + .n300(self.n300) + .n100(self.n100) + .n50(self.n50) + .misses(self.nmiss) + .lazer(self.mods.is_lazer) + .mods(self.mods.inner.clone()); + let state = perf.generate_state()?; + let accuracy = state.accuracy(self.score_origin(attrs)) * 100.0; + let acc = format!("{:.2}%", accuracy); + let score_line: Cow = if self.is_ss(attrs.max_combo()) { "SS".into() - } else if self.is_fc(info.max_combo) { + } else if self.is_fc(attrs.max_combo()) { format!("{} FC", acc).into() } else { - format!("{} {}x {} miss", acc, max_combo, self.count_miss).into() + format!("{} {}x {} miss", acc, max_combo, self.nmiss).into() }; - let pp = self.content.get_pp_from( - *mode, - self.max_combo, - Accuracy::ByCount( - self.count_300 as u64, - self.count_100 as u64, - self.count_50 as u64, - self.count_miss as u64, - ), - &self.mods, - )?; - let pp_if_fc: Cow = if self.is_fc(info.max_combo) { + let pp = perf.calculate()?.pp; + let pp_if_fc: Cow = if self.is_fc(attrs.max_combo()) { "".into() } else { let pp = self.content.get_pp_from( *mode, None, - Accuracy::ByCount( - (self.count_300 + self.count_miss) as u64, - self.count_100 as u64, - self.count_50 as u64, - 0, - ), + Stats::AccOnly { + acc: accuracy, + misses: 0, + }, &self.mods, - )?; + ); format!(" ({:.2}pp if fc)", pp).into() }; @@ -554,7 +562,7 @@ impl<'a> FakeScore<'a> { MessageBuilder::new() .push_safe(&youmu.name) .push(" | ") - .push(b.full_title(&self.mods, info.stars)) + .push(b.full_title(&self.mods, attrs.stars())) .push(" | ") .push(score_line) .push(" | ") @@ -566,21 +574,26 @@ impl<'a> FakeScore<'a> { .description(format!("**pp gained**: **{:.2}**pp", pp)) .field( "Score stats", - format!("**{}** combo | **{}**", max_combo, acc), + format!( + "**{}**/{} combo | **{}**", + max_combo, + attrs.max_combo(), + acc + ), true, ) .field( "300s | 100s | 50s | misses", format!( "**{}** | **{}** | **{}** | **{}**", - self.count_300, self.count_100, self.count_50, self.count_miss + self.n300, self.n100, self.n50, self.nmiss ), true, ) .field( "Map stats", b.difficulty - .apply_mods(&self.mods, info.stars) + .apply_mods(&self.mods, attrs.stars()) .format_info(*mode, &self.mods, b), false, ) @@ -693,7 +706,7 @@ pub(crate) fn user_embed(u: User, ex: UserExtras) -> CreateEmbed { .push(format!( "> {}", map.difficulty - .apply_mods(&v.mods, info.stars) + .apply_mods(&v.mods, info.attrs.stars()) .format_info(mode, &v.mods, &map) .replace('\n', "\n> ") )) diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index 5473d2b..baf735b 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -127,7 +127,7 @@ pub fn dot_osu_hook<'a>( crate::discord::embeds::beatmap_offline_embed( &beatmap, m, /*For now*/ - &Mods::from_str(msg.content.trim(), m).unwrap_or_default(), + &Mods::from_str(msg.content.trim(), m, false).unwrap_or_default(), ) .pls_ok() } @@ -157,7 +157,8 @@ pub fn dot_osu_hook<'a>( crate::discord::embeds::beatmap_offline_embed( &beatmap, m, /*For now*/ - &Mods::from_str(msg.content.trim(), m).unwrap_or_default(), + &Mods::from_str(msg.content.trim(), m, false) + .unwrap_or_default(), ) .pls_ok() }) @@ -300,7 +301,7 @@ async fn handle_beatmap<'a, 'b>( .push_mono_safe(link) .build(), ) - .embed(beatmap_embed(beatmap, mode, &mods, info)) + .embed(beatmap_embed(beatmap, mode, &mods, &info)) .components(vec![beatmap_components(mode, reply_to.guild_id)]) .reference_message(reply_to), ) diff --git a/youmubot-osu/src/discord/interaction.rs b/youmubot-osu/src/discord/interaction.rs index ce087a3..fc2dffc 100644 --- a/youmubot-osu/src/discord/interaction.rs +++ b/youmubot-osu/src/discord/interaction.rs @@ -195,7 +195,7 @@ pub fn handle_simulate_button<'a>( let b = &bm.0; let mode = bm.1; let content = env.oppai.get_beatmap(b.beatmap_id).await?; - let info = content.get_info_with(mode, Mods::NOMOD)?; + let info = content.get_info_with(mode, Mods::NOMOD); assert!(mode == Mode::Std); @@ -214,7 +214,7 @@ pub fn handle_simulate_button<'a>( )) .timeout(Duration::from_secs(300)) .field(mk_input("Mods", "NM")) - .field(mk_input("Max Combo", info.max_combo.to_string())) + .field(mk_input("Max Combo", info.attrs.max_combo().to_string())) .field(mk_input("100s", "0")) .field(mk_input("50s", "0")) .field(mk_input("Misses", "0")), @@ -254,30 +254,27 @@ async fn handle_simluate_query( let mode = bm.1; let content = env.oppai.get_beatmap(b.beatmap_id).await?; - let score = { + let score: FakeScore = { let inputs = &query.inputs; - let (mods, max_combo, c100, c50, cmiss, csliderends) = ( - &inputs[0], &inputs[1], &inputs[2], &inputs[3], &inputs[4], "", - ); + let (mods, max_combo, c100, c50, cmiss) = + (&inputs[0], &inputs[1], &inputs[2], &inputs[3], &inputs[4]); let mods = UnparsedMods::from_str(mods) .map_err(|v| Error::msg(v))? .to_mods(mode)?; - let info = content.get_info_with(mode, &mods)?; - let max_combo = max_combo.parse::().ok(); - let c100 = c100.parse::().unwrap_or(0); - let c50 = c50.parse::().unwrap_or(0); - let cmiss = cmiss.parse::().unwrap_or(0); - let c300 = info.objects - c100 - c50 - cmiss; - let csliderends = csliderends.parse::().ok(); + let info = content.get_info_with(mode, &mods); + let max_combo = max_combo.parse::().ok(); + let n100 = c100.parse::().unwrap_or(0); + let n50 = c50.parse::().unwrap_or(0); + let nmiss = cmiss.parse::().unwrap_or(0); + let n300 = info.object_count as u32 - n100 - n50 - nmiss; FakeScore { bm: &bm, content: &content, mods, - count_300: c300, - count_100: c100, - count_50: c50, - count_miss: cmiss, - count_slider_ends_missed: csliderends, + n300, + n100, + n50, + nmiss, max_combo, } }; @@ -339,7 +336,7 @@ async fn handle_last_req( .oppai .get_beatmap(b.beatmap_id) .await? - .get_possible_pp_with(*m, &mods)?; + .get_possible_pp_with(*m, &mods); comp.create_followup( &ctx, serenity::all::CreateInteractionResponseFollowup::new() @@ -347,7 +344,7 @@ async fn handle_last_req( "Information for beatmap `{}`", bm.short_link(&mods) )) - .embed(beatmap_embed(b, *m, &mods, info)) + .embed(beatmap_embed(b, *m, &mods, &info)) .components(vec![beatmap_components(bm.1, comp.guild_id)]), ) .await?; diff --git a/youmubot-osu/src/discord/link_parser.rs b/youmubot-osu/src/discord/link_parser.rs index 2f7075c..60ff5f2 100644 --- a/youmubot-osu/src/discord/link_parser.rs +++ b/youmubot-osu/src/discord/link_parser.rs @@ -23,13 +23,13 @@ pub struct ToPrint<'a> { lazy_static! { // Beatmap(set) hooks static ref OLD_LINK_REGEX: Regex = Regex::new( - r"(?:https?://)?osu\.ppy\.sh/(?Ps|b|beatmaps)/(?P\d+)(?:[\&\?]m=(?P[0123]))?(?:\+(?P\S+\b))?" + r"(?:https?://)?osu\.ppy\.sh/(?Ps|b|beatmaps)/(?P\d+)(?:[\&\?]m=(?P[0123]))?(?:(?Pv2|[[:^alpha:]]\S+\b))?" ).unwrap(); static ref NEW_LINK_REGEX: Regex = Regex::new( - r"(?:https?://)?osu\.ppy\.sh/beatmapsets/(?P\d+)/?(?:\#(?Posu|taiko|fruits|mania)(?:/(?P\d+)|/?))?(?:\+(?P\S+\b))?" + r"(?:https?://)?osu\.ppy\.sh/beatmapsets/(?P\d+)/?(?:\#(?Posu|taiko|fruits|mania)(?:/(?P\d+)|/?))?(?:(?Pv2|[[:^alpha:]]\S+\b))?" ).unwrap(); static ref SHORT_LINK_REGEX: Regex = Regex::new( - r"(?:^|\s|\W)(?P
/b/(?P\d+)(?:/(?Posu|taiko|fruits|mania))?(?:\+(?P\S+\b))?)" + r"(?:^|\s|\W)(?P
/b/(?P\d+)(?:/(?Posu|taiko|fruits|mania))?(?:(?Pv2|[[:^alpha:]]\S+\b))?)" ).unwrap(); // Score hook @@ -149,7 +149,7 @@ impl EmbedType { env.oppai .get_beatmap(bm.beatmap_id) .await - .and_then(|b| b.get_possible_pp_with(mode, &mods))? + .map(|b| b.get_possible_pp_with(mode, &mods))? }; Ok(Self::Beatmap(Box::new(bm), info, mods)) } diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 8c9f77c..0dc3827 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -306,13 +306,13 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .oppai .get_beatmap(beatmap.beatmap_id) .await? - .get_possible_pp_with(mode, Mods::NOMOD)?; + .get_possible_pp_with(mode, Mods::NOMOD); let mut reply = reply.await?; reply .edit( &ctx, EditMessage::new() - .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, info)) + .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, &info)) .components(vec![beatmap_components(mode, msg.guild_id)]), ) .await?; @@ -492,7 +492,7 @@ impl UserExtras { .oppai .get_beatmap(s.beatmap_id) .await? - .get_info_with(mode, &s.mods)?; + .get_info_with(mode, &s.mods); Some((s, BeatmapWithMode(beatmap, mode), info)) } else { None @@ -828,13 +828,13 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .oppai .get_beatmap(bm.0.beatmap_id) .await? - .get_possible_pp_with(bm.1, &mods)?; + .get_possible_pp_with(bm.1, &mods); msg.channel_id .send_message( &ctx, CreateMessage::new() .content("Here is the beatmap you requested!") - .embed(beatmap_embed(&bm.0, bm.1, &mods, info)) + .embed(beatmap_embed(&bm.0, bm.1, &mods, &info)) .components(vec![beatmap_components(bm.1, msg.guild_id)]) .reference_message(msg), ) diff --git a/youmubot-osu/src/discord/oppai_cache.rs b/youmubot-osu/src/discord/oppai_cache.rs index 640a704..e8d1954 100644 --- a/youmubot-osu/src/discord/oppai_cache.rs +++ b/youmubot-osu/src/discord/oppai_cache.rs @@ -3,8 +3,12 @@ use std::io::Read; use std::sync::Arc; use rosu_map::Beatmap as BeatmapMetadata; -use rosu_pp::{Beatmap, GameMods}; +use rosu_pp::{ + any::{PerformanceAttributes, ScoreState}, + Beatmap, +}; +use rosu_v2::prelude::ScoreStatistics; use youmubot_db_sql::{models::osu as models, Pool}; use youmubot_prelude::*; @@ -28,100 +32,125 @@ pub struct BeatmapBackground { } /// the output of "one" oppai run. -#[derive(Clone, Copy, Debug)] +#[derive(Debug, Clone)] pub struct BeatmapInfo { - pub objects: usize, - pub max_combo: usize, - pub stars: f64, + pub attrs: PerformanceAttributes, + pub object_count: usize, } -#[derive(Clone, Copy, Debug)] -pub enum Accuracy { - ByCount(u64, u64, u64, u64), - // 300 / 100 / 50 / misses - #[allow(dead_code)] - ByValue(f64, u64), -} - -impl From for f64 { - fn from(val: Accuracy) -> Self { - match val { - Accuracy::ByValue(v, _) => v, - Accuracy::ByCount(n300, n100, n50, nmiss) => { - 100.0 * ((6 * n300 + 2 * n100 + n50) as f64) - / ((6 * (n300 + n100 + n50 + nmiss)) as f64) - } - } - } -} - -impl Accuracy { - pub fn misses(&self) -> usize { - (match self { - Accuracy::ByCount(_, _, _, nmiss) => *nmiss, - Accuracy::ByValue(_, nmiss) => *nmiss, - }) as usize - } +/// Stats to be consumed by [BeatmapContent::get_pp_from]. +pub enum Stats<'a> { + Raw(&'a ScoreStatistics), + AccOnly { acc: f64, misses: u32 }, } /// Beatmap Info with attached 95/98/99/100% FC pp. pub type BeatmapInfoWithPP = (BeatmapInfo, [f64; 4]); -fn apply_mods( - perf: rosu_pp::Performance<'_>, - mods: impl Into, -) -> rosu_pp::Performance<'_> { - use rosu_pp::Performance::*; - match perf { - Osu(o) => Osu(o.mods(mods)), - Taiko(t) => Taiko(t.mods(mods)), - Catch(f) => Catch(f.mods(mods)), - Mania(m) => Mania(m.mods(mods)), - } -} - impl BeatmapContent { /// Get pp given the combo and accuracy. pub fn get_pp_from( &self, mode: Mode, - combo: Option, - accuracy: Accuracy, + combo: Option, + stats: Stats<'_>, mods: &Mods, - ) -> Result { + ) -> f64 { let perf = self .content .performance() .mode_or_ignore(mode.into()) - .accuracy(accuracy.into()) - .misses(accuracy.misses() as u32); - let mut perf = apply_mods(perf, mods.inner.clone()); - if let Some(combo) = combo { - perf = perf.combo(combo as u32); - } + .lazer(mods.is_lazer) + .mods(mods.inner.clone()); + let perf = match stats { + Stats::Raw(stats) => { + let max_combo = + combo.unwrap_or_else(|| self.get_info_with(mode, mods).attrs.max_combo()); + perf.state(Self::stats_to_state(stats, mode, max_combo)) + } + Stats::AccOnly { acc, misses } => if let Some(combo) = combo { + perf.combo(combo) + } else { + perf + } + .accuracy(acc) + .misses(misses), + }; let attrs = perf.calculate(); - Ok(attrs.pp()) + attrs.pp() + } + + fn stats_to_state(stats: &ScoreStatistics, mode: Mode, max_combo: u32) -> ScoreState { + let legacy = stats.as_legacy(mode.into()); + ScoreState { + max_combo, + osu_large_tick_hits: stats.large_tick_hit, + osu_small_tick_hits: stats.small_tick_hit, + slider_end_hits: stats.slider_tail_hit, + n_geki: stats.perfect, + n_katu: stats.good, + n300: legacy.count_300, + n100: legacy.count_100, + n50: legacy.count_50, + misses: legacy.count_miss, + } } /// Get info given mods. - pub fn get_info_with(&self, mode: Mode, mods: &Mods) -> Result { - let perf = self.content.performance().mode_or_ignore(mode.into()); - let attrs = apply_mods(perf, mods.inner.clone()).calculate(); - Ok(BeatmapInfo { - objects: self.content.hit_objects.len(), - max_combo: attrs.max_combo() as usize, - stars: attrs.stars(), - }) + pub fn get_info_with(&self, mode: Mode, mods: &Mods) -> BeatmapInfo { + let attrs = self + .content + .performance() + .mode_or_ignore(mode.into()) + .mods(mods.inner.clone()) + .calculate(); + let object_count = self.content.hit_objects.len(); + BeatmapInfo { + attrs, + object_count, + } } - pub fn get_possible_pp_with(&self, mode: Mode, mods: &Mods) -> Result { + pub fn get_possible_pp_with(&self, mode: Mode, mods: &Mods) -> BeatmapInfoWithPP { let pp: [f64; 4] = [ - self.get_pp_from(mode, None, Accuracy::ByValue(95.0, 0), mods)?, - self.get_pp_from(mode, None, Accuracy::ByValue(98.0, 0), mods)?, - self.get_pp_from(mode, None, Accuracy::ByValue(99.0, 0), mods)?, - self.get_pp_from(mode, None, Accuracy::ByValue(100.0, 0), mods)?, + self.get_pp_from( + mode, + None, + Stats::AccOnly { + acc: 95.0, + misses: 0, + }, + mods, + ), + self.get_pp_from( + mode, + None, + Stats::AccOnly { + acc: 98.0, + misses: 0, + }, + mods, + ), + self.get_pp_from( + mode, + None, + Stats::AccOnly { + acc: 99.0, + misses: 0, + }, + mods, + ), + self.get_pp_from( + mode, + None, + Stats::AccOnly { + acc: 100.0, + misses: 0, + }, + mods, + ), ]; - Ok((self.get_info_with(mode, mods)?, pp)) + (self.get_info_with(mode, mods), pp) } } diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 850906e..6fbe576 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -24,7 +24,7 @@ use youmubot_prelude::{ }; use crate::{ - discord::{db::OsuUser, display::ScoreListStyle, oppai_cache::Accuracy, BeatmapWithMode}, + discord::{db::OsuUser, display::ScoreListStyle, oppai_cache::Stats, BeatmapWithMode}, models::{Mode, Mods}, request::UserID, Score, @@ -454,29 +454,24 @@ pub async fn get_leaderboard( let mem = Arc::new(mem); scores .into_iter() - .filter_map(|score| { - let pp = score.pp.map(|v| (true, v)).or_else(|| { - oppai_map - .get_pp_from( + .map(|score| { + let pp = score.pp.map(|v| (true, v)).unwrap_or_else(|| { + ( + false, + oppai_map.get_pp_from( *mode, - Some(score.max_combo as usize), - Accuracy::ByCount( - score.count_300, - score.count_100, - score.count_50, - score.count_miss, - ), + Some(score.max_combo), + Stats::Raw(&score.statistics), &score.mods, - ) - .ok() - .map(|v| (false, v)) - })?; - Some(Ranking { + ), + ) + }); + Ranking { pp: pp.1, official: pp.0, member: mem.clone(), score, - }) + } }) .collect::>() }) @@ -520,7 +515,7 @@ pub async fn display_rankings_table( bm: &BeatmapWithMode, order: OrderBy, ) -> Result<()> { - let has_lazer_score = scores.iter().any(|v| v.score.score.is_none()); + let has_lazer_score = scores.iter().any(|v| v.score.mods.is_lazer); const ITEMS_PER_PAGE: usize = 5; let total_len = scores.len(); @@ -565,7 +560,7 @@ pub async fn display_rankings_table( crate::discord::embeds::grouped_number(if has_lazer_score { score.normalized_score as u64 } else { - score.score.unwrap() + score.score }) } }, diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 16e560d..aa6ab5c 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; use mods::Stats; -use rosu_v2::prelude::GameModIntermode; +use rosu_v2::prelude::{GameModIntermode, ScoreStatistics}; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::fmt; @@ -636,7 +636,7 @@ pub struct Score { pub replay_available: bool, pub beatmap_id: u64, - pub score: Option, + pub score: u64, pub normalized_score: u32, pub pp: Option, pub rank: Rank, @@ -649,9 +649,12 @@ pub struct Score { pub count_miss: u64, pub count_katu: u64, pub count_geki: u64, - pub max_combo: u64, + pub max_combo: u32, pub perfect: bool, + // lazer stats + pub statistics: ScoreStatistics, + /// Whether score would get pp pub ranked: Option, /// Whether score would be stored diff --git a/youmubot-osu/src/models/mods.rs b/youmubot-osu/src/models/mods.rs index 65f4001..8643914 100644 --- a/youmubot-osu/src/models/mods.rs +++ b/youmubot-osu/src/models/mods.rs @@ -16,13 +16,14 @@ lazy_static::lazy_static! { // Beatmap(set) hooks static ref MODS: Regex = Regex::new( // r"(?:https?://)?osu\.ppy\.sh/(?Ps|b|beatmaps)/(?P\d+)(?:[\&\?]m=(?P[0123]))?(?:\+(?P[A-Z]+))?" - r"^((\+?)(?P([A-Za-z0-9][A-Za-z])+))?(@(?P\d(\.\d+)?)x)?(?P(@(ar|AR|od|OD|cs|CS|hp|HP)\d(\.\d)?)+)?(v2)?$" + r"^((\+?)(?P([A-Za-z0-9][A-Za-z])+))?(@(?P\d(\.\d+)?)x)?(?P(@(ar|AR|od|OD|cs|CS|hp|HP)\d(\.\d)?)+)?(?Pv2)?$" ).unwrap(); } #[derive(Debug, Clone, PartialEq, Default)] pub struct UnparsedMods { mods: Cow<'static, str>, + is_lazer: bool, clock: Option, } @@ -43,8 +44,10 @@ impl FromStr for UnparsedMods { return Err(format!("invalid mod sequence: {}", mods)); } } + let is_lazer = ms.name("lazer").is_some(); Ok(Self { mods: mods.map(|v| v.into()).unwrap_or("".into()), + is_lazer, clock: ms .name("clock") .map(|v| v.as_str().parse::<_>().unwrap()) @@ -57,7 +60,7 @@ impl UnparsedMods { /// Convert to [Mods]. pub fn to_mods(&self, mode: Mode) -> Result { use rosu_v2::prelude::*; - let mut mods = Mods::from_str(&self.mods, mode)?; + let mut mods = Mods::from_str(&self.mods, mode, self.is_lazer)?; if let Some(clock) = self.clock { let has_night_day_core = mods.inner.contains_intermode(GameModIntermode::Nightcore) || mods.inner.contains_intermode(GameModIntermode::Daycore); @@ -71,6 +74,9 @@ impl UnparsedMods { let adjust_pitch: Option = None; if clock < 1.0 { speed_change = speed_change.filter(|v| *v != 0.75); + if speed_change.is_some() { + mods.is_lazer = true; + } mods.inner.insert(if has_night_day_core { match mode { Mode::Std => GameMod::DaycoreOsu(DaycoreOsu { speed_change }), @@ -101,6 +107,9 @@ impl UnparsedMods { } if clock > 1.0 { speed_change = speed_change.filter(|v| *v != 1.5); + if speed_change.is_some() { + mods.is_lazer = true; + } mods.inner.insert(if has_night_day_core { match mode { Mode::Std => GameMod::NightcoreOsu(NightcoreOsu { speed_change }), @@ -137,6 +146,7 @@ impl UnparsedMods { #[derive(Debug, Clone, PartialEq, Default)] pub struct Mods { pub inner: GameMods, + pub is_lazer: bool, } /// Store overrides to the stats. @@ -161,6 +171,7 @@ impl Stats { impl Mods { pub const NOMOD: &'static Mods = &Mods { inner: GameMods::new(), + is_lazer: false, }; // fn classic_mod_of(mode: Mode) -> rosu::GameMod { @@ -209,11 +220,11 @@ impl Mods { } } -impl From for Mods { - fn from(inner: GameMods) -> Self { - Self { inner } - } -} +// impl From for Mods { +// fn from(inner: GameMods) -> Self { +// Self { inner } +// } +// } // bitflags::bitflags! { // /// The mods available to osu! @@ -405,7 +416,18 @@ impl Mods { } impl Mods { - pub fn from_str(mut s: &str, mode: Mode) -> Result { + pub fn from_gamemods(mods: GameMods, is_lazer: bool) -> Self { + let is_lazer = is_lazer || { + let mut mm = mods.clone(); + mm.remove_intermode(GameModIntermode::Classic); + mm.try_as_legacy().is_none() + }; + Self { + inner: mods, + is_lazer, + } + } + pub fn from_str(mut s: &str, mode: Mode, is_lazer: bool) -> Result { // Strip leading + if s.starts_with('+') { s = &s[1..]; @@ -420,7 +442,7 @@ impl Mods { if !inner.is_valid() { return Err(error!("Incompatible mods found: {}", inner)); } - Ok(Self { inner }) + Ok(Self::from_gamemods(inner, is_lazer)) // let mut res = GameModsIntermode::default(); // while s.len() >= 2 { // let (m, nw) = s.split_at(2); @@ -463,8 +485,7 @@ impl Mods { impl fmt::Display for Mods { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let is_lazer = !self.inner.contains_intermode(GameModIntermode::Classic); - let mods = if !is_lazer { + let mods = if !self.is_lazer { let mut v = self.inner.clone(); v.remove_intermode(GameModIntermode::Classic); Cow::Owned(v) @@ -479,7 +500,7 @@ impl fmt::Display for Mods { write!(f, "@{:.2}x", clock)?; } } - if is_lazer { + if self.is_lazer { write!(f, "{}", LAZER_TEXT)?; } Ok(()) diff --git a/youmubot-osu/src/models/rosu.rs b/youmubot-osu/src/models/rosu.rs index 9d3ca68..866e523 100644 --- a/youmubot-osu/src/models/rosu.rs +++ b/youmubot-osu/src/models/rosu.rs @@ -112,13 +112,18 @@ impl From for UserEvent { impl From for Score { fn from(s: rosu::score::Score) -> Self { let legacy_stats = s.statistics.as_legacy(s.mode); + let score = if s.set_on_lazer { + s.score + } else { + s.classic_score + } as u64; Self { id: Some(s.id), user_id: s.user_id as u64, date: time_to_utc(s.ended_at), replay_available: s.replay, beatmap_id: s.map_id as u64, - score: Some(s.legacy_score as u64).filter(|v| *v > 0), + score, normalized_score: s.score, pp: s.pp.map(|v| v as f64), rank: if s.passed { s.grade.into() } else { Rank::F }, @@ -126,14 +131,15 @@ impl From for Score { global_rank: s.rank_global, effective_pp: s.weight.map(|w| w.pp as f64), mode: s.mode.into(), - mods: s.mods.into(), + mods: Mods::from_gamemods(s.mods, s.set_on_lazer), count_300: legacy_stats.count_300 as u64, count_100: legacy_stats.count_100 as u64, count_50: legacy_stats.count_50 as u64, count_miss: legacy_stats.count_miss as u64, count_katu: legacy_stats.count_katu as u64, count_geki: legacy_stats.count_geki as u64, - max_combo: s.max_combo as u64, + statistics: s.statistics, + max_combo: s.max_combo, perfect: s.is_perfect_combo, ranked: s.ranked, preserved: s.preserve, diff --git a/youmubot-osu/src/request.rs b/youmubot-osu/src/request.rs index 7ef76f0..20b0313 100644 --- a/youmubot-osu/src/request.rs +++ b/youmubot-osu/src/request.rs @@ -210,7 +210,9 @@ pub mod builders { match self.mods { Some(mods) => r.await.map(|mut ss| { // let mods = GameModsIntermode::from(mods.inner); - ss.retain(|s| Mods::from(s.mods.clone()).contains(&mods)); + ss.retain(|s| { + Mods::from_gamemods(s.mods.clone(), s.set_on_lazer).contains(&mods) + }); ss }), None => r.await,