From 803d718c7ad34d3780ae6c2911ca0682b2417cc4 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Mon, 4 Nov 2024 18:23:00 +0100 Subject: [PATCH] osu: implement beatmapset and simulate button (#55) * Prepare a message beforehand for display_beatmapset * Implement a Beatmapset button * Always sort top plays by pp * Show leaderboard for top pp per user/mod only * Add score simulation * Store all reaction to be removed later * Properly handle errors * Parse beatmap to get placeholders for modal * Make buttons same color --- youmubot-osu/src/discord/display.rs | 45 +++-- youmubot-osu/src/discord/embeds.rs | 145 ++++++++++++++-- youmubot-osu/src/discord/hook.rs | 18 +- youmubot-osu/src/discord/interaction.rs | 219 ++++++++++++++++++++++-- youmubot-osu/src/discord/mod.rs | 17 +- youmubot-osu/src/discord/server_rank.rs | 44 ++++- youmubot-osu/src/models/mod.rs | 39 +++++ youmubot-osu/src/models/rosu.rs | 2 + youmubot-prelude/src/pagination.rs | 9 + youmubot/src/main.rs | 2 + 10 files changed, 476 insertions(+), 64 deletions(-) diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index 1304e39..a8fa3d3 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -356,17 +356,14 @@ mod beatmapset { const SHOW_ALL_EMOTE: &str = "🗒️"; pub async fn display_beatmapset( - ctx: &Context, + ctx: Context, beatmapset: Vec, mode: Option, mods: Mods, - reply_to: &Message, guild_id: Option, - message: impl AsRef, + target: Message, ) -> Result { - if beatmapset.is_empty() { - return Ok(false); - } + assert!(!beatmapset.is_empty(), "Beatmapset should not be empty"); let p = Paginate { infos: vec![None; beatmapset.len()], @@ -374,15 +371,20 @@ mod beatmapset { mode, mods, guild_id, - message: message.as_ref().to_owned(), + + all_reaction: None, }; let ctx = ctx.clone(); - let reply_to = reply_to.clone(); spawn_future(async move { - pagination::paginate_reply(p, &ctx, &reply_to, std::time::Duration::from_secs(60)) - .await - .pls_ok(); + pagination::paginate_with_first_message( + p, + &ctx, + target, + std::time::Duration::from_secs(60), + ) + .await + .pls_ok(); }); Ok(true) } @@ -392,8 +394,9 @@ mod beatmapset { infos: Vec>, mode: Option, mods: Mods, - message: String, guild_id: Option, + + all_reaction: Option, } impl Paginate { @@ -440,7 +443,7 @@ mod beatmapset { } }; msg.edit(ctx, - EditMessage::new().content(self.message.as_str()).embed( + EditMessage::new().embed( crate::discord::embeds::beatmap_embed( map, self.mode.unwrap_or(map.mode), @@ -456,7 +459,7 @@ mod beatmapset { )) }) ) - .components(vec![beatmap_components(self.guild_id)]), + .components(vec![beatmap_components(map.mode, self.guild_id)]), ) .await?; let env = ctx.data.read().await.get::().unwrap().clone(); @@ -476,8 +479,10 @@ mod beatmapset { ctx: &Context, m: &mut serenity::model::channel::Message, ) -> Result<()> { - m.react(&ctx, SHOW_ALL_EMOTE.parse::().unwrap()) - .await?; + self.all_reaction = Some( + m.react(&ctx, SHOW_ALL_EMOTE.parse::().unwrap()) + .await?, + ); Ok(()) } @@ -499,5 +504,13 @@ mod beatmapset { .await .map(Some) } + + async fn cleanup(&mut self, ctx: &Context, _msg: &mut Message) { + if let Some(r) = self.all_reaction.take() { + if !r.delete_all(&ctx).await.is_ok() { + r.delete(&ctx).await.pls_ok(); + } + } + } } } diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 796a780..0d64f95 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -392,11 +392,6 @@ impl<'a> ScoreEmbedBuilder<'a> { .map(|v| format!(" | #{} on Global Rankings!", v)) .unwrap_or_else(|| "".to_owned()); let diff = b.difficulty.apply_mods(&s.mods, stars); - let creator = if b.difficulty_name.contains("'s") { - "".to_owned() - } else { - format!("by {} ", b.creator) - }; let mod_details = mod_details(&s.mods); let description_fields = [ Some( @@ -430,18 +425,8 @@ impl<'a> ScoreEmbedBuilder<'a> { MessageBuilder::new() .push_safe(&u.username) .push(" | ") - .push_safe(&b.artist) - .push(" - ") - .push(&b.title) - .push(" [") - .push_safe(&b.difficulty_name) - .push("] ") - .push(s.mods.to_string()) - .push(" ") - .push(format!("({:.2}\\*)", stars)) - .push(" ") - .push_safe(creator) - .push("| ") + .push(b.full_title(&s.mods, stars)) + .push(" | ") .push(score_line) .push(top_record) .push(world_record) @@ -479,6 +464,132 @@ impl<'a> ScoreEmbedBuilder<'a> { } } +pub(crate) struct FakeScore<'a> { + pub bm: &'a BeatmapWithMode, + pub content: &'a BeatmapContent, + pub mods: Mods, + + pub count_300: usize, + pub count_100: usize, + pub count_50: usize, + pub count_miss: usize, + + pub count_slider_ends_missed: Option, // lazer only + + pub max_combo: Option, +} + +impl<'a> FakeScore<'a> { + fn is_ss(&self, map_max_combo: usize) -> bool { + self.is_fc(map_max_combo) + && self.count_100 + + self.count_50 + + self.count_miss + + self.count_slider_ends_missed.unwrap_or(0) + == 0 + } + fn is_fc(&self, map_max_combo: usize) -> bool { + match self.max_combo { + None => self.count_miss == 0, + Some(combo) => combo == map_max_combo - self.count_slider_ends_missed.unwrap_or(0), + } + } + fn accuracy(&self) -> f64 { + 100.0 + * (self.count_300 as f64 * 300.0 + + self.count_100 as f64 * 100.0 + + self.count_50 as f64 * 50.0) + / ((self.count_300 + self.count_100 + self.count_50 + self.count_miss) as f64 * 300.0) + } + pub fn embed(self, ctx: &Context) -> Result { + let BeatmapWithMode(b, mode) = self.bm; + let info = self.content.get_info_with(*mode, &self.mods)?; + let max_combo = self.max_combo.unwrap_or( + info.max_combo - self.count_miss - self.count_slider_ends_missed.unwrap_or(0), + ); + let acc = format!("{:.2}%", self.accuracy()); + let score_line: Cow = if self.is_ss(info.max_combo) { + "SS".into() + } else if self.is_fc(info.max_combo) { + format!("{} FC", acc).into() + } else { + format!("{} {}x {} miss", acc, max_combo, self.count_miss).into() + }; + let pp = self.content.get_pp_from( + *mode, + self.max_combo, + Accuracy::ByCount( + self.count_300 as u64, + self.count_100 as u64, + self.count_50 as u64, + self.count_miss as u64, + ), + &self.mods, + )?; + let pp_if_fc: Cow = if self.is_fc(info.max_combo) { + "".into() + } else { + let pp = self.content.get_pp_from( + *mode, + None, + Accuracy::ByCount( + (self.count_300 + self.count_miss) as u64, + self.count_100 as u64, + self.count_50 as u64, + 0, + ), + &self.mods, + )?; + format!(" ({:.2}pp if fc)", pp).into() + }; + + let youmu = ctx.cache.current_user(); + + Ok(CreateEmbed::new() + .author( + CreateEmbedAuthor::new(&youmu.name).icon_url(youmu.static_avatar_url().unwrap()), + ) + .color(0xffb6c1) + .title( + MessageBuilder::new() + .push_safe(&youmu.name) + .push(" | ") + .push(b.full_title(&self.mods, info.stars)) + .push(" | ") + .push(score_line) + .push(" | ") + .push(format!("{:.2}pp [?]", pp)) + .push(pp_if_fc) + .build(), + ) + .thumbnail(b.thumbnail_url()) + .description(format!("**pp gained**: **{:.2}**pp", pp)) + .field( + "Score stats", + format!("**{}** combo | **{}**", max_combo, acc), + true, + ) + .field( + "300s | 100s | 50s | misses", + format!( + "**{}** | **{}** | **{}** | **{}**", + self.count_300, self.count_100, self.count_50, self.count_miss + ), + true, + ) + .field( + "Map stats", + b.difficulty + .apply_mods(&self.mods, info.stars) + .format_info(*mode, &self.mods, b), + false, + ) + .footer(CreateEmbedFooter::new( + "This is a simulated score, with pp calculated by Youmu.", + ))) + } +} + pub(crate) fn user_embed(u: User, ex: UserExtras) -> CreateEmbed { let mut stats = Vec::<(&'static str, String, bool)>::new(); let UserExtras { diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index 6ff2797..5473d2b 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -288,6 +288,7 @@ async fn handle_beatmap<'a, 'b>( mods: Mods, reply_to: &Message, ) -> Result<()> { + let mode = mode.unwrap_or(beatmap.mode); reply_to .channel_id .send_message( @@ -299,13 +300,8 @@ async fn handle_beatmap<'a, 'b>( .push_mono_safe(link) .build(), ) - .embed(beatmap_embed( - beatmap, - mode.unwrap_or(beatmap.mode), - &mods, - info, - )) - .components(vec![beatmap_components(reply_to.guild_id)]) + .embed(beatmap_embed(beatmap, mode, &mods, info)) + .components(vec![beatmap_components(mode, reply_to.guild_id)]) .reference_message(reply_to), ) .await?; @@ -319,14 +315,16 @@ async fn handle_beatmapset<'a, 'b>( mode: Option, reply_to: &Message, ) -> Result<()> { + let reply = reply_to + .reply(ctx, format!("Beatmapset information for `{}`", link)) + .await?; crate::discord::display::display_beatmapset( - ctx, + ctx.clone(), beatmaps, mode, Mods::default(), - reply_to, reply_to.guild_id, - format!("Beatmapset information for `{}`", link), + reply, ) .await .pls_ok(); diff --git a/youmubot-osu/src/discord/interaction.rs b/youmubot-osu/src/discord/interaction.rs index ba6fc9e..ce087a3 100644 --- a/youmubot-osu/src/discord/interaction.rs +++ b/youmubot-osu/src/discord/interaction.rs @@ -1,14 +1,15 @@ -use std::pin::Pin; +use std::{pin::Pin, str::FromStr, time::Duration}; use future::Future; use serenity::all::{ ComponentInteraction, ComponentInteractionDataKind, CreateActionRow, CreateButton, - CreateInteractionResponse, CreateInteractionResponseFollowup, CreateInteractionResponseMessage, - GuildId, Interaction, + CreateInputText, CreateInteractionResponse, CreateInteractionResponseFollowup, + CreateInteractionResponseMessage, CreateQuickModal, GuildId, InputTextStyle, Interaction, + QuickModalResponse, }; use youmubot_prelude::*; -use crate::{Mods, UserHeader}; +use crate::{discord::embeds::FakeScore, mods::UnparsedMods, Mode, Mods, UserHeader}; use super::{ display::ScoreListStyle, @@ -20,6 +21,8 @@ use super::{ pub(super) const BTN_CHECK: &str = "youmubot_osu_btn_check"; pub(super) const BTN_LB: &str = "youmubot_osu_btn_lb"; pub(super) const BTN_LAST: &str = "youmubot_osu_btn_last"; +pub(super) const BTN_LAST_SET: &str = "youmubot_osu_btn_last_set"; +pub(super) const BTN_SIMULATE: &str = "youmubot_osu_btn_simulate"; /// Create an action row for score pages. pub fn score_components(guild_id: Option) -> CreateActionRow { @@ -31,11 +34,15 @@ pub fn score_components(guild_id: Option) -> CreateActionRow { } /// Create an action row for score pages. -pub fn beatmap_components(guild_id: Option) -> CreateActionRow { +pub fn beatmap_components(mode: Mode, guild_id: Option) -> CreateActionRow { let mut btns = vec![check_button()]; if guild_id.is_some() { btns.push(lb_button()); } + btns.push(mapset_button()); + if mode == Mode::Std { + btns.push(simulate_button()); + } CreateActionRow::Buttons(btns) } @@ -120,6 +127,20 @@ pub fn last_button() -> CreateButton { .style(serenity::all::ButtonStyle::Success) } +pub fn mapset_button() -> CreateButton { + CreateButton::new(BTN_LAST_SET) + .label("Set") + .emoji('📚') + .style(serenity::all::ButtonStyle::Success) +} + +pub fn simulate_button() -> CreateButton { + CreateButton::new(BTN_SIMULATE) + .label("What If?") + .emoji('🌈') + .style(serenity::all::ButtonStyle::Success) +} + /// Implements the `last` button on scores and beatmaps. pub fn handle_last_button<'a>( ctx: &'a Context, @@ -130,16 +151,190 @@ pub fn handle_last_button<'a>( Some(comp) => comp, None => return Ok(()), }; + handle_last_req(ctx, comp, false).await + }) +} + +/// Implements the `beatmapset` button on scores and beatmaps. +pub fn handle_last_set_button<'a>( + ctx: &'a Context, + interaction: &'a Interaction, +) -> Pin> + Send + 'a>> { + Box::pin(async move { + let comp = match expect_and_defer_button(ctx, interaction, BTN_LAST_SET).await? { + Some(comp) => comp, + None => return Ok(()), + }; + handle_last_req(ctx, comp, true).await + }) +} + +/// Implements the `simulate` button on beatmaps. +pub fn handle_simulate_button<'a>( + ctx: &'a Context, + interaction: &'a Interaction, +) -> Pin> + Send + 'a>> { + Box::pin(async move { + let comp = match interaction.as_message_component() { + Some(comp) + if comp.data.custom_id == BTN_SIMULATE + && matches!(comp.data.kind, ComponentInteractionDataKind::Button) => + { + comp + } + _ => return Ok(()), + }; + let msg = &*comp.message; let env = ctx.data.read().await.get::().unwrap().clone(); - let (bm, mods_def) = super::load_beatmap(&env, comp.channel_id, Some(msg)) + let (bm, _) = super::load_beatmap(&env, comp.channel_id, Some(msg)) .await .unwrap(); - let BeatmapWithMode(b, m) = &bm; + let b = &bm.0; + let mode = bm.1; + let content = env.oppai.get_beatmap(b.beatmap_id).await?; + let info = content.get_info_with(mode, Mods::NOMOD)?; - let mods = mods_def.unwrap_or_default(); + assert!(mode == Mode::Std); + + fn mk_input(title: &str, placeholder: impl Into) -> CreateInputText { + CreateInputText::new(InputTextStyle::Short, title, "") + .placeholder(placeholder) + .required(false) + } + + let Some(query) = comp + .quick_modal( + &ctx, + CreateQuickModal::new(format!( + "Simulate Score on beatmap `{}`", + b.short_link(None, Mods::NOMOD) + )) + .timeout(Duration::from_secs(300)) + .field(mk_input("Mods", "NM")) + .field(mk_input("Max Combo", info.max_combo.to_string())) + .field(mk_input("100s", "0")) + .field(mk_input("50s", "0")) + .field(mk_input("Misses", "0")), + // .short_field("Slider Ends Missed (Lazer Only)"), // too long LMAO + ) + .await? + else { + return Ok(()); + }; + + query.interaction.defer(&ctx).await?; + + if let Err(err) = handle_simluate_query(ctx, &env, &query, bm).await { + query + .interaction + .create_followup( + ctx, + CreateInteractionResponseFollowup::new() + .content(format!("Cannot simulate score: {}", err)) + .ephemeral(true), + ) + .await + .pls_ok(); + } + + Ok(()) + }) +} + +async fn handle_simluate_query( + ctx: &Context, + env: &OsuEnv, + query: &QuickModalResponse, + bm: BeatmapWithMode, +) -> Result<()> { + let b = &bm.0; + let mode = bm.1; + let content = env.oppai.get_beatmap(b.beatmap_id).await?; + + let score = { + let inputs = &query.inputs; + let (mods, max_combo, c100, c50, cmiss, csliderends) = ( + &inputs[0], &inputs[1], &inputs[2], &inputs[3], &inputs[4], "", + ); + let mods = UnparsedMods::from_str(mods) + .map_err(|v| Error::msg(v))? + .to_mods(mode)?; + let info = content.get_info_with(mode, &mods)?; + let max_combo = max_combo.parse::().ok(); + let c100 = c100.parse::().unwrap_or(0); + let c50 = c50.parse::().unwrap_or(0); + let cmiss = cmiss.parse::().unwrap_or(0); + let c300 = info.objects - c100 - c50 - cmiss; + let csliderends = csliderends.parse::().ok(); + FakeScore { + bm: &bm, + content: &content, + mods, + count_300: c300, + count_100: c100, + count_50: c50, + count_miss: cmiss, + count_slider_ends_missed: csliderends, + max_combo, + } + }; + + query + .interaction + .create_followup( + &ctx, + CreateInteractionResponseFollowup::new() + .content(format!( + "Simulated score for `{}`", + b.short_link(None, Mods::NOMOD) + )) + .add_embed(score.embed(ctx)?) + .components(vec![score_components(query.interaction.guild_id)]), + ) + .await?; + + Ok(()) +} + +async fn handle_last_req( + ctx: &Context, + comp: &ComponentInteraction, + is_beatmapset_req: bool, +) -> Result<()> { + let msg = &*comp.message; + + let env = ctx.data.read().await.get::().unwrap().clone(); + + let (bm, mods_def) = super::load_beatmap(&env, comp.channel_id, Some(msg)) + .await + .unwrap(); + let BeatmapWithMode(b, m) = &bm; + + let mods = mods_def.unwrap_or_default(); + + if is_beatmapset_req { + let beatmapset = env.beatmaps.get_beatmapset(bm.0.beatmapset_id).await?; + let reply = comp + .create_followup( + &ctx, + CreateInteractionResponseFollowup::new() + .content(format!("Beatmapset of `{}`", bm.short_link(&mods))), + ) + .await?; + super::display::display_beatmapset( + ctx.clone(), + beatmapset, + None, + mods, + comp.guild_id, + reply, + ) + .await?; + return Ok(()); + } else { let info = env .oppai .get_beatmap(b.beatmap_id) @@ -153,14 +348,14 @@ pub fn handle_last_button<'a>( bm.short_link(&mods) )) .embed(beatmap_embed(b, *m, &mods, info)) - .components(vec![beatmap_components(comp.guild_id)]), + .components(vec![beatmap_components(bm.1, comp.guild_id)]), ) .await?; // Save the beatmap... super::cache::save_beatmap(&env, msg.channel_id, &bm).await?; + } - Ok(()) - }) + Ok(()) } /// Creates a new check button. @@ -191,7 +386,7 @@ pub fn handle_lb_button<'a>( let order = OrderBy::default(); let guild = comp.guild_id.expect("Guild-only command"); - let scores = get_leaderboard(ctx, &env, &bm, order, guild).await?; + let scores = get_leaderboard(ctx, &env, &bm, false, order, guild).await?; if scores.is_empty() { comp.create_followup( diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 7af36f7..8c9f77c 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -313,7 +313,7 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult &ctx, EditMessage::new() .embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, info)) - .components(vec![beatmap_components(msg.guild_id)]), + .components(vec![beatmap_components(mode, msg.guild_id)]), ) .await?; let reaction = reply.react(&ctx, '👌').await?; @@ -810,14 +810,16 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult }; if beatmapset { let beatmapset = env.beatmaps.get_beatmapset(bm.0.beatmapset_id).await?; + let reply = msg + .reply(&ctx, "Here is the beatmapset you requested!") + .await?; display::display_beatmapset( - ctx, + ctx.clone(), beatmapset, None, mods, - msg, msg.guild_id, - "Here is the beatmapset you requested!", + reply, ) .await?; return Ok(()); @@ -833,7 +835,7 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult CreateMessage::new() .content("Here is the beatmap you requested!") .embed(beatmap_embed(&bm.0, bm.1, &mods, info)) - .components(vec![beatmap_components(msg.guild_id)]) + .components(vec![beatmap_components(bm.1, msg.guild_id)]) .reference_message(msg), ) .await?; @@ -941,10 +943,13 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult } = ListingArgs::parse(&env, msg, &mut args, ScoreListStyle::default()).await?; let osu_client = &env.client; - let plays = osu_client + let mut plays = osu_client .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) .await?; + plays.sort_unstable_by(|a, b| b.pp.partial_cmp(&a.pp).unwrap()); + let plays = plays; + match nth { Nth::Nth(nth) => { let Some(play) = plays.get(nth as usize) else { diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 3b07321..850906e 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -1,4 +1,10 @@ -use std::{borrow::Cow, cmp::Ordering, collections::HashMap, str::FromStr, sync::Arc}; +use std::{ + borrow::Cow, + cmp::Ordering, + collections::{BTreeMap, HashMap}, + str::FromStr, + sync::Arc, +}; use chrono::DateTime; use pagination::paginate_with_first_message; @@ -316,13 +322,26 @@ impl FromStr for OrderBy { } } +struct AllLb; +impl FromStr for AllLb { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "--all" => Ok(AllLb), + _ => Err(Error::msg("unknown value")), + } + } +} + #[command("leaderboard")] #[aliases("lb", "bmranks", "br", "cc", "updatelb")] -#[usage = "[--score to sort by score, default to sort by pp] / [--table to show a table, --grid to show score by score] / [mods to filter]"] +#[usage = "[--all to show all scores, not just ranked] / [--score to sort by score, default to sort by pp] / [--table to show a table, --grid to show score by score] / [mods to filter]"] #[description = "See the server's ranks on the last seen beatmap"] #[max_args(2)] #[only_in(guilds)] pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let show_all = args.single::().is_ok(); let order = args.single::().unwrap_or_default(); let style = args.single::().unwrap_or_default(); let guild = msg.guild_id.expect("Guild-only command"); @@ -339,7 +358,7 @@ pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> C let scores = { let reaction = msg.react(ctx, '⌛').await?; - let s = get_leaderboard(ctx, &env, &bm, order, guild).await?; + let s = get_leaderboard(ctx, &env, &bm, show_all, order, guild).await?; reaction.delete(&ctx).await?; s }; @@ -399,6 +418,7 @@ pub async fn get_leaderboard( ctx: &Context, env: &OsuEnv, bm: &BeatmapWithMode, + show_unranked: bool, order: OrderBy, guild: GuildId, ) -> Result> { @@ -462,6 +482,24 @@ pub async fn get_leaderboard( }) .collect::>(); + if !show_unranked { + let mut mp = BTreeMap::>::new(); + for r in scores.drain(0..scores.len()) { + let rs = mp.entry(r.score.user_id).or_default(); + match rs.iter_mut().find(|t| t.score.mods == r.score.mods) { + Some(t) => { + if t.pp < r.pp { + *t = r; + } + } + None => { + rs.push(r); + } + } + } + scores = mp.into_values().flatten().collect(); + } + match order { OrderBy::PP => scores.sort_by(|a, b| { (b.official, b.pp) diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index 48fedb7..877a85d 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use mods::Stats; use rosu_v2::prelude::GameModIntermode; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::fmt; use std::time::Duration; @@ -439,6 +440,39 @@ impl Beatmap { pub fn thumbnail_url(&self) -> String { format!("https://b.ppy.sh/thumb/{}l.jpg", self.beatmapset_id) } + + /// Beatmap title and difficulty name + pub fn map_title(&self) -> String { + MessageBuilder::new() + .push_safe(&self.artist) + .push(" - ") + .push_safe(&self.title) + .push(" [") + .push_safe(&self.difficulty_name) + .push("]") + .build() + } + + /// Full title with creator name if needed + pub fn full_title(&self, mods: &Mods, stars: f64) -> String { + let creator: Cow = if self.difficulty_name.contains("'s") { + "".into() + } else { + format!(" by {}", self.creator).into() + }; + + MessageBuilder::new() + .push_safe(&self.artist) + .push(" - ") + .push_safe(&self.title) + .push(" [") + .push_safe(&self.difficulty_name) + .push("] ") + .push(mods.to_string()) + .push(format!(" ({:.2}\\*)", stars)) + .push_safe(creator) + .build() + } } #[derive(Clone, Debug)] @@ -618,6 +652,11 @@ pub struct Score { pub max_combo: u64, pub perfect: bool, + /// Whether score would get pp + pub ranked: Option, + /// Whether score would be stored + pub preserved: Option, + // Some APIv2 stats pub server_accuracy: f64, pub global_rank: Option, diff --git a/youmubot-osu/src/models/rosu.rs b/youmubot-osu/src/models/rosu.rs index 3f2f935..9d3ca68 100644 --- a/youmubot-osu/src/models/rosu.rs +++ b/youmubot-osu/src/models/rosu.rs @@ -135,6 +135,8 @@ impl From for Score { count_geki: legacy_stats.count_geki as u64, max_combo: s.max_combo as u64, perfect: s.is_perfect_combo, + ranked: s.ranked, + preserved: s.preserve, lazer_build_id: s.build_id, } } diff --git a/youmubot-prelude/src/pagination.rs b/youmubot-prelude/src/pagination.rs index ec6cd3f..f1817b5 100644 --- a/youmubot-prelude/src/pagination.rs +++ b/youmubot-prelude/src/pagination.rs @@ -27,6 +27,9 @@ pub trait Paginate: Send + Sized { Ok(()) } + /// Cleans up after the pagination has timed out. + async fn cleanup(&mut self, _ctx: &Context, _m: &mut Message) -> () {} + /// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling /// before handing the functionality over. /// @@ -116,6 +119,10 @@ impl Paginate for WithPageCount { fn is_empty(&self) -> Option { Some(self.page_count == 0) } + + async fn cleanup(&mut self, ctx: &Context, msg: &mut Message) { + self.inner.cleanup(ctx, msg).await; + } } #[async_trait::async_trait] @@ -240,6 +247,8 @@ pub async fn paginate_with_first_message( } }; + pager.cleanup(ctx, &mut message).await; + for reaction in reactions { if reaction.delete_all(&ctx).await.pls_ok().is_none() { // probably no permission to delete all reactions, fall back to delete my own. diff --git a/youmubot/src/main.rs b/youmubot/src/main.rs index 85c6be2..b79d581 100644 --- a/youmubot/src/main.rs +++ b/youmubot/src/main.rs @@ -166,6 +166,8 @@ async fn main() { handler.push_hook(youmubot_osu::discord::score_hook); handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_check_button); handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_last_button); + handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_last_set_button); + handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_simulate_button); handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_lb_button); } #[cfg(feature = "codeforces")]