diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index 4dbb119..11975ed 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -3,15 +3,18 @@ use super::{embeds::score_embed, BeatmapWithMode}; use crate::{ discord::beatmap_cache::BeatmapMetaCache, discord::cache::save_beatmap, - discord::oppai_cache::BeatmapCache, - models::{Mode, Score}, + discord::oppai_cache::{BeatmapCache, BeatmapContent}, + models::{Mode, Score, User, UserEventRank}, request::UserID, Client as Osu, }; use announcer::MemberToChannels; use serenity::{ http::CacheHttp, - model::id::{ChannelId, UserId}, + model::{ + channel::Message, + id::{ChannelId, UserId}, + }, CacheAndHttp, }; use std::{collections::HashMap, sync::Arc}; @@ -22,12 +25,14 @@ pub const ANNOUNCER_KEY: &'static str = "osu"; /// The announcer struct implementing youmubot_prelude::Announcer pub struct Announcer { - client: Osu, + client: Arc, } impl Announcer { pub fn new(client: Osu) -> Self { - Self { client } + Self { + client: Arc::new(client), + } } } @@ -106,65 +111,56 @@ impl Announcer { mode: Mode, d: AppData, ) -> Result, Error> { + let days_since_last_update = (chrono::Utc::now() - osu_user.last_update).num_days() + 1; + let last_update = osu_user.last_update.clone(); let (scores, user) = { let scores = self.scan_user(osu_user, mode).await?; let user = self .client - .user(UserID::ID(osu_user.id), |f| f.mode(mode)) + .user(UserID::ID(osu_user.id), |f| { + f.mode(mode) + .event_days(days_since_last_update.min(31) as u8) + }) .await? .ok_or(Error::msg("user not found"))?; (scores, user) }; + let client = self.client.clone(); let pp = user.pp; spawn_future(async move { - scores - .into_iter() - .map(|(rank, score)| { - let d = d.clone(); - async move { - let data = d.read().await; - let cache = data.get::().unwrap(); - let oppai = data.get::().unwrap(); - let beatmap = cache.get_beatmap_default(score.beatmap_id).await?; - let content = oppai.get_beatmap(beatmap.beatmap_id).await?; - let r: Result<_> = - Ok((rank, score, BeatmapWithMode(beatmap, mode), content)); - r - } - }) - .collect::>() - .filter_map(|v| future::ready(v.ok())) - .for_each(move |(rank, score, beatmap, content)| { - let channels = channels.clone(); - let d = d.clone(); - let c = c.clone(); - let user = user.clone(); - async move { - let data = d.read().await; - 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, - &content, - &user, - Some(rank), - e, - ) - }) - }) - .await - { - dbg!(e); - } - save_beatmap(&*data, *channel, &beatmap).ok(); - } - } - }) + let event_scores = user + .events + .iter() + .filter_map(|u| u.to_event_rank()) + .filter(|u| u.mode == mode && u.date > last_update) + .map(|ev| CollectedScore::from_event(&*client, &user, ev, user_id, &channels[..])) + .collect::>() + .filter_map(|u| future::ready(u.ok_or_print())) + .collect::>() .await; + let top_scores = scores.into_iter().filter_map(|(rank, score)| { + if score.date > last_update { + Some(CollectedScore::from_top_score( + &user, + score, + mode, + rank, + user_id, + &channels[..], + )) + } else { + None + } + }); + let ctx = Context { data: d, c }; + event_scores + .into_iter() + .chain(top_scores) + .map(|v| v.send_message(&ctx)) + .collect::>() + .try_collect::>() + .await + .ok_or_print(); }); Ok(pp) } @@ -183,3 +179,147 @@ impl Announcer { Ok(scores) } } + +#[derive(Clone)] +struct Context { + data: AppData, + c: Arc, +} + +struct CollectedScore<'a> { + pub user: &'a User, + pub score: Score, + pub mode: Mode, + pub kind: ScoreType, + + pub discord_user: UserId, + pub channels: &'a [ChannelId], +} + +impl<'a> CollectedScore<'a> { + fn from_top_score( + user: &'a User, + score: Score, + mode: Mode, + rank: u8, + discord_user: UserId, + channels: &'a [ChannelId], + ) -> Self { + Self { + user, + score, + mode, + kind: ScoreType::TopRecord(rank), + discord_user, + channels, + } + } + + async fn from_event( + osu: &Osu, + user: &'a User, + event: UserEventRank, + discord_user: UserId, + channels: &'a [ChannelId], + ) -> Result> { + let scores = osu + .scores(event.beatmap_id, |f| { + f.user(UserID::ID(user.id)).mode(event.mode) + }) + .await?; + let score = match scores.into_iter().next() { + Some(v) => v, + None => return Err(Error::msg("cannot get score for map...")), + }; + Ok(Self { + user, + score, + mode: event.mode, + kind: ScoreType::WorldRecord(event.rank), + discord_user, + channels, + }) + } +} + +impl<'a> CollectedScore<'a> { + async fn send_message(self, ctx: &Context) -> Result> { + let (bm, content) = self.get_beatmap(&ctx).await?; + self.channels + .into_iter() + .map(|c| self.send_message_to(*c, ctx, &bm, &content)) + .collect::>() + .try_collect() + .await + } + + async fn get_beatmap( + &self, + ctx: &Context, + ) -> Result<( + BeatmapWithMode, + impl std::ops::Deref, + )> { + let data = ctx.data.read().await; + let cache = data.get::().unwrap(); + let oppai = data.get::().unwrap(); + let beatmap = cache.get_beatmap_default(self.score.beatmap_id).await?; + let content = oppai.get_beatmap(beatmap.beatmap_id).await?; + Ok((BeatmapWithMode(beatmap, self.mode), content)) + } + + async fn send_message_to( + &self, + channel: ChannelId, + ctx: &Context, + bm: &BeatmapWithMode, + content: &BeatmapContent, + ) -> Result { + let m = channel + .send_message(ctx.c.http(), |c| { + c.content(match self.kind { + ScoreType::TopRecord(_) => { + format!("New top record from {}!", self.discord_user.mention()) + } + ScoreType::WorldRecord(_) => { + format!("New best score from {}!", self.discord_user.mention()) + } + }) + .embed(|e| { + let mut b = score_embed(&self.score, &bm, content, self.user); + match self.kind { + ScoreType::TopRecord(rank) => b.top_record(rank), + ScoreType::WorldRecord(rank) => b.world_record(rank), + } + .build(e) + }) + }) + .await?; + save_beatmap(&*ctx.data.read().await, channel, &bm).ok_or_print(); + Ok(m) + } +} + +enum ScoreType { + TopRecord(u8), + WorldRecord(u16), +} + +trait OkPrint { + type Output; + fn ok_or_print(self) -> Option; +} + +impl OkPrint for Result { + type Output = T; + + fn ok_or_print(self) -> Option { + match self { + Ok(v) => Some(v), + Err(e) => { + eprintln!("Error: {:?}", e); + None + } + } + } +} diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 4c39c89..ed1bba0 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -160,144 +160,184 @@ pub fn beatmapset_embed<'a>( })) } -pub(crate) fn score_embed<'a>( - s: &Score, - bm: &BeatmapWithMode, - content: &BeatmapContent, - u: &User, +pub(crate) struct ScoreEmbedBuilder<'a> { + s: &'a Score, + bm: &'a BeatmapWithMode, + content: &'a BeatmapContent, + u: &'a User, top_record: Option, - m: &'a mut CreateEmbed, -) -> &'a mut CreateEmbed { - 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), - Rank::F => format!("{:.2}% {} combo [FAILED]", accuracy, s.max_combo), - v => format!( - "{:.2}% {}x {} miss {} rank", - accuracy, s.max_combo, s.count_miss, v - ), - }; - let pp = s.pp.map(|pp| (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, + world_record: Option, +} + +impl<'a> ScoreEmbedBuilder<'a> { + pub fn top_record(&mut self, rank: u8) -> &mut Self { + self.top_record = Some(rank); + self + } + pub fn world_record(&mut self, rank: u16) -> &mut Self { + self.world_record = Some(rank); + self + } +} + +pub(crate) fn score_embed<'a>( + s: &'a Score, + bm: &'a BeatmapWithMode, + content: &'a BeatmapContent, + u: &'a User, +) -> ScoreEmbedBuilder<'a> { + ScoreEmbedBuilder { + s, + bm, + content, + u, + top_record: None, + world_record: None, + } +} + +impl<'a> ScoreEmbedBuilder<'a> { + pub fn build<'b>(&self, m: &'b mut CreateEmbed) -> &'b mut CreateEmbed { + let mode = self.bm.mode(); + let b = &self.bm.0; + let s = self.s; + let content = self.content; + let u = self.u; + 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), + Rank::F => format!("{:.2}% {} combo [FAILED]", accuracy, s.max_combo), + v => format!( + "{:.2}% {}x {} miss {} rank", + accuracy, s.max_combo, s.count_miss, v + ), + }; + let pp = s.pp.map(|pp| (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| (pp as f64, format!("{:.2}pp [?]", pp))) + }); + let pp = if !s.perfect { + mode.to_oppai_mode() + .and_then(|op| { + content + .get_pp_from(oppai_rs::Combo::FC(0), accuracy as f32, Some(op), s.mods) + .ok() + }) + .filter(|&v| { + pp.as_ref() + .map(|&(origin, _)| origin < v as f64) + .unwrap_or(false) + }) + .and_then(|value| { + pp.as_ref() + .map(|(_, original)| format!("{} ({:.2}pp if FC?)", original, value)) + }) + .or(pp.map(|v| v.1)) + } else { + pp.map(|v| v.1) + }; + let pp_gained = s.pp.map(|full_pp| { + self.top_record + .map(|top| { + let after_pp = u.pp.unwrap(); + let effective_pp = full_pp * (0.95f64).powi(top as i32 - 1); + 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 ) - .ok() - }) - .map(|pp| (pp as f64, format!("{:.2}pp [?]", pp))) - }); - let pp = if !s.perfect { - mode.to_oppai_mode() - .and_then(|op| { - content - .get_pp_from(oppai_rs::Combo::FC(0), accuracy as f32, Some(op), s.mods) - .ok() - }) - .filter(|&v| { - pp.as_ref() - .map(|&(origin, _)| origin < v as f64) - .unwrap_or(false) - }) - .and_then(|value| { - pp.as_ref() - .map(|(_, original)| format!("{} ({:.2}pp if FC?)", original, value)) - }) - .or(pp.map(|v| v.1)) - } 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 - 1); - 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, Some(stars)); - 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, - stars, - b.creator, - score_line, - top_record - )) - .description(format!( - r#"**Beatmap**: {} - {} [{}]**{} ** + }) + .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 = self + .top_record + .map(|v| format!("| #{} top record!", v)) + .unwrap_or("".to_owned()); + let world_record = self + .world_record + .map(|v| format!("| #{} on Global Rankings!", v)) + .unwrap_or("".to_owned()); + 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!( + "{} | {} - {} [{}] {} ({:.2}\\*) by {} | {} {} {}", + u.username, + b.artist, + b.title, + b.difficulty_name, + s.mods, + stars, + b.creator, + score_line, + top_record, + world_record, + )) + .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( - "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, - ) - .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.")); + 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( + "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, + ) + .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.")); + } + m } - m } pub(crate) fn user_embed<'a>( diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 2d8aa99..102846f 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -23,6 +23,7 @@ mod db; pub(crate) mod embeds; mod hook; pub(crate) mod oppai_cache; +mod register_user; mod server_rank; use db::OsuUser; @@ -95,6 +96,7 @@ pub fn setup( catch, mania, save, + forcesave, recent, last, check, @@ -168,14 +170,32 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let user: Option = osu.user(UserID::Auto(user), |f| f).await?; match user { Some(u) => { - OsuSavedUsers::open(&*data).borrow_mut()?.insert( - msg.author.id, - OsuUser { - id: u.id, - last_update: chrono::Utc::now(), - pp: vec![], - }, - ); + let check_beatmap_id = register_user::user_register_beatmap_id(&u); + let check = osu + .user_recent(UserID::ID(u.id), |f| f.mode(Mode::Std).limit(1)) + .await? + .into_iter() + .take(1) + .any(|s| s.beatmap_id == check_beatmap_id); + if !check { + let msg = msg.reply(&ctx, format!("To set your osu username, please make your most recent play be the following map: `/b/{}` in **osu! standard** mode! It does **not** have to be a pass.", check_beatmap_id)); + let beatmap = osu + .beatmaps( + crate::request::BeatmapRequestKind::Beatmap(check_beatmap_id), + |f| f, + ) + .await? + .into_iter() + .next() + .unwrap(); + msg.await? + .edit(&ctx, |f| { + f.embed(|e| beatmap_embed(&beatmap, Mode::Std, Mods::NOMOD, None, e)) + }) + .await?; + return Ok(()); + } + add_user(msg.author.id, u.id, &*data)?; msg.reply( &ctx, MessageBuilder::new() @@ -192,6 +212,55 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult Ok(()) } +#[command] +#[description = "Save the given username as someone's username."] +#[owners_only] +#[usage = "[ping user]/[username or user_id]"] +#[num_args(2)] +pub async fn forcesave(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let data = ctx.data.read().await; + let osu = data.get::().unwrap(); + let target = args.single::()?; + + let user = args.single::()?; + let user: Option = osu.user(UserID::Auto(user), |f| f).await?; + match user { + Some(u) => { + add_user(target, u.id, &*data)?; + msg.reply( + &ctx, + MessageBuilder::new() + .push("user has been set to ") + .push_mono_safe(u.username) + .build(), + ) + .await?; + } + None => { + msg.reply(&ctx, "user not found...").await?; + } + } + Ok(()) +} + +fn add_user(target: serenity::model::id::UserId, user_id: u64, data: &TypeMap) -> Result<()> { + OsuSavedUsers::open(data).borrow_mut()?.insert( + target, + OsuUser { + id: user_id, + last_update: chrono::Utc::now(), + pp: vec![], + }, + ); + OsuUserBests::open(data) + .borrow_mut()? + .iter_mut() + .for_each(|(_, r)| { + r.remove(&target); + }); + Ok(()) +} + struct ModeArg(Mode); impl FromStr for ModeArg { @@ -438,7 +507,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu "{}: here is the play that you requested", msg.author )) - .embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user, None, m)) + .embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user).build(m)) }) .await?; @@ -537,7 +606,7 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul for score in scores.iter() { msg.channel_id .send_message(&ctx, |c| { - c.embed(|m| score_embed(&score, &bm, &content, &user, None, m)) + c.embed(|m| score_embed(&score, &bm, &content, &user).build(m)) }) .await?; } @@ -601,7 +670,11 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult "{}: here is the play that you requested", msg.author )) - .embed(|m| score_embed(&top_play, &beatmap, &content, &user, Some(rank), m)) + .embed(|m| { + score_embed(&top_play, &beatmap, &content, &user) + .top_record(rank) + .build(m) + }) }) .await?; diff --git a/youmubot-osu/src/discord/register_user.rs b/youmubot-osu/src/discord/register_user.rs new file mode 100644 index 0000000..6f655f1 --- /dev/null +++ b/youmubot-osu/src/discord/register_user.rs @@ -0,0 +1,19 @@ +use crate::models::User; + +const BEATMAP_IDS: [u64; 100] = [ + 2469345, 2084862, 2486881, 2330357, 2546607, 1655981, 1626537, 888015, 1062394, 1319547, + 1852572, 1944926, 2129143, 1057509, 2022718, 1097543, 1736329, 1056207, 930249, 1936782, + 1919312, 1570203, 2201460, 1495498, 965549, 2428358, 2118444, 1849433, 820619, 999944, 1571309, + 1055147, 1619555, 338682, 1438917, 954692, 824891, 2026320, 764014, 2237466, 2058788, 1969946, + 1892257, 1473301, 2336704, 774965, 657509, 1031604, 898576, 714001, 1872396, 831705, 1917082, + 978326, 795232, 1814494, 713867, 2077126, 1612329, 1314214, 1849273, 1829925, 1640362, 801158, + 431957, 1054501, 1627148, 816600, 1857519, 1080094, 1642274, 1232440, 1843653, 953586, 2044362, + 1489536, 951053, 1069111, 2154507, 1007699, 1099936, 1077323, 1874119, 909032, 760466, 1911308, + 1820921, 1231520, 954254, 425779, 1586059, 2198684, 1040044, 799913, 994933, 969681, 888016, + 1100327, 1063410, 2078961, +]; + +pub fn user_register_beatmap_id(u: &User) -> u64 { + let now = chrono::Utc::now(); + BEATMAP_IDS[(u.id + (now.timestamp() / 3600) as u64) as usize % BEATMAP_IDS.len()] +} diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index 31df742..129fc0a 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -13,7 +13,7 @@ use std::convert::TryInto; use youmubot_prelude::{ratelimit::Ratelimit, *}; /// The number of requests per minute to the osu! server. -const REQUESTS_PER_MINUTE: usize = 60; +const REQUESTS_PER_MINUTE: usize = 100; /// Client is the client that will perform calls to the osu! api server. pub struct Client { diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 7575f6b..9333035 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::fmt; use std::time::Duration; @@ -11,6 +12,10 @@ pub(crate) mod raw; pub use mods::Mods; use serenity::utils::MessageBuilder; +lazy_static::lazy_static! { + static ref EVENT_RANK_REGEX: Regex = Regex::new(r#"^.+achieved rank #(\d+) on .+\((.+)\)$"#).unwrap(); +} + #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum ApprovalStatus { Loved, @@ -264,6 +269,17 @@ impl Mode { }) } + /// Parse from the display output of the enum itself. + pub fn parse_from_display(s: &str) -> Option { + Some(match s { + "osu!" => Mode::Std, + "osu!taiko" => Mode::Taiko, + "osu!mania" => Mode::Catch, + "osu!catch" => Mode::Mania, + _ => return None, + }) + } + /// Parse from the new site's convention. pub fn parse_from_new_site(s: &str) -> Option { Some(match s { @@ -377,6 +393,30 @@ pub struct UserEvent { pub epic_factor: u8, } +/// Represents a "achieved rank #x on beatmap" event. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UserEventRank { + pub beatmap_id: u64, + pub rank: u16, + pub mode: Mode, + pub date: DateTime, +} + +impl UserEvent { + /// Try to parse the event into a "rank" event. + pub fn to_event_rank(&self) -> Option { + let captures = EVENT_RANK_REGEX.captures(self.display_html.as_str())?; + let rank: u16 = captures.get(1)?.as_str().parse().ok()?; + let mode: Mode = Mode::parse_from_display(captures.get(2)?.as_str())?; + Some(UserEventRank { + beatmap_id: self.beatmap_id?, + date: self.date.clone(), + mode, + rank, + }) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct User { pub id: u64,