diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index ef7e09a..d4f3024 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -7,12 +7,53 @@ use chrono::Utc; use serenity::{builder::CreateEmbed, utils::MessageBuilder}; use youmubot_prelude::*; -fn format_mode(actual: Mode, original: Mode) -> String { - if actual == original { - format!("{}", actual) - } else { - format!("{} (converted)", actual) +/// Writes a number grouped in groups of 3. +fn grouped_number(num: u64) -> String { + let s = num.to_string(); + let mut b = MessageBuilder::new(); + let mut i = if s.len() % 3 == 0 { 3 } else { s.len() % 3 }; + b.push(&s[..i]); + while i < s.len() { + b.push(",").push(&s[i..i + 3]); + i += 3; } + b.build() +} + +fn beatmap_description(b: &Beatmap) -> String { + MessageBuilder::new() + .push_bold_line(&b.approval) + .push({ + let link = b.download_link(false); + format!( + "Download: [[Link]]({}) [[No Video]]({}?noVideo=1) [[Bloodcat]]({})", + link, + link, + b.download_link(true), + ) + }) + .push_line(format!(" [[Beatmapset]]({})", b.beatmapset_link())) + .push("Language: ") + .push_bold(&b.language) + .push(" | Genre: ") + .push_bold_line(&b.genre) + .push( + b.source + .as_ref() + .map(|v| format!("Source: **{}**\n", v)) + .unwrap_or_else(|| "".to_owned()), + ) + .push("Tags: ") + .push_line( + b.tags + .iter() + .map(|v| MessageBuilder::new().push_mono_safe(v).build()) + .take(10) + .chain(std::iter::once("...".to_owned())) + .collect::>() + .join(" "), + ) + .build() } pub fn beatmap_embed<'a>( @@ -27,7 +68,7 @@ pub fn beatmap_embed<'a>( } else { format!(" {}", mods) }; - let diff = b.difficulty.apply_mods(mods); + let diff = b.difficulty.apply_mods(mods, info.map(|v| v.stars as f64)); c.title( MessageBuilder::new() .push_bold_safe(&b.artist) @@ -45,18 +86,8 @@ pub fn beatmap_embed<'a>( .icon_url(format!("https://a.ppy.sh/{}", b.creator_id)) }) .url(b.link()) - .thumbnail(format!("https://b.ppy.sh/thumb/{}l.jpg", b.beatmapset_id)) .image(b.cover_url()) .color(0xffb6c1) - .field( - "Star Difficulty", - format!( - "{:.2}⭐", - info.map(|v| v.stars as f64).unwrap_or(b.difficulty.stars) - ), - true, - ) - .fields(Some(("Mods", mods, true)).filter(|_| mods != Mods::NOMOD)) .fields(info.map(|info| { ( "Calculated pp", @@ -67,59 +98,8 @@ pub fn beatmap_embed<'a>( false, ) })) - .field( - "Length", - MessageBuilder::new() - .push_bold_safe(Duration(diff.total_length)) - .push(" (") - .push_bold_safe(Duration(diff.drain_length)) - .push(" drain)") - .build(), - false, - ) - .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", diff.bpm.round(), true) - .fields(b.difficulty.max_combo.map(|v| ("Max combo", v, true))) - .field("Mode", format_mode(m, b.mode), true) - .fields(b.source.as_ref().map(|v| ("Source", v, true))) - .field( - "Tags", - b.tags - .iter() - .map(|v| MessageBuilder::new().push_mono_safe(v).build()) - .take(10) - .chain(std::iter::once("...".to_owned())) - .collect::>() - .join(" "), - false, - ) - .description( - MessageBuilder::new() - .push({ - let link = format!( - "https://osu.ppy.sh/beatmapsets/{}/download", - b.beatmapset_id - ); - format!( - "Download: [[Link]]({}) [[No Video]]({}?noVideo=1)", - link, link - ) - }) - .push_line(format!(" [[Beatmapset]]({})", b.beatmapset_link())) - .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: ") - .push_bold(&b.genre) - .build(), - ) + .field("Information", diff.format_info(m, mods, b), false) + .description(beatmap_description(b)) .footer(|f| { if info.is_none() && mods != Mods::NOMOD { f.text("Star difficulty not reflecting mods applied."); @@ -153,51 +133,12 @@ pub fn beatmapset_embed<'a>( "https://osu.ppy.sh/beatmapsets/{}", b.beatmapset_id, )) - // .thumbnail(format!("https://b.ppy.sh/thumb/{}l.jpg", b.beatmapset_id)) .image(format!( "https://assets.ppy.sh/beatmaps/{}/covers/cover.jpg", b.beatmapset_id )) .color(0xffb6c1) - .description( - MessageBuilder::new() - .push_line({ - let link = format!( - "https://osu.ppy.sh/beatmapsets/{}/download", - b.beatmapset_id - ); - format!( - "Download: [[Link]]({}) [[No Video]]({}?noVideo=1)", - link, link - ) - }) - .push_line(&b.approval) - .push("Language: ") - .push_bold(&b.language) - .push(" | Genre: ") - .push_bold(&b.genre) - .build(), - ) - .field( - "Length", - MessageBuilder::new() - .push_bold_safe(Duration(b.difficulty.total_length)) - .build(), - true, - ) - .field("BPM", b.difficulty.bpm.round(), true) - .fields(b.source.as_ref().map(|v| ("Source", v, false))) - .field( - "Tags", - b.tags - .iter() - .map(|v| MessageBuilder::new().push_mono_safe(v).build()) - .take(10) - .chain(std::iter::once("...".to_owned())) - .collect::>() - .join(" "), - false, - ) + .description(beatmap_description(b)) .footer(|f| { if too_many_diffs { f.text(format!( @@ -212,27 +153,8 @@ pub fn beatmapset_embed<'a>( .fields(bs.iter().rev().take(MAX_DIFFS).rev().map(|b: &Beatmap| { ( format!("[{}]", b.difficulty_name), - MessageBuilder::new() - .push(format!( - "[[Link]]({}) (`{}`)", - b.link(), - b.short_link(m, None) - )) - .push(", ") - .push_bold(format!("{:.2}⭐", b.difficulty.stars)) - .push(", ") - .push_bold_line(format_mode(m.unwrap_or(b.mode), b.mode)) - .push("CS") - .push_bold(format!("{:.1}", b.difficulty.cs)) - .push(", AR") - .push_bold(format!("{:.1}", b.difficulty.ar)) - .push(", OD") - .push_bold(format!("{:.1}", b.difficulty.od)) - .push(", HP") - .push_bold(format!("{:.1}", b.difficulty.hp)) - .push(", ⌛ ") - .push_bold(format!("{}", Duration(b.difficulty.drain_length))) - .build(), + b.difficulty + .format_info(m.unwrap_or(b.mode), Mods::NOMOD, b), false, ) })) @@ -297,13 +219,31 @@ pub(crate) fn score_embed<'a>( } else { pp.map(|v| v.1) }; + let pp_gained = s.pp.map(|full_pp| { + top_record + .map(|top| { + let after_pp = u.pp.unwrap(); + let effective_pp = full_pp * (0.95f64).powi(top as i32); + let before_pp = after_pp - effective_pp; + format!( + "**pp gained**: **{:.2}**pp (+**{:.2}**pp | {:.2}pp \\➡️ {:.2}pp)", + full_pp, effective_pp, before_pp, after_pp + ) + }) + .unwrap_or_else(|| format!("**pp gained**: **{:.2}**pp", full_pp)) + }); let score_line = pp .map(|pp| format!("{} | {}", &score_line, pp)) .unwrap_or(score_line); + let max_combo = b + .difficulty + .max_combo + .map(|max| format!("**{}x**/{}x", s.max_combo, max)) + .unwrap_or_else(|| format!("**{}x**", s.max_combo)); let top_record = top_record .map(|v| format!("| #{} top record!", v)) .unwrap_or("".to_owned()); - let diff = b.difficulty.apply_mods(s.mods); + let diff = b.difficulty.apply_mods(s.mods, Some(stars)); m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url())) .color(0xffb6c1) .title(format!( @@ -318,60 +258,42 @@ pub(crate) fn score_embed<'a>( score_line, top_record )) - .description(format!("[[Beatmap]]({})", b.link())) + .description(format!( + r#"**Beatmap**: {} - {} [{}]**{} ** +**Links**: [[Listing]]({}) [[Download]]({}) [[Bloodcat]]({}) +**Played on**: {} +{}"#, + b.artist, + b.title, + b.difficulty_name, + s.mods, + b.link(), + b.download_link(false), + b.download_link(true), + s.date.format("%F %T"), + pp_gained.as_ref().map(|v| &v[..]).unwrap_or(""), + )) .image(b.cover_url()) .field( - "Beatmap", - format!("{} - {} [{}]", b.artist, b.title, b.difficulty_name), - false, - ) - .field("Rank", &score_line, false) - .field( - "300s / 100s / 50s / misses", + "Score stats", format!( - "**{}** ({}) / **{}** ({}) / **{}** / **{}**", + "**{}** | {} | **{:.2}%**", + grouped_number(s.score), + max_combo, + accuracy + ), + true, + ) + .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("Mode", mode.to_string(), true) - .field( - "Map stats", - MessageBuilder::new() - .push(format!( - "[[Link]]({}) (`{}`)", - b.link(), - b.short_link(Some(mode), Some(s.mods)) - )) - .push(", ") - .push_bold(format!("{:.2}⭐", stars)) - .push(", ") - .push_bold_line( - b.mode.to_string() - + if bm.is_converted() { - " (Converted)" - } else { - "" - }, - ) - .push("CS") - .push_bold(format!("{:.1}", diff.cs)) - .push(", AR") - .push_bold(format!("{:.1}", diff.ar)) - .push(", OD") - .push_bold(format!("{:.1}", diff.od)) - .push(", HP") - .push_bold(format!("{:.1}", diff.hp)) - .push(", BPM ") - .push_bold(format!("{}", diff.bpm.round())) - .push(", ⌛ ") - .push_bold(format!("{}", Duration(diff.drain_length))) - .build(), - false, - ) - .timestamp(&s.date) - .field("Played on", s.date.format("%F %T"), false); + .field("Map stats", diff.format_info(mode, s.mods, b), false) + .timestamp(&s.date); if mode.to_oppai_mode().is_none() && s.mods != Mods::NOMOD { m.footer(|f| f.text("Star difficulty does not reflect game mods.")); } @@ -394,31 +316,44 @@ pub(crate) fn user_embed<'a>( .unwrap_or("Inactive".to_owned()), false, ) - .field("World Rank", format!("#{}", u.rank), true) + .field("World Rank", format!("#{}", grouped_number(u.rank)), true) .field( "Country Rank", - format!(":flag_{}: #{}", u.country.to_lowercase(), u.country_rank), + format!( + ":flag_{}: #{}", + u.country.to_lowercase(), + grouped_number(u.country_rank) + ), true, ) .field("Accuracy", format!("{:.2}%", u.accuracy), true) .field( - "Play count", - format!("{} (play time: {})", u.play_count, Duration(u.played_time)), + "Play count / Play time", + format!( + "{} ({})", + grouped_number(u.play_count), + Duration(u.played_time) + ), false, ) .field( "Ranks", format!( - "{} SSH | {} SS | {} SH | {} S | {} A", - u.count_ssh, u.count_ss, u.count_sh, u.count_s, u.count_a + "**{}** SSH | **{}** SS | **{}** SH | **{}** S | **{}** A", + grouped_number(u.count_ssh), + grouped_number(u.count_ss), + grouped_number(u.count_sh), + grouped_number(u.count_s), + grouped_number(u.count_a) ), false, ) .field( - "Level", + format!("Level {:.0}", u.level), format!( - "Level **{:.0}**: {} total score, {} ranked score", - u.level, u.total_score, u.ranked_score + "**{}** total score, **{}** ranked score", + grouped_number(u.total_score), + grouped_number(u.ranked_score) ), false, ) @@ -441,13 +376,14 @@ pub(crate) fn user_embed<'a>( ) )) .push("on ") - .push(format!( - "[{} - {}]({})", + .push_line(format!( + "[{} - {} [{}]]({})**{} **", MessageBuilder::new().push_bold_safe(&map.artist).build(), MessageBuilder::new().push_bold_safe(&map.title).build(), - map.link() + map.difficulty_name, + map.link(), + v.mods )) - .push_line(format!(" [{}]", map.difficulty_name)) .push(format!( "{:.1}⭐ | `{}`", info.map(|i| i.stars as f64).unwrap_or(map.difficulty.stars), diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index 4469593..3c99f0e 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -20,13 +20,13 @@ use super::embeds::{beatmap_embed, beatmapset_embed}; lazy_static! { static ref OLD_LINK_REGEX: Regex = Regex::new( - r"https?://osu\.ppy\.sh/(?Ps|b)/(?P\d+)(?:[\&\?]m=(?P\d))?(?:\+(?P[A-Z]+))?" + r"(?:https?://)?osu\.ppy\.sh/(?Ps|b)/(?P\d+)(?:[\&\?]m=(?P\d))?(?:\+(?P[A-Z]+))?" ).unwrap(); 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]+))?" + 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]+))?" + r"(?:^|\s)/b/(?P\d+)(?:/(?Posu|taiko|fruits|mania))?(?:\+(?P[A-Z]+))?" ).unwrap(); } diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index de9d8a2..8d1c7ce 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -122,11 +122,6 @@ pub fn mania(ctx: &mut Context, msg: &Message, args: Args) -> CommandResult { pub(crate) struct BeatmapWithMode(pub Beatmap, pub Mode); impl BeatmapWithMode { - /// Whether this beatmap-with-mode is a converted beatmap. - fn is_converted(&self) -> bool { - self.0.mode != self.1 - } - fn mode(&self) -> Mode { self.1 } diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index c88dc3e..7575f6b 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -2,12 +2,14 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt; use std::time::Duration; +use youmubot_prelude::Duration as YoumuDuration; pub mod mods; pub mod parse; pub(crate) mod raw; pub use mods::Mods; +use serenity::utils::MessageBuilder; #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum ApprovalStatus { @@ -92,8 +94,11 @@ impl Difficulty { } /// 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(); + pub fn apply_mods(&self, mods: Mods, updated_stars: Option) -> Difficulty { + let mut diff = Difficulty { + stars: updated_stars.unwrap_or(self.stars), + ..self.clone() + }; // Apply mods one by one if mods.contains(Mods::EZ) { @@ -118,6 +123,58 @@ impl Difficulty { diff } + + /// Format the difficulty info into a short summary. + pub fn format_info(&self, mode: Mode, mods: Mods, original_beatmap: &Beatmap) -> String { + let is_not_ranked = match original_beatmap.approval { + ApprovalStatus::Ranked(_) => false, + _ => true, + }; + let three_lines = is_not_ranked; + let bpm = (self.bpm * 100.0).round() / 100.0; + MessageBuilder::new() + .push(format!( + "[[Link]]({}) (`{}`)", + original_beatmap.link(), + original_beatmap.short_link(Some(mode), Some(mods)) + )) + .push(if three_lines { "\n" } else { ", " }) + .push_bold(format!("{:.2}⭐", self.stars)) + .push(", ") + .push( + original_beatmap + .difficulty + .max_combo + .map(|c| format!("max **{}x**, ", c)) + .unwrap_or_else(|| "".to_owned()), + ) + .push(if is_not_ranked { + format!("status **{}**, mode ", original_beatmap.approval) + } else { + "".to_owned() + }) + .push_bold_line(format_mode(mode, original_beatmap.mode)) + .push("CS") + .push_bold(format!("{:.1}", self.cs)) + .push(", AR") + .push_bold(format!("{:.1}", self.ar)) + .push(", OD") + .push_bold(format!("{:.1}", self.od)) + .push(", HP") + .push_bold(format!("{:.1}", self.hp)) + .push(format!(", BPM**{}**", bpm)) + .push(", ⌛ ") + .push_bold(format!("{}", YoumuDuration(self.drain_length))) + .build() + } +} + +fn format_mode(actual: Mode, original: Mode) -> String { + if actual == original { + format!("{}", actual) + } else { + format!("{} (converted)", actual) + } } #[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)] @@ -277,6 +334,18 @@ impl Beatmap { ) } + /// Returns a direct download link. If `bloodcat` is true, return the bloodcat download link. + pub fn download_link(&self, bloodcat: bool) -> String { + if bloodcat { + format!("https://bloodcat.com/osu/s/{}", self.beatmapset_id) + } else { + format!( + "https://osu.ppy.sh/beatmapsets/{}/download", + self.beatmapset_id + ) + } + } + /// Return a parsable short link. pub fn short_link(&self, override_mode: Option, mods: Option) -> String { format!(