diff --git a/youmubot-osu/src/models/deser.rs b/youmubot-osu/src/models/deser.rs index 1f5808c..6ce6020 100644 --- a/youmubot-osu/src/models/deser.rs +++ b/youmubot-osu/src/models/deser.rs @@ -17,9 +17,13 @@ impl<'de> Deserialize<'de> for Score { user_id: parse_from_str(&raw.user_id)?, date: parse_date(&raw.date)?, beatmap_id: raw.beatmap_id.map(parse_from_str).transpose()?.unwrap_or(0), - replay_available: parse_bool(&raw.replay_available)?, + replay_available: raw + .replay_available + .map(parse_bool) + .transpose()? + .unwrap_or(false), score: parse_from_str(&raw.score)?, - pp: parse_from_str(&raw.pp)?, + pp: raw.pp.map(parse_from_str).transpose()?, rank: parse_from_str(&raw.rank)?, mods: { let v: u64 = parse_from_str(&raw.enabled_mods)?; diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 0e7ddf2..5f955a4 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -159,6 +159,14 @@ impl Beatmap { self.beatmapset_id, NEW_MODE_NAMES[self.mode as usize], self.beatmap_id ) } + + /// Link to the cover image of the beatmap. + pub fn cover_url(&self) -> String { + format!( + "https://assets.ppy.sh/beatmaps/{}/covers/cover.jpg", + self.beatmapset_id + ) + } } #[derive(Debug)] @@ -198,6 +206,16 @@ pub struct User { pub accuracy: f64, } +impl User { + pub fn link(&self) -> String { + format!("https://osu.ppy.sh/users/{}", self.id) + } + + pub fn avatar_url(&self) -> String { + format!("https://a.ppy.sh/{}", self.id) + } +} + #[derive(Debug)] pub enum Rank { SS, @@ -244,7 +262,7 @@ pub struct Score { pub beatmap_id: u64, pub score: u64, - pub pp: f64, + pub pp: Option, pub rank: Rank, pub mods: Mods, // Later @@ -257,3 +275,41 @@ pub struct Score { pub max_combo: u64, pub perfect: bool, } + +impl Score { + /// Given the play's mode, calculate the score's accuracy. + pub fn accuracy(&self, mode: Mode) -> f64 { + 100.0 + * match mode { + Mode::Std => { + (6 * self.count_300 + 2 * self.count_100 + self.count_50) as f64 + / (6.0 + * (self.count_300 + self.count_100 + self.count_50 + self.count_miss) + as f64) + } + Mode::Taiko => { + (2 * self.count_300 + self.count_100) as f64 + / 2.0 + / (self.count_300 + self.count_100 + self.count_miss) as f64 + } + Mode::Catch => { + (self.count_300 + self.count_100) as f64 + / (self.count_300 + self.count_100 + self.count_miss + self.count_katu/* # of droplet misses */) + as f64 + } + Mode::Mania => { + ((self.count_geki /* MAX */ + self.count_300) * 6 + + self.count_katu /* 200 */ * 4 + + self.count_100 * 2 + + self.count_50) as f64 + / 6.0 + / (self.count_geki + + self.count_300 + + self.count_katu + + self.count_100 + + self.count_50 + + self.count_miss) as f64 + } + } + } +} diff --git a/youmubot-osu/src/models/raw.rs b/youmubot-osu/src/models/raw.rs index 6d90fa5..d11064a 100644 --- a/youmubot-osu/src/models/raw.rs +++ b/youmubot-osu/src/models/raw.rs @@ -93,6 +93,6 @@ pub(crate) struct Score { pub user_id: String, pub date: String, pub rank: String, - pub pp: String, - pub replay_available: String, + pub pp: Option, + pub replay_available: Option, } diff --git a/youmubot/src/commands/osu/mod.rs b/youmubot/src/commands/osu/mod.rs index bcf4fb6..aa35067 100644 --- a/youmubot/src/commands/osu/mod.rs +++ b/youmubot/src/commands/osu/mod.rs @@ -13,13 +13,15 @@ use serenity::{ utils::MessageBuilder, }; use youmubot_osu::{ - models::{Beatmap, Mode, Score, User}, + models::{Beatmap, Mode, Rank, Score, User}, request::{BeatmapRequestKind, UserID}, + Client as OsuClient, }; mod hook; pub use hook::hook; +use std::str::FromStr; group!({ name: "osu", @@ -27,7 +29,7 @@ group!({ prefix: "osu", description: "osu! related commands.", }, - commands: [std, taiko, catch, mania, save], + commands: [std, taiko, catch, mania, save, recent], }); #[command] @@ -101,6 +103,96 @@ pub fn save(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { Ok(()) } +struct ModeArg(Mode); + +impl FromStr for ModeArg { + type Err = Error; + fn from_str(s: &str) -> Result { + Ok(ModeArg(match s { + "std" => Mode::Std, + "taiko" => Mode::Taiko, + "catch" => Mode::Catch, + "mania" => Mode::Mania, + _ => return Err(Error::from(format!("Unknown mode {}", s))), + })) + } +} + +#[command] +#[description = "Gets an user's recent play"] +#[usage = "#[the nth recent play = 1] / [mode (std, taiko, mania, catch) = std] / [username / user id = your saved id]"] +#[example = "#1 / taiko / natsukagami"] +#[max_args(3)] +pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { + struct Nth(u8); + + impl FromStr for Nth { + type Err = Error; + fn from_str(s: &str) -> Result { + if !s.starts_with("#") { + Err(Error::from("Not an order")) + } else { + let v = s.split_at("#".len()).1.parse()?; + Ok(Nth(v)) + } + } + } + + let mut data = ctx.data.write(); + + let nth = args.single::().unwrap_or(Nth(1)).0.min(50).max(1); + let mode = args.single::().unwrap_or(ModeArg(Mode::Std)).0; + let user = match args.single::() { + Ok(v) => v, + Err(_) => { + let db: DBWriteGuard<_> = data + .get_mut::() + .ok_or(Error::from("DB uninitialized"))? + .into(); + let db = db.borrow()?; + match db.get(&msg.author.id) { + Some(ref v) => v.to_string(), + None => { + msg.reply(&ctx, "You have not saved any account.")?; + return Ok(()); + } + } + } + }; + + dbg!((nth, &mode, &user, &args)); + + let reqwest = data.get::().unwrap(); + let osu: &OsuClient = data.get::().unwrap(); + let user = osu + .user(reqwest, UserID::Auto(user), |f| f.mode(mode))? + .ok_or(Error::from("User not found"))?; + let recent_play = osu + .user_recent(reqwest, UserID::ID(user.id), |f| f.mode(mode).limit(nth))? + .into_iter() + .last() + .ok_or(Error::from("No such play"))?; + let beatmap = osu + .beatmaps( + reqwest, + BeatmapRequestKind::Beatmap(recent_play.beatmap_id), + |f| f.mode(mode, true), + )? + .into_iter() + .next() + .unwrap(); + + 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, &mode, None, m)) + })?; + + Ok(()) +} + fn get_user(ctx: &mut Context, msg: &Message, args: Args, mode: Mode) -> CommandResult { let mut data = ctx.data.write(); let username = match args.remains() { @@ -147,6 +239,78 @@ fn get_user(ctx: &mut Context, msg: &Message, args: Args, mode: Mode) -> Command Ok(()) } +fn score_embed<'a>( + s: &Score, + b: &Beatmap, + u: &User, + mode: &Mode, + top_record: Option, + m: &'a mut CreateEmbed, +) -> &'a mut CreateEmbed { + let accuracy = s.accuracy(*mode); + let score_line = match &s.rank { + Rank::SS | Rank::SSH => format!("SS"), + _ if s.perfect => format!("{:2}% FC", accuracy), + Rank::F => format!("{:.2}% {} combo [FAILED]", accuracy, s.max_combo), + v => format!("{:.2}% {} combo {} rank", accuracy, s.max_combo, v), + }; + let score_line = + s.pp.map(|pp| format!("{} | {:2}pp", &score_line, pp)) + .unwrap_or(score_line); + let top_record = top_record + .map(|v| format!("| #{} top record!", v)) + .unwrap_or("".to_owned()); + m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url())) + .color(0xffb6c1) + .title(format!( + "{} | {} - {} [{}] {} ({:.2}\\*) by {} | {} {}", + u.username, + b.artist, + b.title, + b.difficulty_name, + s.mods, + b.difficulty.stars, + b.creator, + score_line, + top_record + )) + .description(format!("[[Beatmap]]({})", b.link())) + .image(b.cover_url()) + .field( + "Beatmap", + format!("{} - {} [{}]", b.artist, b.title, b.difficulty_name), + false, + ) + .field("Rank", &score_line, false) + .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", + MessageBuilder::new() + .push(format!("[[Link]]({})", b.link())) + .push(", ") + .push_bold(format!("{:.2}⭐", b.difficulty.stars)) + .push(", ") + .push_bold_line( + b.mode.to_string() + if b.mode == *mode { "" } else { " (Converted)" }, + ) + .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.drain_length))) + .build(), + false, + ) + .field("Played on", s.date.format("%F %T"), false) +} + fn user_embed<'a>( u: User, best: Option<(Score, Beatmap)>, @@ -195,7 +359,10 @@ fn user_embed<'a>( ( "Best Record", MessageBuilder::new() - .push_bold(format!("{:.2}pp", v.pp)) + .push_bold(format!( + "{:.2}pp", + v.pp.unwrap() /*Top record should have pp*/ + )) .push(" - ") .push_line(format!("{:.1} ago", Duration(Utc::now() - v.date))) .push("on ")