diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index e27da53..f1262fa 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -341,7 +341,7 @@ impl<'a> CollectedScore<'a> { } .build() }) - .components(vec![score_components()]), + .components(vec![score_components(Some(guild))]), ) .await?; diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index d016b2d..78dee39 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -2,7 +2,7 @@ pub use beatmapset::display_beatmapset; pub use scores::ScoreListStyle; mod scores { - use serenity::model::channel::Message; + use serenity::{all::GuildId, model::channel::Message}; use youmubot_prelude::*; @@ -39,17 +39,21 @@ mod scores { scores: Vec, mode: Mode, ctx: &'a Context, + guild_id: Option, m: Message, ) -> Result<()> { match self { ScoreListStyle::Table => table::display_scores_table(scores, mode, ctx, m).await, - ScoreListStyle::Grid => grid::display_scores_grid(scores, mode, ctx, m).await, + ScoreListStyle::Grid => { + grid::display_scores_grid(scores, mode, ctx, guild_id, m).await + } } } } mod grid { use pagination::paginate_with_first_message; + use serenity::all::GuildId; use serenity::builder::EditMessage; use serenity::model::channel::Message; @@ -63,6 +67,7 @@ mod scores { scores: Vec, mode: Mode, ctx: &'a Context, + guild_id: Option, mut on: Message, ) -> Result<()> { if scores.is_empty() { @@ -72,7 +77,11 @@ mod scores { } paginate_with_first_message( - Paginate { scores, mode }, + Paginate { + scores, + guild_id, + mode, + }, ctx, on, std::time::Duration::from_secs(60), @@ -83,6 +92,7 @@ mod scores { pub struct Paginate { scores: Vec, + guild_id: Option, mode: Mode, } @@ -112,7 +122,7 @@ mod scores { .footer(format!("Page {}/{}", page + 1, self.scores.len())) .build() }) - .components(vec![score_components()]), + .components(vec![score_components(self.guild_id)]), ) .await?; save_beatmap(&env, msg.channel_id, &bm).await?; @@ -331,10 +341,9 @@ mod scores { mod beatmapset { use serenity::{ - all::Reaction, + all::{GuildId, Reaction}, builder::{CreateEmbedFooter, EditMessage}, - model::channel::Message, - model::channel::ReactionType, + model::channel::{Message, ReactionType}, }; use youmubot_prelude::*; @@ -353,6 +362,7 @@ mod beatmapset { mode: Option, mods: Option, reply_to: &Message, + guild_id: Option, message: impl AsRef, ) -> Result { let mods = mods.unwrap_or(Mods::NOMOD); @@ -366,6 +376,7 @@ mod beatmapset { maps: beatmapset, mode, mods, + guild_id, message: message.as_ref().to_owned(), }; @@ -385,6 +396,7 @@ mod beatmapset { mode: Option, mods: Mods, message: String, + guild_id: Option, } impl Paginate { @@ -447,7 +459,7 @@ mod beatmapset { )) }) ) - .components(vec![beatmap_components()]), + .components(vec![beatmap_components(self.guild_id)]), ) .await?; let env = ctx.data.read().await.get::().unwrap().clone(); diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index f6e775d..2efe81f 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -85,7 +85,7 @@ pub fn score_hook<'a>( ) }) .embed(score_embed(&s, &b, &c, h).build()) - .components(vec![score_components()]), + .components(vec![score_components(msg.guild_id)]), ) .await .pls_ok(); @@ -303,7 +303,7 @@ async fn handle_beatmap<'a, 'b>( mods, info, )) - .components(vec![beatmap_components()]) + .components(vec![beatmap_components(reply_to.guild_id)]) .reference_message(reply_to), ) .await?; @@ -323,6 +323,7 @@ async fn handle_beatmapset<'a, 'b>( mode, None, reply_to, + reply_to.guild_id, format!("Beatmapset information for `{}`", link), ) .await diff --git a/youmubot-osu/src/discord/interaction.rs b/youmubot-osu/src/discord/interaction.rs index 3305395..ff10bb6 100644 --- a/youmubot-osu/src/discord/interaction.rs +++ b/youmubot-osu/src/discord/interaction.rs @@ -2,34 +2,48 @@ use std::pin::Pin; use future::Future; use serenity::all::{ - ComponentInteractionDataKind, CreateActionRow, CreateButton, CreateInteractionResponseMessage, - Interaction, + ComponentInteractionDataKind, CreateActionRow, CreateButton, CreateInteractionResponse, + CreateInteractionResponseFollowup, CreateInteractionResponseMessage, GuildId, Interaction, }; use youmubot_prelude::*; use crate::Mods; -use super::{display::ScoreListStyle, embeds::beatmap_embed, BeatmapWithMode, OsuEnv}; +use super::{ + display::ScoreListStyle, + embeds::beatmap_embed, + server_rank::{display_rankings_table, get_leaderboard, OrderBy}, + BeatmapWithMode, OsuEnv, +}; pub(super) const BTN_CHECK: &'static str = "youmubot_osu_btn_check"; +pub(super) const BTN_LB: &'static str = "youmubot_osu_btn_lb"; pub(super) const BTN_LAST: &'static str = "youmubot_osu_btn_last"; /// Create an action row for score pages. -pub fn score_components() -> CreateActionRow { - CreateActionRow::Buttons(vec![check_button(), last_button()]) +pub fn score_components(guild_id: Option) -> CreateActionRow { + let mut btns = vec![check_button(), last_button()]; + if guild_id.is_some() { + btns.insert(1, lb_button()); + } + CreateActionRow::Buttons(btns) } /// Create an action row for score pages. -pub fn beatmap_components() -> CreateActionRow { - CreateActionRow::Buttons(vec![check_button()]) +pub fn beatmap_components(guild_id: Option) -> CreateActionRow { + let mut btns = vec![check_button()]; + if guild_id.is_some() { + btns.push(lb_button()); + } + CreateActionRow::Buttons(btns) } /// Creates a new check button. pub fn check_button() -> CreateButton { CreateButton::new(BTN_CHECK) - .label("Check your score") + .label("Check") .emoji('🔎') - .style(serenity::all::ButtonStyle::Secondary) + .style(serenity::all::ButtonStyle::Success) } /// Implements the `check` button on scores and beatmaps. @@ -72,7 +86,7 @@ pub fn handle_check_button<'a>( comp.get_response(&ctx).await? }; ScoreListStyle::Grid - .display_scores(scores, bm.1, ctx, reply) + .display_scores(scores, bm.1, ctx, comp.guild_id, reply) .await?; Ok(()) @@ -82,9 +96,9 @@ pub fn handle_check_button<'a>( /// Creates a new check button. pub fn last_button() -> CreateButton { CreateButton::new(BTN_LAST) - .label("View Beatmap") + .label("Map") .emoji('🎼') - .style(serenity::all::ButtonStyle::Secondary) + .style(serenity::all::ButtonStyle::Success) } /// Implements the `last` button on scores and beatmaps. @@ -123,7 +137,7 @@ pub fn handle_last_button<'a>( CreateInteractionResponseMessage::new() .content("Here is the beatmap you requested!") .embed(beatmap_embed(&b, m, mods, info)) - .components(vec![beatmap_components()]), + .components(vec![beatmap_components(comp.guild_id)]), ), ) .await?; @@ -133,3 +147,67 @@ pub fn handle_last_button<'a>( Ok(()) }) } + +/// Creates a new check button. +pub fn lb_button() -> CreateButton { + CreateButton::new(BTN_LB) + .label("Ranks") + .emoji('👑') + .style(serenity::all::ButtonStyle::Success) +} + +/// Implements the `lb` button on scores and beatmaps. +pub fn handle_lb_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_LB + && matches!(comp.data.kind, ComponentInteractionDataKind::Button) => + { + comp + } + _ => return Ok(()), + }; + let msg = &*comp.message; + + let env = ctx.data.read().await.get::().unwrap().clone(); + + let (bm, _) = super::load_beatmap(&env, comp.channel_id, Some(msg)) + .await + .unwrap(); + let order = OrderBy::default(); + let guild = comp.guild_id.expect("Guild-only command"); + + comp.create_response( + &ctx, + CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new()), + ) + .await?; + let scores = get_leaderboard(&ctx, &env, &bm, order, guild).await?; + + if scores.is_empty() { + comp.create_followup( + &ctx, + CreateInteractionResponseFollowup::new() + .content("No scores have been recorded for this beatmap."), + ) + .await?; + return Ok(()); + } + + let reply = comp + .create_followup( + &ctx, + CreateInteractionResponseFollowup::new().content(format!( + "⌛ Loading top scores on beatmap `{}`...", + bm.short_link(Mods::NOMOD) + )), + ) + .await?; + display_rankings_table(&ctx, reply, scores, &bm, order).await?; + Ok(()) + }) +} diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index d4f4475..c18dcc3 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -296,7 +296,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()]), + .components(vec![beatmap_components(msg.guild_id)]), ) .await?; let reaction = reply.react(&ctx, '👌').await?; @@ -520,7 +520,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu CreateMessage::new() .content("Here is the play that you requested".to_string()) .embed(score_embed(&recent_play, &beatmap_mode, &content, &user).build()) - .components(vec![score_components()]) + .components(vec![score_components(msg.guild_id)]) .reference_message(msg), ) .await?; @@ -538,7 +538,9 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu format!("Here are the recent plays by `{}`!", user.username), ) .await?; - style.display_scores(plays, mode, ctx, reply).await?; + style + .display_scores(plays, mode, ctx, reply.guild_id, reply) + .await?; } } Ok(()) @@ -623,6 +625,7 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult None, Some(mods), msg, + msg.guild_id, "Here is the beatmapset you requested!", ) .await?; @@ -639,7 +642,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()]) + .components(vec![beatmap_components(msg.guild_id)]) .reference_message(msg), ) .await?; @@ -696,7 +699,9 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul ), ) .await?; - style.display_scores(scores, mode, ctx, reply).await?; + style + .display_scores(scores, mode, ctx, msg.guild_id, reply) + .await?; Ok(()) } @@ -779,7 +784,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .top_record(rank) .build(), ) - .components(vec![score_components()]) + .components(vec![score_components(msg.guild_id)]) }) .await?; @@ -793,7 +798,9 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let reply = msg .reply(&ctx, format!("Here are the top plays by `{}`!", user_id)) .await?; - style.display_scores(plays, mode, ctx, reply).await?; + style + .display_scores(plays, mode, ctx, msg.guild_id, reply) + .await?; } } Ok(()) diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 16c2517..350a03e 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -1,6 +1,8 @@ use std::{collections::HashMap, str::FromStr, sync::Arc}; +use pagination::paginate_with_first_message; use serenity::{ + all::GuildId, builder::EditMessage, framework::standard::{macros::command, Args, CommandResult}, model::channel::Message, @@ -15,9 +17,10 @@ use youmubot_prelude::{ }; use crate::{ - discord::{display::ScoreListStyle, oppai_cache::Accuracy}, + discord::{display::ScoreListStyle, oppai_cache::Accuracy, BeatmapWithMode}, models::{Mode, Mods}, request::UserID, + Score, }; use super::{ModeArg, OsuEnv}; @@ -187,7 +190,7 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OrderBy { +pub enum OrderBy { PP, Score, } @@ -219,141 +222,170 @@ impl FromStr for OrderBy { pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let order = args.single::().unwrap_or_default(); let style = args.single::().unwrap_or_default(); - - let env = ctx.data.read().await.get::().unwrap().clone(); - - let (bm, _) = - match super::load_beatmap(&env, msg.channel_id, msg.referenced_message.as_ref()).await { - Some((bm, mods_def)) => { - let mods = args.find::().ok().or(mods_def).unwrap_or(Mods::NOMOD); - (bm, mods) - } - None => { - msg.reply(&ctx, "No beatmap queried on this channel.") - .await?; - return Ok(()); - } - }; - - let osu_client = env.client.clone(); - - // Get oppai map. - let mode = bm.1; - let oppai = env.oppai; - let oppai_map = oppai.get_beatmap(bm.0.beatmap_id).await?; - let guild = msg.guild_id.expect("Guild-only command"); - let scores = { - const NO_SCORES: &str = "No scores have been recorded for this beatmap."; - // Signal that we are running. - let running_reaction = msg.react(&ctx, '⌛').await?; - - let osu_users = env - .saved_users - .all() - .await? - .into_iter() - .map(|v| (v.user_id, v)) - .collect::>(); - let mut scores = env - .prelude - .members - .query_members(&ctx, guild) - .await? - .iter() - .filter_map(|m| osu_users.get(&m.user.id).map(|ou| (m.distinct(), ou.id))) - .map(|(mem, osu_id)| { - osu_client - .scores(bm.0.beatmap_id, move |f| { - f.user(UserID::ID(osu_id)).mode(bm.1) - }) - .map(|r| Some((mem, r.ok()?))) - }) - .collect::>() - .filter_map(future::ready) - .collect::>() - .await - .into_iter() - .flat_map(|(mem, scores)| { - let mem = Arc::new(mem); - scores - .into_iter() - .filter_map(|score| { - let pp = score.pp.map(|v| (true, v)).or_else(|| { - oppai_map - .get_pp_from( - mode, - Some(score.max_combo as usize), - Accuracy::ByCount( - score.count_300, - score.count_100, - score.count_50, - score.count_miss, - ), - score.mods, - ) - .ok() - .map(|v| (false, v)) - })?; - Some((pp, mem.clone(), score)) - }) - .collect::>() - }) - .collect::>(); - - running_reaction.delete(&ctx).await?; - - if scores.is_empty() { - msg.reply(&ctx, NO_SCORES).await?; + let env = ctx.data.read().await.get::().unwrap().clone(); + let bm = match super::load_beatmap(&env, msg.channel_id, msg.referenced_message.as_ref()).await + { + Some((bm, _)) => bm, + None => { + msg.reply(&ctx, "No beatmap queried on this channel.") + .await?; return Ok(()); } - match order { - OrderBy::PP => scores.sort_by(|(a, _, _), (b, _, _)| { - b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal) - }), - OrderBy::Score => { - scores.sort_by(|(_, _, a), (_, _, b)| b.normalized_score.cmp(&a.normalized_score)) - } - }; - scores + }; + + let scores = { + let reaction = msg.react(ctx, '⌛').await?; + let s = get_leaderboard(&ctx, &env, &bm, order, guild).await?; + reaction.delete(&ctx).await?; + s }; if scores.is_empty() { - msg.reply( - &ctx, - "No scores have been recorded for this beatmap. Run `osu check` to scan for yours!", - ) - .await?; + msg.reply(&ctx, "No scores have been recorded for this beatmap.") + .await?; return Ok(()); } - if let ScoreListStyle::Grid = style { - let reply = msg - .reply( - &ctx, - format!( - "Here are the top scores on beatmap `{}` of this server!", - bm.short_link(Mods::NOMOD) - ), - ) - .await?; - style - .display_scores( - scores.into_iter().map(|(_, _, a)| a).collect(), - mode, - ctx, - reply, - ) - .await?; - return Ok(()); + match style { + ScoreListStyle::Table => { + let reply = msg + .reply( + &ctx, + format!( + "⌛ Loading top scores on beatmap `{}`...", + bm.short_link(Mods::NOMOD) + ), + ) + .await?; + display_rankings_table(&ctx, reply, scores, &bm, order).await?; + } + ScoreListStyle::Grid => { + let reply = msg + .reply( + &ctx, + format!( + "Here are the top scores on beatmap `{}` of this server!", + bm.short_link(Mods::NOMOD) + ), + ) + .await?; + style + .display_scores( + scores.into_iter().map(|s| s.score).collect(), + bm.1, + ctx, + Some(guild), + reply, + ) + .await?; + } } - let has_lazer_score = scores.iter().any(|(_, _, v)| v.score.is_none()); + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct Ranking { + pub pp: f64, // calculated pp or score pp + pub official: bool, // official = pp is from bancho + pub member: Arc, + pub score: Score, +} + +pub async fn get_leaderboard( + ctx: &Context, + env: &OsuEnv, + bm: &BeatmapWithMode, + order: OrderBy, + guild: GuildId, +) -> Result> { + let BeatmapWithMode(beatmap, mode) = bm; + let oppai_map = env.oppai.get_beatmap(beatmap.beatmap_id).await?; + let osu_users = env + .saved_users + .all() + .await? + .into_iter() + .map(|v| (v.user_id, v)) + .collect::>(); + let mut scores = env + .prelude + .members + .query_members(&ctx, guild) + .await? + .iter() + .filter_map(|m| osu_users.get(&m.user.id).map(|ou| (m.distinct(), ou.id))) + .map(|(mem, osu_id)| { + env.client + .scores(bm.0.beatmap_id, move |f| { + f.user(UserID::ID(osu_id)).mode(bm.1) + }) + .map(|r| Some((mem, r.ok()?))) + }) + .collect::>() + .filter_map(future::ready) + .collect::>() + .await + .into_iter() + .flat_map(|(mem, scores)| { + let mem = Arc::new(mem); + scores + .into_iter() + .filter_map(|score| { + let pp = score.pp.map(|v| (true, v)).or_else(|| { + oppai_map + .get_pp_from( + *mode, + Some(score.max_combo as usize), + Accuracy::ByCount( + score.count_300, + score.count_100, + score.count_50, + score.count_miss, + ), + score.mods, + ) + .ok() + .map(|v| (false, v)) + })?; + Some(Ranking { + pp: pp.1, + official: pp.0, + member: mem.clone(), + score, + }) + }) + .collect::>() + }) + .collect::>(); + + match order { + OrderBy::PP => scores.sort_by(|a, b| { + (b.official, b.pp) + .partial_cmp(&(a.official, a.pp)) + .unwrap_or(std::cmp::Ordering::Equal) + }), + OrderBy::Score => { + scores.sort_by(|a, b| b.score.normalized_score.cmp(&a.score.normalized_score)) + } + }; + Ok(scores) +} + +pub async fn display_rankings_table( + ctx: &Context, + to: Message, + scores: Vec, + bm: &BeatmapWithMode, + order: OrderBy, +) -> Result<()> { + let has_lazer_score = scores.iter().any(|v| v.score.score.is_none()); const ITEMS_PER_PAGE: usize = 5; let total_len = scores.len(); let total_pages = (total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; - paginate_reply( + paginate_with_first_message( paginate_from_fn(move |page: u8, ctx: &Context, m: &mut Message| { let start = (page as usize) * ITEMS_PER_PAGE; let end = (start + ITEMS_PER_PAGE).min(scores.len()); @@ -372,29 +404,39 @@ pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> C let score_arr = scores .iter() .enumerate() - .map(|(id, ((official, pp), member, score))| { - [ - format!("{}", 1 + id + start), - match order { - OrderBy::PP => { - format!("{:.2}{}", pp, if *official { "" } else { "[?]" }) - } - OrderBy::Score => { - crate::discord::embeds::grouped_number(if has_lazer_score { - score.normalized_score as u64 - } else { - score.score.unwrap() - }) - } + .map( + |( + id, + Ranking { + pp, + official, + member, + score, }, - score.mods.to_string(), - score.rank.to_string(), - format!("{:.2}%", score.accuracy(bm.1)), - format!("{}x", score.max_combo), - format!("{}", score.count_miss), - member.to_string(), - ] - }) + )| { + [ + format!("{}", 1 + id + start), + match order { + OrderBy::PP => { + format!("{:.2}{}", pp, if *official { "" } else { "[?]" }) + } + OrderBy::Score => { + crate::discord::embeds::grouped_number(if has_lazer_score { + score.normalized_score as u64 + } else { + score.score.unwrap() + }) + } + }, + score.mods.to_string(), + score.rank.to_string(), + format!("{:.2}%", score.accuracy(bm.1)), + format!("{}x", score.max_combo), + format!("{}", score.count_miss), + member.to_string(), + ] + }, + ) .collect::>(); let score_table = match order { @@ -425,10 +467,9 @@ pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> C }) .with_page_count(total_pages), ctx, - msg, + to, std::time::Duration::from_secs(60), ) .await?; - Ok(()) } diff --git a/youmubot/src/main.rs b/youmubot/src/main.rs index c4143dd..921a1ff 100644 --- a/youmubot/src/main.rs +++ b/youmubot/src/main.rs @@ -168,7 +168,8 @@ async fn main() { handler.push_hook(youmubot_osu::discord::dot_osu_hook); 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_button); + handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_lb_button); } #[cfg(feature = "codeforces")] handler.push_hook(youmubot_cf::InfoHook);