From 9aab6e11ec588ae9ba3c94aaa8dc9ad474275aa6 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Fri, 12 Jun 2020 01:03:56 -0400 Subject: [PATCH 01/14] Implement BeatmapCache and Oppai interface --- Cargo.lock | 53 +++++++++++++++ youmubot-osu/Cargo.toml | 2 + youmubot-osu/src/discord/mod.rs | 4 +- youmubot-osu/src/discord/oppai_cache.rs | 90 +++++++++++++++++++++++++ 4 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 youmubot-osu/src/discord/oppai_cache.rs diff --git a/Cargo.lock b/Cargo.lock index b540b26..740b93b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,14 @@ name = "adler32" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "ahash" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "const-random 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "aho-corasick" version = "0.7.10" @@ -162,6 +170,24 @@ dependencies = [ "syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "const-random" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "const-random-macro 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro-hack 0.5.15 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "const-random-macro" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "getrandom 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)", + "proc-macro-hack 0.5.15 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "core-foundation" version = "0.7.0" @@ -244,6 +270,16 @@ dependencies = [ "sct 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "dashmap" +version = "3.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "ahash 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)", + "num_cpus 1.13.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "digest" version = "0.8.1" @@ -796,6 +832,16 @@ dependencies = [ "vcpkg 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "oppai-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", + "cc 1.0.54 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.70 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "parking_lot" version = "0.9.0" @@ -1710,7 +1756,9 @@ version = "0.1.0" dependencies = [ "bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)", + "dashmap 3.11.4 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "oppai-rs 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", "rayon 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "regex 1.3.7 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1737,6 +1785,7 @@ dependencies = [ "checksum Inflector 0.11.4 (registry+https://github.com/rust-lang/crates.io-index)" = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" "checksum addr2line 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a49806b9dadc843c61e7c97e72490ad7f7220ae249012fbda9ad0609457c0543" "checksum adler32 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2" +"checksum ahash 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "e8fd72866655d1904d6b0997d0b07ba561047d070fbe29de039031c641b61217" "checksum aho-corasick 0.7.10 (registry+https://github.com/rust-lang/crates.io-index)" = "8716408b8bc624ed7f65d223ddb9ac2d044c0547b6fa4b0d554f3a9540496ada" "checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" "checksum backtrace 0.3.48 (registry+https://github.com/rust-lang/crates.io-index)" = "0df2f85c8a2abbe3b7d7e748052fdd9b76a0458fdeb16ad4223f5eca78c7c130" @@ -1756,6 +1805,8 @@ dependencies = [ "checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" "checksum codeforces 0.1.0 (git+https://github.com/natsukagami/rust-codeforces-api)" = "" "checksum command_attr 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c27d6155f93d880b6379d93ddc9b2417b3b69b715360c5f25525e4576338a381" +"checksum const-random 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "2f1af9ac737b2dd2d577701e59fd09ba34822f6f2ebdb30a7647405d9e55e16a" +"checksum const-random-macro 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "25e4c606eb459dd29f7c57b2e0879f2b6f14ee130918c2b78ccb58a9624e6c7a" "checksum core-foundation 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" "checksum core-foundation-sys 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" "checksum crc32fast 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ba125de2af0df55319f41944744ad91c71113bf74a4646efff39afe1f6842db1" @@ -1765,6 +1816,7 @@ dependencies = [ "checksum crossbeam-queue 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c695eeca1e7173472a32221542ae469b3e9aac3a4fc81f7696bcad82029493db" "checksum crossbeam-utils 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8" "checksum ct-logs 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4d3686f5fa27dbc1d76c751300376e167c5a43387f44bb451fd1c24776e49113" +"checksum dashmap 3.11.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8cfcd41ae02d60edded204341d2798ba519c336c51a37330aa4b98a1128def32" "checksum digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" "checksum dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)" = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" "checksum dtoa 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)" = "4358a9e11b9a09cf52383b451b49a169e8d797b68aa02301ff586d70d9661ea3" @@ -1830,6 +1882,7 @@ dependencies = [ "checksum openssl 0.10.29 (registry+https://github.com/rust-lang/crates.io-index)" = "cee6d85f4cb4c4f59a6a85d5b68a233d280c82e29e822913b9c8b129fbf20bdd" "checksum openssl-probe 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" "checksum openssl-sys 0.9.56 (registry+https://github.com/rust-lang/crates.io-index)" = "f02309a7f127000ed50594f0b50ecc69e7c654e16d41b4e8156d1b3df8e0b52e" +"checksum oppai-rs 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4f143357550da5c04800333509df440fcbe5254120a5af05097a083caf23105b" "checksum parking_lot 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252" "checksum parking_lot_core 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b876b1b9e7ac6e1a74a6da34d25c42e17e8862aa409cbbbdcfc8d86c6f3bc62b" "checksum percent-encoding 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" diff --git a/youmubot-osu/Cargo.toml b/youmubot-osu/Cargo.toml index d851508..f92a0e8 100644 --- a/youmubot-osu/Cargo.toml +++ b/youmubot-osu/Cargo.toml @@ -15,6 +15,8 @@ bitflags = "1" rayon = "1.1" lazy_static = "1" regex = "1" +oppai-rs = "0.2.0" +dashmap = "3.11.4" youmubot-db = { path = "../youmubot-db" } youmubot-prelude = { path = "../youmubot-prelude" } diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 418171a..22f5a58 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -20,6 +20,7 @@ mod cache; mod db; pub(crate) mod embeds; mod hook; +mod oppai_cache; mod server_rank; use db::OsuUser; @@ -58,9 +59,10 @@ pub fn setup( // API client let http_client = data.get_cloned::(); data.insert::(OsuHttpClient::new( - http_client, + http_client.clone(), std::env::var("OSU_API_KEY").expect("Please set OSU_API_KEY as osu! api key."), )); + data.insert::(oppai_cache::BeatmapCache::new(http_client)); // Announcer announcers.add(announcer::ANNOUNCER_KEY, announcer::updates); diff --git a/youmubot-osu/src/discord/oppai_cache.rs b/youmubot-osu/src/discord/oppai_cache.rs new file mode 100644 index 0000000..11b3a2e --- /dev/null +++ b/youmubot-osu/src/discord/oppai_cache.rs @@ -0,0 +1,90 @@ +use serenity::framework::standard::CommandError; +use std::{ffi::CString, sync::Arc}; +use youmubot_prelude::TypeMapKey; + +/// the information collected from a download/Oppai request. +#[derive(Clone, Debug)] +pub struct BeatmapContent { + id: u64, + content: Arc, +} + +/// the output of "one" oppai run. +#[derive(Clone, Copy, Debug)] +pub struct BeatmapInfo { + stars: f32, + pp: [f32; 4], // 95, 98, 99, 100 +} + +impl BeatmapContent { + /// Get pp given the combo and accuracy. + pub fn get_pp_from( + &self, + combo: oppai_rs::Combo, + accuracy: f32, + mods: impl Into, + ) -> Result { + Ok(oppai_rs::Oppai::new_from_content(&self.content[..])? + .combo(combo)? + .accuracy(accuracy)? + .mods(mods.into()) + .pp()) + } + + /// Get info given mods. + pub fn get_info_with( + &self, + mods: impl Into, + ) -> Result { + let mut oppai = oppai_rs::Oppai::new_from_content(&self.content[..])?; + oppai.mods(mods.into()).combo(oppai_rs::Combo::PERFECT)?; + let pp = [ + oppai.accuracy(95.0)?.pp(), + oppai.accuracy(98.0)?.pp(), + oppai.accuracy(99.0)?.pp(), + oppai.accuracy(100.0)?.pp(), + ]; + let stars = oppai.stars(); + Ok(BeatmapInfo { stars, pp }) + } +} + +/// A central cache for the beatmaps. +pub struct BeatmapCache { + client: reqwest::blocking::Client, + cache: Arc>, +} + +impl BeatmapCache { + /// Create a new cache. + pub fn new(client: reqwest::blocking::Client) -> Self { + BeatmapCache { + client, + cache: Arc::new(dashmap::DashMap::new()), + } + } + + fn download_beatmap(&self, id: u64) -> Result { + let content = self + .client + .get(&format!("https://osu.ppy.sh/u/{}", id)) + .send()? + .bytes()?; + Ok(BeatmapContent { + id, + content: Arc::new(CString::new(content.into_iter().collect::>())?), + }) + } + + /// Get a beatmap from the cache. + pub fn get_beatmap(&self, id: u64) -> Result { + self.cache + .entry(id) + .or_try_insert_with(|| self.download_beatmap(id)) + .map(|v| v.clone()) + } +} + +impl TypeMapKey for BeatmapCache { + type Value = BeatmapCache; +} From f8cbd7ceb05c9920ec83b27dd707df06a07eccca Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Fri, 12 Jun 2020 01:06:24 -0400 Subject: [PATCH 02/14] Implement conversion between mods --- youmubot-osu/src/models/mods.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/youmubot-osu/src/models/mods.rs b/youmubot-osu/src/models/mods.rs index 48dcc69..5a49984 100644 --- a/youmubot-osu/src/models/mods.rs +++ b/youmubot-osu/src/models/mods.rs @@ -128,3 +128,9 @@ impl fmt::Display for Mods { Ok(()) } } + +impl From for oppai_rs::Mods { + fn from(m: Mods) -> Self { + oppai_rs::Mods::from_bits_truncate(m.bits() as i32) + } +} From c43f9067b8d6b08c26fbc9a327ca010bae0a4856 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Fri, 12 Jun 2020 02:44:56 -0400 Subject: [PATCH 03/14] Hooks and last works --- youmubot-osu/src/discord/embeds.rs | 59 ++++++++++++++++++++++--- youmubot-osu/src/discord/hook.rs | 57 +++++++++++++++++------- youmubot-osu/src/discord/mod.rs | 19 +++++--- youmubot-osu/src/discord/oppai_cache.rs | 23 ++++++---- youmubot-osu/src/models/mod.rs | 11 +++++ 5 files changed, 135 insertions(+), 34 deletions(-) diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 94f1a36..aec5282 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -1,5 +1,8 @@ use super::BeatmapWithMode; -use crate::models::{Beatmap, Mode, Rank, Score, User}; +use crate::{ + discord::oppai_cache::BeatmapInfo, + models::{Beatmap, Mode, Mods, Rank, Score, User}, +}; use chrono::Utc; use serenity::{builder::CreateEmbed, utils::MessageBuilder}; use youmubot_prelude::*; @@ -12,7 +15,32 @@ fn format_mode(actual: Mode, original: Mode) -> String { } } -pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a mut CreateEmbed { +pub fn beatmap_embed<'a>( + b: &'_ Beatmap, + m: Mode, + mods: Mods, + info: Option, + c: &'a mut CreateEmbed, +) -> &'a mut CreateEmbed { + let mod_str = if mods == Mods::NOMOD { + "".to_owned() + } else { + format!(" {}", mods) + }; + let total_length = if mods.intersects(Mods::DT | Mods::NC) { + b.total_length * 2 / 3 + } else if mods.intersects(Mods::HT) { + b.total_length * 4 / 3 + } else { + b.total_length + }; + let drain_length = if mods.intersects(Mods::DT | Mods::NC) { + b.drain_length * 2 / 3 + } else if mods.intersects(Mods::HT) { + b.drain_length * 4 / 3 + } else { + b.drain_length + }; c.title( MessageBuilder::new() .push_bold_safe(&b.artist) @@ -21,6 +49,7 @@ pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a .push(" [") .push_bold_safe(&b.difficulty_name) .push("]") + .push(&mod_str) .build(), ) .author(|a| { @@ -34,15 +63,29 @@ pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a .color(0xffb6c1) .field( "Star Difficulty", - format!("{:.2}⭐", b.difficulty.stars), + format!( + "{:.2}⭐", + info.map(|v| v.stars as f64).unwrap_or(b.difficulty.stars) + ), false, ) + .fields(info.map(|info| { + ( + "Calculated pp", + format!( + "95%: **{:.2}**pp, 98%: **{:.2}**pp, 99%: **{:.2}**pp, 100%: **{:.2}**pp", + info.pp[0], info.pp[1], info.pp[2], info.pp[3] + ), + false, + ) + })) + .fields(Some(("Mods", mods, false)).filter(|_| mods != Mods::NOMOD)) .field( "Length", MessageBuilder::new() - .push_bold_safe(Duration(b.total_length)) + .push_bold_safe(Duration(total_length)) .push(" (") - .push_bold_safe(Duration(b.drain_length)) + .push_bold_safe(Duration(drain_length)) .push(" drain)") .build(), false, @@ -90,6 +133,12 @@ pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a .push_bold(&b.genre) .build(), ) + .footer(|f| { + if info.is_none() && mods != Mods::NOMOD { + f.text("Star difficulty not reflecting mods applied."); + } + f + }) } const MAX_DIFFS: usize = 25 - 4; diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index 62c2bcf..51c9ecc 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -1,6 +1,7 @@ use super::OsuClient; use crate::{ - models::{Beatmap, Mode}, + discord::oppai_cache::{BeatmapCache, BeatmapInfo}, + models::{Beatmap, Mode, Mods}, request::BeatmapRequestKind, }; use lazy_static::lazy_static; @@ -11,6 +12,7 @@ use serenity::{ model::channel::Message, utils::MessageBuilder, }; +use std::str::FromStr; use youmubot_prelude::*; use super::embeds::{beatmap_embed, beatmapset_embed}; @@ -34,13 +36,13 @@ pub fn hook(ctx: &mut Context, msg: &Message) -> () { let mut last_beatmap = None; for l in old_links.into_iter().chain(new_links.into_iter()) { if let Err(v) = msg.channel_id.send_message(&ctx, |m| match l.embed { - EmbedType::Beatmap(b) => { - let t = handle_beatmap(&b, l.link, l.mode, l.mods, m); + EmbedType::Beatmap(b, info, mods) => { + let t = handle_beatmap(&b, info, l.link, l.mode, mods, m); let mode = l.mode.unwrap_or(b.mode); last_beatmap = Some(super::BeatmapWithMode(b, mode)); t } - EmbedType::Beatmapset(b) => handle_beatmapset(b, l.link, l.mode, l.mods, m), + EmbedType::Beatmapset(b) => handle_beatmapset(b, l.link, l.mode, m), }) { println!("Error in osu! hook: {:?}", v) } @@ -59,7 +61,7 @@ pub fn hook(ctx: &mut Context, msg: &Message) -> () { } enum EmbedType { - Beatmap(Beatmap), + Beatmap(Beatmap, Option, Mods), Beatmapset(Vec), } @@ -67,12 +69,12 @@ struct ToPrint<'a> { embed: EmbedType, link: &'a str, mode: Option, - mods: Option<&'a str>, } fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result>, Error> { let osu = ctx.data.get_cloned::(); let mut to_prints: Vec> = Vec::new(); + let cache = ctx.data.get_cloned::(); for capture in OLD_LINK_REGEX.captures_iter(content) { let req_type = capture.name("link_type").unwrap().as_str(); let req = match req_type { @@ -100,11 +102,22 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result { for b in beatmaps.into_iter() { + // collect beatmap info + let mods = capture + .name("mods") + .map(|v| Mods::from_str(v.as_str()).ok()) + .flatten() + .unwrap_or(Mods::NOMOD); + let info = mode.unwrap_or(b.mode).to_oppai_mode().and_then(|mode| { + cache + .get_beatmap(b.beatmap_id) + .and_then(|b| b.get_info_with(Some(mode), mods)) + .ok() + }); to_prints.push(ToPrint { - embed: EmbedType::Beatmap(b), + embed: EmbedType::Beatmap(b, info, mods), link: capture.get(0).unwrap().as_str(), mode, - mods: capture.name("mods").map(|v| v.as_str()), }) } } @@ -112,7 +125,6 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result (), } @@ -123,6 +135,7 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result(ctx: &mut Context, content: &'a str) -> Result>, Error> { let osu = ctx.data.get_cloned::(); let mut to_prints: Vec> = Vec::new(); + let cache = ctx.data.get_cloned::(); for capture in NEW_LINK_REGEX.captures_iter(content) { let mode = capture.name("mode").and_then(|v| { Some(match v.as_str() { @@ -133,7 +146,6 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result return None, }) }); - let mods = capture.name("mods").map(|v| v.as_str()); let link = capture.get(0).unwrap().as_str(); let req = match capture.name("beatmap_id") { Some(ref v) => BeatmapRequestKind::Beatmap(v.as_str().parse()?), @@ -148,10 +160,24 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result { for beatmap in beatmaps.into_iter() { + // collect beatmap info + let mods = capture + .name("mods") + .map(|v| Mods::from_str(v.as_str()).ok()) + .flatten() + .unwrap_or(Mods::NOMOD); + let info = mode + .unwrap_or(beatmap.mode) + .to_oppai_mode() + .and_then(|mode| { + cache + .get_beatmap(beatmap.beatmap_id) + .and_then(|b| b.get_info_with(Some(mode), mods)) + .ok() + }); to_prints.push(ToPrint { - embed: EmbedType::Beatmap(beatmap), + embed: EmbedType::Beatmap(beatmap, info, mods), link, - mods, mode, }) } @@ -159,7 +185,6 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result to_prints.push(ToPrint { embed: EmbedType::Beatmapset(beatmaps), link, - mods, mode, }), } @@ -169,9 +194,10 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result( beatmap: &Beatmap, + info: Option, link: &'_ str, mode: Option, - mods: Option<&'_ str>, + mods: Mods, m: &'a mut CreateMessage<'b>, ) -> &'a mut CreateMessage<'b> { m.content( @@ -180,14 +206,13 @@ fn handle_beatmap<'a, 'b>( .push_mono_safe(link) .build(), ) - .embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), b)) + .embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), mods, info, b)) } fn handle_beatmapset<'a, 'b>( beatmaps: Vec, link: &'_ str, mode: Option, - mods: Option<&'_ str>, m: &'a mut CreateMessage<'b>, ) -> &'a mut CreateMessage<'b> { let mut beatmaps = beatmaps; diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 22f5a58..93bea84 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -1,5 +1,6 @@ use crate::{ - models::{Beatmap, Mode, Score, User}, + discord::oppai_cache::BeatmapCache, + models::{Beatmap, Mode, Mods, Score, User}, request::{BeatmapRequestKind, UserID}, Client as OsuHttpClient, }; @@ -20,7 +21,7 @@ mod cache; mod db; pub(crate) mod embeds; mod hook; -mod oppai_cache; +pub(crate) mod oppai_cache; mod server_rank; use db::OsuUser; @@ -334,18 +335,26 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult #[command] #[description = "Show information from the last queried beatmap."] -#[num_args(0)] -pub fn last(ctx: &mut Context, msg: &Message, _: Args) -> CommandResult { +#[usage = "[mods = no mod]"] +#[max_args(1)] +pub fn last(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { let b = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?; match b { Some(BeatmapWithMode(b, m)) => { + let mods = args.find::().unwrap_or(Mods::NOMOD); + let info = ctx + .data + .get_cloned::() + .get_beatmap(b.beatmap_id)? + .get_info_with(m.to_oppai_mode(), mods) + .ok(); msg.channel_id.send_message(&ctx, |f| { f.content(format!( "{}: here is the beatmap you requested!", msg.author )) - .embed(|c| beatmap_embed(&b, m, c)) + .embed(|c| beatmap_embed(&b, m, mods, info, c)) })?; } None => { diff --git a/youmubot-osu/src/discord/oppai_cache.rs b/youmubot-osu/src/discord/oppai_cache.rs index 11b3a2e..60cf6b3 100644 --- a/youmubot-osu/src/discord/oppai_cache.rs +++ b/youmubot-osu/src/discord/oppai_cache.rs @@ -12,8 +12,8 @@ pub struct BeatmapContent { /// the output of "one" oppai run. #[derive(Clone, Copy, Debug)] pub struct BeatmapInfo { - stars: f32, - pp: [f32; 4], // 95, 98, 99, 100 + pub stars: f32, + pub pp: [f32; 4], // 95, 98, 99, 100 } impl BeatmapContent { @@ -22,21 +22,27 @@ impl BeatmapContent { &self, combo: oppai_rs::Combo, accuracy: f32, + mode: Option, mods: impl Into, ) -> Result { - Ok(oppai_rs::Oppai::new_from_content(&self.content[..])? - .combo(combo)? - .accuracy(accuracy)? - .mods(mods.into()) - .pp()) + let mut oppai = oppai_rs::Oppai::new_from_content(&self.content[..])?; + oppai.combo(combo)?.accuracy(accuracy)?.mods(mods.into()); + if let Some(mode) = mode { + oppai.mode(mode)?; + } + Ok(oppai.pp()) } /// Get info given mods. pub fn get_info_with( &self, + mode: Option, mods: impl Into, ) -> Result { let mut oppai = oppai_rs::Oppai::new_from_content(&self.content[..])?; + if let Some(mode) = mode { + oppai.mode(mode)?; + } oppai.mods(mods.into()).combo(oppai_rs::Combo::PERFECT)?; let pp = [ oppai.accuracy(95.0)?.pp(), @@ -50,6 +56,7 @@ impl BeatmapContent { } /// A central cache for the beatmaps. +#[derive(Clone, Debug)] pub struct BeatmapCache { client: reqwest::blocking::Client, cache: Arc>, @@ -67,7 +74,7 @@ impl BeatmapCache { fn download_beatmap(&self, id: u64) -> Result { let content = self .client - .get(&format!("https://osu.ppy.sh/u/{}", id)) + .get(&format!("https://osu.ppy.sh/osu/{}", id)) .send()? .bytes()?; Ok(BeatmapContent { diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 57697bf..75eaad6 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -118,6 +118,17 @@ impl fmt::Display for Mode { } } +impl Mode { + /// Convert to oppai mode. + pub fn to_oppai_mode(self) -> Option { + Some(match self { + Mode::Std => oppai_rs::Mode::Std, + Mode::Taiko => oppai_rs::Mode::Taiko, + _ => return None, + }) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Beatmap { // Beatmapset info From 20571d35de46704ac5159fd65d423763f146235a Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sat, 13 Jun 2020 22:19:05 -0400 Subject: [PATCH 04/14] Show actual stars for oppai-enabled maps --- youmubot-osu/src/discord/mod.rs | 139 ++++++++++++++++++++++---------- 1 file changed, 97 insertions(+), 42 deletions(-) diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 93bea84..f15fe6b 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -225,6 +225,7 @@ impl FromStr for Nth { fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> CommandResult { let watcher = ctx.data.get_cloned::(); let osu = ctx.data.get_cloned::(); + let beatmap_cache = ctx.data.get_cloned::(); if plays.is_empty() { m.reply(&ctx, "No plays found")?; @@ -235,53 +236,107 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command const ITEMS_PER_PAGE: usize = 5; let total_pages = (plays.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; - watcher.paginate_fn(ctx, m.channel_id, |page, e| { - let page = page as usize; - let start = page * ITEMS_PER_PAGE; - let end = plays.len().min(start + ITEMS_PER_PAGE); - if start >= end { - return (e, Err(Error::from("No more pages"))); - } + watcher.paginate_fn( + ctx, + m.channel_id, + |page, e| { + let page = page as usize; + let start = page * ITEMS_PER_PAGE; + let end = plays.len().min(start + ITEMS_PER_PAGE); + if start >= end { + return (e, Err(Error::from("No more pages"))); + } - let plays = &plays[start..end]; - let beatmaps = { - let b = &mut beatmaps[start..end]; - b.par_iter_mut().enumerate().map( - |(i, v)| v.get_or_insert_with( - || osu.beatmaps(BeatmapRequestKind::Beatmap(plays[i].beatmap_id), |f| f) - .ok() - .and_then(|v| v.into_iter().next()) - .map(|b| format!( - "[{:.1}*] {} - {} [{}] (#{})", - b.difficulty.stars, b.artist, b.title, b.difficulty_name, b.beatmap_id)) - .unwrap_or("FETCH FAILED".to_owned()))).collect::>() - }; - let /*mods width*/ mw = plays.iter().map(|v| v.mods.to_string().len()).max().unwrap().max(4); - let /*beatmap names*/ bw = beatmaps.iter().map(|v| v.len()).max().unwrap().max(7); + let plays = &plays[start..end]; + let beatmaps = { + let b = &mut beatmaps[start..end]; + b.par_iter_mut() + .enumerate() + .map(|(i, v)| { + v.get_or_insert_with(|| { + if let Some(b) = osu + .beatmaps(BeatmapRequestKind::Beatmap(plays[i].beatmap_id), |f| f) + .ok() + .and_then(|v| v.into_iter().next()) + { + let stars = beatmap_cache + .get_beatmap(b.beatmap_id) + .ok() + .and_then(|b| { + mode.to_oppai_mode().and_then(|mode| { + b.get_info_with(Some(mode), plays[i].mods).ok() + }) + }) + .map(|info| info.stars as f64) + .unwrap_or(b.difficulty.stars); + format!( + "[{:.1}*] {} - {} [{}] (#{})", + stars, b.artist, b.title, b.difficulty_name, b.beatmap_id + ) + } else { + "FETCH_FAILED".to_owned() + } + }) + }) + .collect::>() + }; + /*mods width*/ + let mw = plays + .iter() + .map(|v| v.mods.to_string().len()) + .max() + .unwrap() + .max(4); + /*beatmap names*/ + let bw = beatmaps.iter().map(|v| v.len()).max().unwrap().max(7); - let mut m = MessageBuilder::new(); - // Table header - m.push_line(format!(" # | pp | accuracy | rank | {:mw$} | {:bw$}", "mods", "beatmap", mw = mw, bw = bw)); - m.push_line(format!("---------------------------------{:-3} | {:>6} | {:>8} | {:^4} | {:mw$} | {:bw$}", id + start + 1, - play.pp.map(|v| format!("{:.2}", v)).unwrap_or("-".to_owned()), + play.pp + .map(|v| format!("{:.2}", v)) + .unwrap_or("-".to_owned()), format!("{:.2}%", play.accuracy(mode)), - play.rank.to_string(), play.mods.to_string(), beatmap, mw = mw, bw = bw)); - } - // End - let table = m.build().replace("```", "\\`\\`\\`"); - let mut m = MessageBuilder::new(); - m - .push_codeblock(table, None) - .push_line(format!("Page **{}/{}**", page + 1, total_pages)) - .push_line("Note: star difficulty don't reflect mods applied."); - (e.content(m.build()), Ok(())) - }, std::time::Duration::from_secs(60)) + play.rank.to_string(), + play.mods.to_string(), + beatmap, + mw = mw, + bw = bw + )); + } + // End + let table = m.build().replace("```", "\\`\\`\\`"); + let mut m = MessageBuilder::new(); + m.push_codeblock(table, None).push_line(format!( + "Page **{}/{}**", + page + 1, + total_pages + )); + if let None = mode.to_oppai_mode() { + m.push_line("Note: star difficulty don't reflect mods applied."); + } + (e.content(m.build()), Ok(())) + }, + std::time::Duration::from_secs(60), + ) } #[command] From 00971d7d3e97f58d23eb9144a8dd396b27448ffe Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sat, 13 Jun 2020 23:29:13 -0400 Subject: [PATCH 05/14] Implement beatmap metadata caching --- youmubot-osu/src/discord/announcer.rs | 23 ++++++-- youmubot-osu/src/discord/beatmap_cache.rs | 70 +++++++++++++++++++++++ youmubot-osu/src/discord/mod.rs | 27 +++++---- youmubot-osu/src/lib.rs | 2 +- youmubot-osu/src/models/mod.rs | 2 +- youmubot-osu/src/models/raw.rs | 2 +- 6 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 youmubot-osu/src/discord/beatmap_cache.rs diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index d20bf4c..b69a5f2 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -1,8 +1,9 @@ use super::db::{OsuSavedUsers, OsuUser}; use super::{embeds::score_embed, BeatmapWithMode, OsuClient}; use crate::{ + discord::beatmap_cache::BeatmapMetaCache, models::{Mode, Score}, - request::{BeatmapRequestKind, UserID}, + request::UserID, Client as Osu, }; use announcer::MemberToChannels; @@ -22,6 +23,7 @@ pub const ANNOUNCER_KEY: &'static str = "osu"; /// Announce osu! top scores. pub fn updates(c: Arc, d: AppData, channels: MemberToChannels) -> CommandResult { let osu = d.get_cloned::(); + let cache = d.get_cloned::(); // For each user... let mut data = OsuSavedUsers::open(&*d.read()).borrow()?.clone(); for (user_id, osu_user) in data.iter_mut() { @@ -31,7 +33,17 @@ pub fn updates(c: Arc, d: AppData, channels: MemberToChannels) -> } osu_user.pp = match (&[Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania]) .par_iter() - .map(|m| handle_user_mode(c.clone(), &osu, &osu_user, *user_id, &channels[..], *m)) + .map(|m| { + handle_user_mode( + c.clone(), + &osu, + &cache, + &osu_user, + *user_id, + &channels[..], + *m, + ) + }) .collect::>() { Ok(v) => v, @@ -51,6 +63,7 @@ pub fn updates(c: Arc, d: AppData, channels: MemberToChannels) -> fn handle_user_mode( c: Arc, osu: &Osu, + cache: &BeatmapMetaCache, osu_user: &OsuUser, user_id: UserId, channels: &[ChannelId], @@ -63,9 +76,9 @@ fn handle_user_mode( scores .into_par_iter() .filter_map(|(rank, score)| { - let beatmap = osu - .beatmaps(BeatmapRequestKind::Beatmap(score.beatmap_id), |f| f) - .map(|v| BeatmapWithMode(v.into_iter().next().unwrap(), mode)); + let beatmap = cache + .get_beatmap_default(score.beatmap_id) + .map(|v| BeatmapWithMode(v, mode)); match beatmap { Ok(v) => Some((rank, score, v)), Err(e) => { diff --git a/youmubot-osu/src/discord/beatmap_cache.rs b/youmubot-osu/src/discord/beatmap_cache.rs new file mode 100644 index 0000000..be3efbb --- /dev/null +++ b/youmubot-osu/src/discord/beatmap_cache.rs @@ -0,0 +1,70 @@ +use crate::{ + models::{ApprovalStatus, Beatmap, Mode}, + Client, +}; +use dashmap::DashMap; +use serenity::framework::standard::CommandError; +use std::sync::Arc; +use youmubot_prelude::TypeMapKey; + +/// BeatmapMetaCache intercepts beatmap-by-id requests and caches them for later recalling. +/// Does not cache non-Ranked beatmaps. +#[derive(Clone, Debug)] +pub struct BeatmapMetaCache { + client: Client, + cache: Arc>, +} + +impl TypeMapKey for BeatmapMetaCache { + type Value = BeatmapMetaCache; +} + +impl BeatmapMetaCache { + /// Create a new beatmap cache. + pub fn new(client: Client) -> Self { + BeatmapMetaCache { + client, + cache: Arc::new(DashMap::new()), + } + } + fn insert_if_possible(&self, id: u64, mode: Option) -> Result { + let beatmap = self + .client + .beatmaps(crate::BeatmapRequestKind::Beatmap(id), |f| { + if let Some(mode) = mode { + f.mode(mode, true); + } + f + }) + .and_then(|v| { + v.into_iter() + .next() + .ok_or(CommandError::from("beatmap not found")) + })?; + if let ApprovalStatus::Ranked(_) = beatmap.approval { + self.cache.insert((id, beatmap.mode), beatmap.clone()); + }; + Ok(beatmap) + } + /// Get the given beatmap + pub fn get_beatmap(&self, id: u64, mode: Mode) -> Result { + self.cache + .get(&(id, mode)) + .map(|b| Ok(b.clone())) + .unwrap_or_else(|| self.insert_if_possible(id, Some(mode))) + } + + /// Get a beatmap without a mode... + pub fn get_beatmap_default(&self, id: u64) -> Result { + (&[Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania]) + .iter() + .filter_map(|&mode| { + self.cache + .get(&(id, mode)) + .filter(|b| b.mode == mode) + .map(|b| Ok(b.clone())) + }) + .next() + .unwrap_or_else(|| self.insert_if_possible(id, None)) + } +} diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index f15fe6b..2db9317 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -1,4 +1,5 @@ use crate::{ + discord::beatmap_cache::BeatmapMetaCache, discord::oppai_cache::BeatmapCache, models::{Beatmap, Mode, Mods, Score, User}, request::{BeatmapRequestKind, UserID}, @@ -17,6 +18,7 @@ use std::str::FromStr; use youmubot_prelude::*; mod announcer; +pub(crate) mod beatmap_cache; mod cache; mod db; pub(crate) mod embeds; @@ -59,11 +61,15 @@ pub fn setup( // API client let http_client = data.get_cloned::(); - data.insert::(OsuHttpClient::new( + let osu_client = OsuHttpClient::new( http_client.clone(), std::env::var("OSU_API_KEY").expect("Please set OSU_API_KEY as osu! api key."), - )); + ); + data.insert::(osu_client.clone()); data.insert::(oppai_cache::BeatmapCache::new(http_client)); + data.insert::(beatmap_cache::BeatmapMetaCache::new( + osu_client, + )); // Announcer announcers.add(announcer::ANNOUNCER_KEY, announcer::updates); @@ -224,7 +230,7 @@ impl FromStr for Nth { fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> CommandResult { let watcher = ctx.data.get_cloned::(); - let osu = ctx.data.get_cloned::(); + let osu = ctx.data.get_cloned::(); let beatmap_cache = ctx.data.get_cloned::(); if plays.is_empty() { @@ -254,11 +260,7 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command .enumerate() .map(|(i, v)| { v.get_or_insert_with(|| { - if let Some(b) = osu - .beatmaps(BeatmapRequestKind::Beatmap(plays[i].beatmap_id), |f| f) - .ok() - .and_then(|v| v.into_iter().next()) - { + if let Some(b) = osu.get_beatmap(plays[i].beatmap_id, mode).ok() { let stars = beatmap_cache .get_beatmap(b.beatmap_id) .ok() @@ -350,6 +352,7 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); + let meta_cache = ctx.data.get_cloned::(); let user = osu .user(user, |f| f.mode(mode))? .ok_or(Error::from("User not found"))?; @@ -360,12 +363,8 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult .into_iter() .last() .ok_or(Error::from("No such play"))?; - let beatmap = osu - .beatmaps(BeatmapRequestKind::Beatmap(recent_play.beatmap_id), |f| { - f.mode(mode, true) - })? - .into_iter() - .next() + let beatmap = meta_cache + .get_beatmap(recent_play.beatmap_id, mode) .map(|v| BeatmapWithMode(v, mode)) .unwrap(); diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index 0d453fa..b93dbf6 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -14,7 +14,7 @@ use std::{convert::TryInto, sync::Arc}; /// Client is the client that will perform calls to the osu! api server. /// It's cheap to clone, so do it. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct Client { key: Arc, client: HTTPClient, diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 75eaad6..6476bf6 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -94,7 +94,7 @@ impl fmt::Display for Language { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, std::hash::Hash)] pub enum Mode { Std, Taiko, diff --git a/youmubot-osu/src/models/raw.rs b/youmubot-osu/src/models/raw.rs index 641518f..38edcaf 100644 --- a/youmubot-osu/src/models/raw.rs +++ b/youmubot-osu/src/models/raw.rs @@ -1,6 +1,6 @@ use serde::Deserialize; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Clone, Debug)] pub(crate) struct Beatmap { pub approved: String, pub submit_date: String, From 9f54ca3a1aa375cd01dc5c96075817fe6336bde0 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sat, 13 Jun 2020 23:44:04 -0400 Subject: [PATCH 06/14] Align the ranks table --- youmubot-osu/src/discord/server_rank.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index b7aa618..62f7d2a 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -50,17 +50,19 @@ pub fn server_rank(ctx: &mut Context, m: &Message, mut args: Args) -> CommandRes m.channel_id, move |page: u8, e: &mut EditMessage| { let start = (page as usize) * ITEMS_PER_PAGE; - if start >= users.len() { + let end = (start + ITEMS_PER_PAGE).min(users.len()); + if start >= end { return (e, Err(Error("No more items".to_owned()))); } let total_len = users.len(); - let users = users.iter().skip(start).take(ITEMS_PER_PAGE); + let users = &users[start..end]; + let username_len = users.iter().map(|(_, u)| u.len()).max().unwrap_or(8).max(8); let mut content = MessageBuilder::new(); content .push_line("```") .push_line("Rank | pp | Username") - .push_line("-------------------------"); - for (id, (pp, member)) in users.enumerate() { + .push_line(format!("-----------------{:-4} | {:>7.2} | ", From cbb1e14d93a6a0ec65126a2955430449d569a841 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 00:08:00 -0400 Subject: [PATCH 07/14] Implement predicted pp --- youmubot-osu/src/discord/mod.rs | 44 +++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 2db9317..1977e0e 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -282,6 +282,31 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command }) .collect::>() }; + let pp = plays + .iter() + .map(|p| { + p.pp.map(|pp| format!("{:.2}pp", pp)) + .or_else(|| { + beatmap_cache.get_beatmap(p.beatmap_id).ok().and_then(|b| { + mode.to_oppai_mode().and_then(|op| { + b.get_pp_from( + oppai_rs::Combo::NonFC { + max_combo: p.max_combo as u32, + misses: p.count_miss as u32, + }, + p.accuracy(mode) as f32, + Some(op), + p.mods, + ) + .ok() + .map(|pp| format!("{:.2}pp [?]", pp)) + }) + }) + }) + .unwrap_or("-".to_owned()) + }) + .collect::>(); + let pw = pp.iter().map(|v| v.len()).max().unwrap_or(2); /*mods width*/ let mw = plays .iter() @@ -295,31 +320,34 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command let mut m = MessageBuilder::new(); // Table header m.push_line(format!( - " # | pp | accuracy | rank | {:mw$} | {:bw$}", + " # | {:pw$} | accuracy | rank | {:mw$} | {:bw$}", + "pp", "mods", "beatmap", + pw = pw, mw = mw, bw = bw )); m.push_line(format!( - "---------------------------------{:-3} | {:>6} | {:>8} | {:^4} | {:mw$} | {:bw$}", + "{:>3} | {:>pw$} | {:>8} | {:^4} | {:mw$} | {:bw$}", id + start + 1, - play.pp - .map(|v| format!("{:.2}", v)) - .unwrap_or("-".to_owned()), + pp[id], format!("{:.2}%", play.accuracy(mode)), play.rank.to_string(), play.mods.to_string(), beatmap, + pw = pw, mw = mw, bw = bw )); @@ -333,7 +361,9 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command total_pages )); if let None = mode.to_oppai_mode() { - m.push_line("Note: star difficulty don't reflect mods applied."); + m.push_line("Note: star difficulty doesn't reflect mods applied."); + } else { + m.push_line("[?] means pp was predicted by oppai-rs."); } (e.content(m.build()), Ok(())) }, From 33f19dbaba0f0e96c9569e5e9da36aab758d7f6b Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 18:44:06 -0400 Subject: [PATCH 08/14] Handle cs/od/ar/hp with mods --- youmubot-osu/src/discord/embeds.rs | 50 +++++++------------- youmubot-osu/src/models/mod.rs | 73 +++++++++++++++++++++++++++++- youmubot-osu/src/models/parse.rs | 4 +- 3 files changed, 90 insertions(+), 37 deletions(-) diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index aec5282..9498f48 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -27,20 +27,7 @@ pub fn beatmap_embed<'a>( } else { format!(" {}", mods) }; - let total_length = if mods.intersects(Mods::DT | Mods::NC) { - b.total_length * 2 / 3 - } else if mods.intersects(Mods::HT) { - b.total_length * 4 / 3 - } else { - b.total_length - }; - let drain_length = if mods.intersects(Mods::DT | Mods::NC) { - b.drain_length * 2 / 3 - } else if mods.intersects(Mods::HT) { - b.drain_length * 4 / 3 - } else { - b.drain_length - }; + let diff = b.difficulty.apply_mods(mods); c.title( MessageBuilder::new() .push_bold_safe(&b.artist) @@ -67,8 +54,9 @@ pub fn beatmap_embed<'a>( "{:.2}⭐", info.map(|v| v.stars as f64).unwrap_or(b.difficulty.stars) ), - false, + true, ) + .fields(Some(("Mods", mods, true)).filter(|_| mods != Mods::NOMOD)) .fields(info.map(|info| { ( "Calculated pp", @@ -79,25 +67,20 @@ pub fn beatmap_embed<'a>( false, ) })) - .fields(Some(("Mods", mods, false)).filter(|_| mods != Mods::NOMOD)) .field( "Length", MessageBuilder::new() - .push_bold_safe(Duration(total_length)) + .push_bold_safe(Duration(diff.total_length)) .push(" (") - .push_bold_safe(Duration(drain_length)) + .push_bold_safe(Duration(diff.drain_length)) .push(" drain)") .build(), false, ) - .field("Circle Size", format!("{:.1}", b.difficulty.cs), true) - .field("Approach Rate", format!("{:.1}", b.difficulty.ar), true) - .field( - "Overall Difficulty", - format!("{:.1}", b.difficulty.od), - true, - ) - .field("HP Drain", format!("{:.1}", b.difficulty.hp), true) + .field("Circle Size", format!("{:.1}", diff.cs), true) + .field("Approach Rate", format!("{:.1}", diff.ar), true) + .field("Overall Difficulty", format!("{:.1}", diff.od), true) + .field("HP Drain", format!("{:.1}", diff.hp), true) .field("BPM", b.bpm.round(), true) .fields(b.difficulty.max_combo.map(|v| ("Max combo", v, true))) .field("Mode", format_mode(m, b.mode), true) @@ -194,7 +177,7 @@ pub fn beatmapset_embed<'a>( .field( "Length", MessageBuilder::new() - .push_bold_safe(Duration(b.total_length)) + .push_bold_safe(Duration(b.difficulty.total_length)) .build(), true, ) @@ -240,7 +223,7 @@ pub fn beatmapset_embed<'a>( .push(", HP") .push_bold(format!("{:.1}", b.difficulty.hp)) .push(", ⌛ ") - .push_bold(format!("{}", Duration(b.drain_length))) + .push_bold(format!("{}", Duration(b.difficulty.drain_length))) .build(), false, ) @@ -272,6 +255,7 @@ pub(crate) fn score_embed<'a>( let top_record = top_record .map(|v| format!("| #{} top record!", v)) .unwrap_or("".to_owned()); + let diff = b.difficulty.apply_mods(s.mods); m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url())) .color(0xffb6c1) .title(format!( @@ -313,15 +297,15 @@ pub(crate) fn score_embed<'a>( }, ) .push("CS") - .push_bold(format!("{:.1}", b.difficulty.cs)) + .push_bold(format!("{:.1}", diff.cs)) .push(", AR") - .push_bold(format!("{:.1}", b.difficulty.ar)) + .push_bold(format!("{:.1}", diff.ar)) .push(", OD") - .push_bold(format!("{:.1}", b.difficulty.od)) + .push_bold(format!("{:.1}", diff.od)) .push(", HP") - .push_bold(format!("{:.1}", b.difficulty.hp)) + .push_bold(format!("{:.1}", diff.hp)) .push(", ⌛ ") - .push_bold(format!("{}", Duration(b.drain_length))) + .push_bold(format!("{}", Duration(diff.drain_length))) .build(), false, ) diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 6476bf6..06c883e 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -45,6 +45,77 @@ pub struct Difficulty { pub count_slider: u64, pub count_spinner: u64, pub max_combo: Option, + + pub drain_length: Duration, + pub total_length: Duration, +} + +impl Difficulty { + // Difficulty calculation is based on + // https://www.reddit.com/r/osugame/comments/6phntt/difficulty_settings_table_with_all_values/ + + fn apply_everything_by_ratio(&mut self, rat: f64) { + self.cs = (self.cs * rat).min(10.0); + self.od = (self.od * rat).min(10.0); + self.ar = (self.ar * rat).min(10.0); + self.hp = (self.hp * rat).min(10.0); + } + fn apply_ar_by_time_ratio(&mut self, rat: f64) { + // Convert AR to approach time... + let approach_time = if self.ar < 5.0 { + 1800.0 - self.ar * 120.0 + } else { + 1200.0 - (self.ar - 5.0) * 150.0 + }; + // Update it... + let approach_time = approach_time * rat; + // Convert it back to AR... + self.ar = if approach_time > 1200.0 { + (1800.0 - approach_time) / 120.0 + } else { + (1200.0 - approach_time) / 150.0 + 5.0 + }; + } + fn apply_od_by_time_ratio(&mut self, rat: f64) { + // Convert OD to hit timing + let hit_timing = 79.0 - self.od * 6.0 + 0.5; + // Update it... + let hit_timing = hit_timing * rat + 0.5 / rat; + // then convert back + self.od = (79.0 - (hit_timing - 0.5)) / 6.0; + } + fn apply_length_by_ratio(&mut self, mul: u32, div: u32) { + self.drain_length = self.drain_length * mul / div; + self.total_length = self.total_length * mul / div; + } + /// Apply mods to the given difficulty. + /// Note that `stars`, `aim` and `speed` cannot be calculated from this alone. + pub fn apply_mods(&self, mods: Mods) -> Difficulty { + let mut diff = self.clone(); + + // Apply mods one by one + if mods.contains(Mods::EZ) { + diff.apply_everything_by_ratio(0.5); + } + if mods.contains(Mods::HT) { + diff.apply_ar_by_time_ratio(4.0 / 3.0); + diff.apply_od_by_time_ratio(4.0 / 3.0); + diff.apply_length_by_ratio(4, 3); + } + if mods.contains(Mods::HR) { + let old_cs = diff.cs; + diff.apply_everything_by_ratio(1.4); + // CS is changed by 1.3 tho + diff.cs = old_cs * 1.3; + } + if mods.contains(Mods::DT) { + diff.apply_ar_by_time_ratio(2.0 / 3.0); + diff.apply_od_by_time_ratio(2.0 / 3.0); + diff.apply_length_by_ratio(2, 3); + } + + diff + } } #[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)] @@ -152,8 +223,6 @@ pub struct Beatmap { pub beatmap_id: u64, pub difficulty_name: String, pub difficulty: Difficulty, - pub drain_length: Duration, - pub total_length: Duration, pub file_hash: String, pub mode: Mode, pub favourite_count: u64, diff --git a/youmubot-osu/src/models/parse.rs b/youmubot-osu/src/models/parse.rs index 0d17431..fcb0532 100644 --- a/youmubot-osu/src/models/parse.rs +++ b/youmubot-osu/src/models/parse.rs @@ -139,9 +139,9 @@ impl TryFrom for Beatmap { count_slider: parse_from_str(&raw.count_slider)?, count_spinner: parse_from_str(&raw.count_spinner)?, max_combo: raw.max_combo.map(parse_from_str).transpose()?, + drain_length: parse_duration(&raw.hit_length)?, + total_length: parse_duration(&raw.total_length)?, }, - drain_length: parse_duration(&raw.hit_length)?, - total_length: parse_duration(&raw.total_length)?, file_hash: raw.file_md5, mode: parse_mode(&raw.mode)?, favourite_count: parse_from_str(&raw.favourite_count)?, From eba1bb324c3beca3d573939542ec14de6a031424 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 19:51:26 -0400 Subject: [PATCH 09/14] Handle score_embed --- youmubot-osu/src/discord/announcer.rs | 24 ++++++++--------- youmubot-osu/src/discord/embeds.rs | 38 ++++++++++++++++++++++----- youmubot-osu/src/discord/mod.rs | 19 +++++++++----- 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index b69a5f2..2949e02 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -2,6 +2,7 @@ use super::db::{OsuSavedUsers, OsuUser}; use super::{embeds::score_embed, BeatmapWithMode, OsuClient}; use crate::{ discord::beatmap_cache::BeatmapMetaCache, + discord::oppai_cache::BeatmapCache, models::{Mode, Score}, request::UserID, Client as Osu, @@ -24,6 +25,7 @@ pub const ANNOUNCER_KEY: &'static str = "osu"; pub fn updates(c: Arc, d: AppData, channels: MemberToChannels) -> CommandResult { let osu = d.get_cloned::(); let cache = d.get_cloned::(); + let oppai = d.get_cloned::(); // For each user... let mut data = OsuSavedUsers::open(&*d.read()).borrow()?.clone(); for (user_id, osu_user) in data.iter_mut() { @@ -38,6 +40,7 @@ pub fn updates(c: Arc, d: AppData, channels: MemberToChannels) -> c.clone(), &osu, &cache, + &oppai, &osu_user, *user_id, &channels[..], @@ -64,6 +67,7 @@ fn handle_user_mode( c: Arc, osu: &Osu, cache: &BeatmapMetaCache, + oppai: &BeatmapCache, osu_user: &OsuUser, user_id: UserId, channels: &[ChannelId], @@ -75,23 +79,17 @@ fn handle_user_mode( .ok_or(Error::from("user not found"))?; scores .into_par_iter() - .filter_map(|(rank, score)| { - let beatmap = cache - .get_beatmap_default(score.beatmap_id) - .map(|v| BeatmapWithMode(v, mode)); - match beatmap { - Ok(v) => Some((rank, score, v)), - Err(e) => { - dbg!(e); - None - } - } + .map(|(rank, score)| -> Result<_, Error> { + let beatmap = cache.get_beatmap_default(score.beatmap_id)?; + let content = oppai.get_beatmap(beatmap.beatmap_id)?; + Ok((rank, score, BeatmapWithMode(beatmap, mode), content)) }) - .for_each(|(rank, score, beatmap)| { + .filter_map(|v| v.ok()) + .for_each(|(rank, score, beatmap, content)| { for channel in (&channels).iter() { if let Err(e) = channel.send_message(c.http(), |c| { c.content(format!("New top record from {}!", user_id.mention())) - .embed(|e| score_embed(&score, &beatmap, &user, Some(rank), e)) + .embed(|e| score_embed(&score, &beatmap, &content, &user, Some(rank), e)) }) { dbg!(e); } diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 9498f48..5596fcc 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -1,6 +1,6 @@ use super::BeatmapWithMode; use crate::{ - discord::oppai_cache::BeatmapInfo, + discord::oppai_cache::{BeatmapContent, BeatmapInfo}, models::{Beatmap, Mode, Mods, Rank, Score, User}, }; use chrono::Utc; @@ -233,6 +233,7 @@ pub fn beatmapset_embed<'a>( pub(crate) fn score_embed<'a>( s: &Score, bm: &BeatmapWithMode, + content: &BeatmapContent, u: &User, top_record: Option, m: &'a mut CreateEmbed, @@ -240,6 +241,11 @@ pub(crate) fn score_embed<'a>( let mode = bm.mode(); let b = &bm.0; let accuracy = s.accuracy(mode); + let stars = mode + .to_oppai_mode() + .and_then(|mode| content.get_info_with(Some(mode), s.mods).ok()) + .map(|info| info.stars as f64) + .unwrap_or(b.difficulty.stars); let score_line = match &s.rank { Rank::SS | Rank::SSH => format!("SS"), _ if s.perfect => format!("{:.2}% FC", accuracy), @@ -249,9 +255,23 @@ pub(crate) fn score_embed<'a>( accuracy, s.max_combo, s.count_miss, v ), }; - let score_line = - s.pp.map(|pp| format!("{} | {:.2}pp", &score_line, pp)) - .unwrap_or(score_line); + let pp = s.pp.map(|pp| format!("{:.2}pp", pp)).or_else(|| { + mode.to_oppai_mode() + .and_then(|op| { + content + .get_pp_from( + oppai_rs::Combo::non_fc(s.max_combo as u32, s.count_miss as u32), + accuracy as f32, + Some(op), + s.mods, + ) + .ok() + }) + .map(|pp| format!("{:.2}pp [?]", pp)) + }); + let score_line = pp + .map(|pp| format!("{} | {}", &score_line, pp)) + .unwrap_or(score_line); let top_record = top_record .map(|v| format!("| #{} top record!", v)) .unwrap_or("".to_owned()); @@ -265,7 +285,7 @@ pub(crate) fn score_embed<'a>( b.title, b.difficulty_name, s.mods, - b.difficulty.stars, + stars, b.creator, score_line, top_record @@ -286,7 +306,7 @@ pub(crate) fn score_embed<'a>( MessageBuilder::new() .push(format!("[[Link]]({})", b.link())) .push(", ") - .push_bold(format!("{:.2}⭐", b.difficulty.stars)) + .push_bold(format!("{:.2}⭐", stars)) .push(", ") .push_bold_line( b.mode.to_string() @@ -309,7 +329,11 @@ pub(crate) fn score_embed<'a>( .build(), false, ) - .field("Played on", s.date.format("%F %T"), false) + .field("Played on", s.date.format("%F %T"), false); + if mode.to_oppai_mode().is_none() && s.mods != Mods::NOMOD { + m.footer(|f| f.text("Star difficulty does not reflect game mods.")); + } + m } pub(crate) fn user_embed<'a>( diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 1977e0e..131a338 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -383,6 +383,7 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult let osu = ctx.data.get_cloned::(); let meta_cache = ctx.data.get_cloned::(); + let oppai = ctx.data.get_cloned::(); let user = osu .user(user, |f| f.mode(mode))? .ok_or(Error::from("User not found"))?; @@ -395,19 +396,20 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult .ok_or(Error::from("No such play"))?; let beatmap = meta_cache .get_beatmap(recent_play.beatmap_id, mode) - .map(|v| BeatmapWithMode(v, mode)) .unwrap(); + let content = oppai.get_beatmap(beatmap.beatmap_id)?; + let beatmap_mode = BeatmapWithMode(beatmap, mode); msg.channel_id.send_message(&ctx, |m| { m.content(format!( "{}: here is the play that you requested", msg.author )) - .embed(|m| score_embed(&recent_play, &beatmap, &user, None, m)) + .embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user, None, m)) })?; // Save the beatmap... - cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap)?; + cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap_mode)?; } Nth::All => { let plays = osu.user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50))?; @@ -466,6 +468,9 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); + let oppai = ctx.data.get_cloned::(); + + let content = oppai.get_beatmap(b.beatmap_id)?; let user = osu .user(user, |f| f)? @@ -478,7 +483,7 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult for score in scores.into_iter() { msg.channel_id.send_message(&ctx, |c| { - c.embed(|m| score_embed(&score, &bm, &user, None, m)) + c.embed(|m| score_embed(&score, &bm, &content, &user, None, m)) })?; } } @@ -502,6 +507,7 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); + let oppai = ctx.data.get_cloned::(); let user = osu .user(user, |f| f.mode(mode))? .ok_or(Error::from("User not found"))?; @@ -522,15 +528,16 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { })? .into_iter() .next() - .map(|v| BeatmapWithMode(v, mode)) .unwrap(); + let content = oppai.get_beatmap(beatmap.beatmap_id)?; + let beatmap = BeatmapWithMode(beatmap, mode); msg.channel_id.send_message(&ctx, |m| { m.content(format!( "{}: here is the play that you requested", msg.author )) - .embed(|m| score_embed(&top_play, &beatmap, &user, Some(rank), m)) + .embed(|m| score_embed(&top_play, &beatmap, &content, &user, Some(rank), m)) })?; // Save the beatmap... From 5656d96ce1324d1c4a10909eeb947611f060b7ab Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 20:03:42 -0400 Subject: [PATCH 10/14] Re-format score embed --- youmubot-osu/src/discord/embeds.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 5596fcc..3c44a44 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -298,8 +298,15 @@ pub(crate) fn score_embed<'a>( false, ) .field("Rank", &score_line, false) + .field( + "300s / 100s / 50s / misses", + format!( + "**{}** ({}) / **{}** ({}) / **{}** / **{}**", + s.count_300, s.count_geki, s.count_100, s.count_katu, s.count_50, s.count_miss + ), + true, + ) .fields(s.pp.map(|pp| ("pp gained", format!("{:.2}pp", pp), true))) - .field("Creator", &b.creator, true) .field("Mode", mode.to_string(), true) .field( "Map stats", @@ -329,6 +336,7 @@ pub(crate) fn score_embed<'a>( .build(), false, ) + .timestamp(&s.date) .field("Played on", s.date.format("%F %T"), false); if mode.to_oppai_mode().is_none() && s.mods != Mods::NOMOD { m.footer(|f| f.text("Star difficulty does not reflect game mods.")); From 5c270db9cb07d001dfd740525f2e358dc01c5e1b Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 20:36:15 -0400 Subject: [PATCH 11/14] Implement announcer tool --- youmubot-prelude/src/announcer.rs | 47 ++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/youmubot-prelude/src/announcer.rs b/youmubot-prelude/src/announcer.rs index 99847bb..ad5bfb9 100644 --- a/youmubot-prelude/src/announcer.rs +++ b/youmubot-prelude/src/announcer.rs @@ -168,6 +168,51 @@ impl AnnouncerHandler { } } +/// Gets the announcer of the given guild. +pub fn announcer_of( + ctx: &Context, + key: &'static str, + guild: GuildId, +) -> Result, Error> { + Ok(AnnouncerChannels::open(&*ctx.data.read()) + .borrow()? + .get(key) + .and_then(|channels| channels.get(&guild).cloned())) +} + +#[command("list")] +#[description = "List the registered announcers of this server"] +#[num_args(0)] +#[only_in(guilds)] +pub fn list_announcers(ctx: &mut Context, m: &Message, _: Args) -> CommandResult { + let guild_id = m.guild_id.unwrap(); + let announcers = AnnouncerChannels::open(&*ctx.data.read()); + let announcers = announcers.borrow()?; + + let channels = ctx + .data + .get_cloned::() + .into_iter() + .filter_map(|key| { + announcers + .get(key) + .and_then(|channels| channels.get(&guild_id)) + .map(|&ch| (key, ch)) + }) + .map(|(key, ch)| format!(" - `{}`: activated on channel {}", key, ch.mention())) + .collect::>(); + + m.reply( + &ctx, + format!( + "Activated announcers on this server:\n{}", + channels.join("\n") + ), + )?; + + Ok(()) +} + #[command("register")] #[description = "Register the current channel with an announcer"] #[usage = "[announcer key]"] @@ -253,5 +298,5 @@ pub fn remove_announcer(ctx: &mut Context, m: &Message, mut args: Args) -> Comma #[only_in(guilds)] #[required_permissions(MANAGE_CHANNELS)] #[description = "Manage the announcers in the server."] -#[commands(remove_announcer, register_announcer)] +#[commands(remove_announcer, register_announcer, list_announcers)] pub struct AnnouncerCommands; From cafa65581c5d85c93b71ea5612f730e71d73bea3 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 21:40:36 -0400 Subject: [PATCH 12/14] Add short link references in embeds --- youmubot-osu/src/discord/embeds.rs | 32 +++++++++---- youmubot-osu/src/discord/hook.rs | 75 +++++++++++++++++++++++++----- youmubot-osu/src/discord/mod.rs | 26 ++++++++--- youmubot-osu/src/models/mod.rs | 34 ++++++++++++++ 4 files changed, 140 insertions(+), 27 deletions(-) diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 3c44a44..d349e30 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -109,7 +109,11 @@ pub fn beatmap_embed<'a>( ) }) .push_line(format!(" [[Beatmapset]]({})", b.beatmapset_link())) - .push_line(&b.approval) + .push_line(format!( + "Short link: `{}`", + b.short_link(Some(m), Some(mods)) + )) + .push_bold_line(&b.approval) .push("Language: ") .push_bold(&b.language) .push(" | Genre: ") @@ -209,7 +213,11 @@ pub fn beatmapset_embed<'a>( ( format!("[{}]", b.difficulty_name), MessageBuilder::new() - .push(format!("[[Link]]({})", b.link())) + .push(format!( + "[[Link]]({}) (`{}`)", + b.link(), + b.short_link(m, None) + )) .push(", ") .push_bold(format!("{:.2}⭐", b.difficulty.stars)) .push(", ") @@ -311,7 +319,11 @@ pub(crate) fn score_embed<'a>( .field( "Map stats", MessageBuilder::new() - .push(format!("[[Link]]({})", b.link())) + .push(format!( + "[[Link]]({}) (`{}`)", + b.link(), + b.short_link(Some(mode), Some(s.mods)) + )) .push(", ") .push_bold(format!("{:.2}⭐", stars)) .push(", ") @@ -346,7 +358,7 @@ pub(crate) fn score_embed<'a>( pub(crate) fn user_embed<'a>( u: User, - best: Option<(Score, BeatmapWithMode)>, + best: Option<(Score, BeatmapWithMode, Option)>, m: &'a mut CreateEmbed, ) -> &'a mut CreateEmbed { m.title(u.username) @@ -388,8 +400,8 @@ pub(crate) fn user_embed<'a>( ), false, ) - .fields(best.map(|(v, map)| { - let map = map.0; + .fields(best.map(|(v, map, info)| { + let BeatmapWithMode(map, mode) = map; ( "Best Record", MessageBuilder::new() @@ -413,8 +425,12 @@ pub(crate) fn user_embed<'a>( MessageBuilder::new().push_bold_safe(&map.title).build(), map.link() )) - .push(format!(" [{}]", map.difficulty_name)) - .push(format!(" ({:.1}⭐)", map.difficulty.stars)) + .push_line(format!(" [{}]", map.difficulty_name)) + .push(format!( + "{:.1}⭐ | `{}`", + info.map(|i| i.stars as f64).unwrap_or(map.difficulty.stars), + map.short_link(Some(mode), Some(v.mods)) + )) .build(), false, ) diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index 51c9ecc..4469593 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -1,5 +1,6 @@ use super::OsuClient; use crate::{ + discord::beatmap_cache::BeatmapMetaCache, discord::oppai_cache::{BeatmapCache, BeatmapInfo}, models::{Beatmap, Mode, Mods}, request::BeatmapRequestKind, @@ -24,6 +25,9 @@ lazy_static! { static ref NEW_LINK_REGEX: Regex = Regex::new( r"https?://osu\.ppy\.sh/beatmapsets/(?P\d+)/?(?:\#(?Posu|taiko|fruits|mania)(?:/(?P\d+)|/?))?(?:\+(?P[A-Z]+))?" ).unwrap(); + static ref SHORT_LINK_REGEX: Regex = Regex::new( + r"/b/(?P\d+)(?:/(?Posu|taiko|fruits|mania))?(?:\+(?P[A-Z]+))?" + ).unwrap(); } pub fn hook(ctx: &mut Context, msg: &Message) -> () { @@ -33,8 +37,13 @@ pub fn hook(ctx: &mut Context, msg: &Message) -> () { let mut v = move || -> CommandResult { let old_links = handle_old_links(ctx, &msg.content)?; let new_links = handle_new_links(ctx, &msg.content)?; + let short_links = handle_short_links(ctx, &msg, &msg.content)?; let mut last_beatmap = None; - for l in old_links.into_iter().chain(new_links.into_iter()) { + for l in old_links + .into_iter() + .chain(new_links.into_iter()) + .chain(short_links.into_iter()) + { if let Err(v) = msg.channel_id.send_message(&ctx, |m| match l.embed { EmbedType::Beatmap(b, info, mods) => { let t = handle_beatmap(&b, info, l.link, l.mode, mods, m); @@ -137,15 +146,9 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result> = Vec::new(); let cache = ctx.data.get_cloned::(); for capture in NEW_LINK_REGEX.captures_iter(content) { - let mode = capture.name("mode").and_then(|v| { - Some(match v.as_str() { - "osu" => Mode::Std, - "taiko" => Mode::Taiko, - "fruits" => Mode::Catch, - "mania" => Mode::Mania, - _ => return None, - }) - }); + let mode = capture + .name("mode") + .and_then(|v| Mode::parse_from_new_site(v.as_str())); let link = capture.get(0).unwrap().as_str(); let req = match capture.name("beatmap_id") { Some(ref v) => BeatmapRequestKind::Beatmap(v.as_str().parse()?), @@ -163,8 +166,7 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result(ctx: &mut Context, content: &'a str) -> Result( + ctx: &mut Context, + msg: &Message, + content: &'a str, +) -> Result>, Error> { + if let Some(guild_id) = msg.guild_id { + if announcer::announcer_of(ctx, crate::discord::announcer::ANNOUNCER_KEY, guild_id)? + != Some(msg.channel_id) + { + // Disable if we are not in the server's announcer channel + return Ok(vec![]); + } + } + let osu = ctx.data.get_cloned::(); + let cache = ctx.data.get_cloned::(); + Ok(SHORT_LINK_REGEX + .captures_iter(content) + .map(|capture| -> Result<_, Error> { + let mode = capture + .name("mode") + .and_then(|v| Mode::parse_from_new_site(v.as_str())); + let id: u64 = capture.name("id").unwrap().as_str().parse()?; + let beatmap = match mode { + Some(mode) => osu.get_beatmap(id, mode), + None => osu.get_beatmap_default(id), + }?; + let mods = capture + .name("mods") + .and_then(|v| Mods::from_str(v.as_str()).ok()) + .unwrap_or(Mods::NOMOD); + let info = mode + .unwrap_or(beatmap.mode) + .to_oppai_mode() + .and_then(|mode| { + cache + .get_beatmap(beatmap.beatmap_id) + .and_then(|b| b.get_info_with(Some(mode), mods)) + .ok() + }); + Ok(ToPrint { + embed: EmbedType::Beatmap(beatmap, info, mods), + link: capture.get(0).unwrap().as_str(), + mode, + }) + }) + .filter_map(|v| v.ok()) + .collect()) +} + fn handle_beatmap<'a, 'b>( beatmap: &Beatmap, info: Option, diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 131a338..fb72d97 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -272,8 +272,12 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command .map(|info| info.stars as f64) .unwrap_or(b.difficulty.stars); format!( - "[{:.1}*] {} - {} [{}] (#{})", - stars, b.artist, b.title, b.difficulty_name, b.beatmap_id + "[{:.1}*] {} - {} [{}] ({})", + stars, + b.artist, + b.title, + b.difficulty_name, + b.short_link(Some(mode), Some(plays[i].mods)), ) } else { "FETCH_FAILED".to_owned() @@ -554,18 +558,26 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { fn get_user(ctx: &mut Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult { let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); + let cache = ctx.data.get_cloned::(); let user = osu.user(user, |f| f.mode(mode))?; + let oppai = ctx.data.get_cloned::(); match user { Some(u) => { let best = osu .user_best(UserID::ID(u.id), |f| f.limit(1).mode(mode))? .into_iter() .next() - .map(|m| { - osu.beatmaps(BeatmapRequestKind::Beatmap(m.beatmap_id), |f| { - f.mode(mode, true) - }) - .map(|map| (m, BeatmapWithMode(map.into_iter().next().unwrap(), mode))) + .map(|m| -> Result<_, Error> { + let beatmap = cache.get_beatmap(m.beatmap_id, mode)?; + let info = mode + .to_oppai_mode() + .map(|mode| -> Result<_, Error> { + Ok(oppai + .get_beatmap(m.beatmap_id)? + .get_info_with(Some(mode), m.mods)?) + }) + .transpose()?; + Ok((m, BeatmapWithMode(beatmap, mode), info)) }) .transpose()?; msg.channel_id.send_message(&ctx, |m| { diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 06c883e..c568887 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -198,6 +198,27 @@ impl Mode { _ => return None, }) } + + /// Parse from the new site's convention. + pub fn parse_from_new_site(s: &str) -> Option { + Some(match s { + "osu" => Mode::Std, + "taiko" => Mode::Taiko, + "fruits" => Mode::Catch, + "mania" => Mode::Mania, + _ => return None, + }) + } + + /// Returns the mode string in the new convention. + pub fn to_str_new_site(&self) -> &'static str { + match self { + Mode::Std => "osu", + Mode::Taiko => "taiko", + Mode::Catch => "fruits", + Mode::Mania => "mania", + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -249,6 +270,19 @@ impl Beatmap { ) } + /// Return a parsable short link. + pub fn short_link(&self, override_mode: Option, mods: Option) -> String { + format!( + "/b/{}{}{}", + self.beatmap_id, + match override_mode { + Some(mode) if mode != self.mode => format!("/{}", mode.to_str_new_site()), + _ => "".to_owned(), + }, + mods.map(|m| format!("{}", m)).unwrap_or("".to_owned()), + ) + } + /// Link to the cover image of the beatmap. pub fn cover_url(&self) -> String { format!( From cfd848767d83085a27fed6f33df8b6ae7119f487 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 21:45:16 -0400 Subject: [PATCH 13/14] Match more osu! play mode strings --- youmubot-osu/src/discord/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index fb72d97..2b17019 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -180,11 +180,11 @@ struct ModeArg(Mode); impl FromStr for ModeArg { type Err = String; fn from_str(s: &str) -> Result { - Ok(ModeArg(match s { - "std" => Mode::Std, - "taiko" => Mode::Taiko, - "catch" => Mode::Catch, - "mania" => Mode::Mania, + Ok(ModeArg(match &s.to_lowercase()[..] { + "osu" | "std" => Mode::Std, + "taiko" | "osu!taiko" => Mode::Taiko, + "ctb" | "fruits" | "catch" | "osu!ctb" | "osu!catch" => Mode::Catch, + "osu!mania" | "mania" => Mode::Mania, _ => return Err(format!("Unknown mode {}", s)), })) } From 4a0c596ea56691c3f8febef2d8f6e5a790efcb35 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sun, 14 Jun 2020 21:52:13 -0400 Subject: [PATCH 14/14] Push rust to 1.44 --- .drone.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.drone.yml b/.drone.yml index c9495d5..7347149 100644 --- a/.drone.yml +++ b/.drone.yml @@ -10,12 +10,12 @@ trigger: steps: - name: format_check - image: rust:1.41 + image: rust:1.44 commands: - rustup component add rustfmt - cargo fmt -- --check - name: cargo_check - image: rust:1.41 + image: rust:1.44 commands: - cargo check @@ -31,7 +31,7 @@ trigger: steps: - name: build_release - image: rust:1.41 + image: rust:1.44 commands: - cargo build --release - name: deploy