From 2feb91ac00dbacbf23755b77f17f7988dbe272ab Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Sat, 19 Jun 2021 22:54:08 +0900 Subject: [PATCH] Misc improvements to osu! (#15) * Use len() as hint for pagination * Implement len for beatmapset paging * More tweaks for pagination * Move table rendering to display mod * Use grid throughout the commands * No more double user update * Sort by PP by default * Filter check by mod * Filter lb by mod * Improve 1-page cases --- youmubot-osu/src/discord/display.rs | 346 ++++++++++++++++++++++++ youmubot-osu/src/discord/embeds.rs | 14 +- youmubot-osu/src/discord/mod.rs | 253 ++--------------- youmubot-osu/src/discord/server_rank.rs | 99 +++---- youmubot-prelude/src/pagination.rs | 67 ++++- 5 files changed, 482 insertions(+), 297 deletions(-) diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index cf02cec..4f6d882 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -1,4 +1,346 @@ pub use beatmapset::display_beatmapset; +pub use scores::ScoreListStyle; + +mod scores { + use crate::models::{Mode, Score}; + use serenity::{framework::standard::CommandResult, model::channel::Message}; + use youmubot_prelude::*; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + /// The style for the scores list to be displayed. + pub enum ScoreListStyle { + Table, + Grid, + } + + impl Default for ScoreListStyle { + fn default() -> Self { + Self::Table + } + } + + impl std::str::FromStr for ScoreListStyle { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "--table" => Ok(Self::Table), + "--grid" => Ok(Self::Grid), + _ => Err(Error::msg("unknown value")), + } + } + } + + impl ScoreListStyle { + pub async fn display_scores<'a>( + self, + scores: Vec, + mode: Mode, + ctx: &'a Context, + m: &'a Message, + ) -> CommandResult { + match self { + ScoreListStyle::Table => table::display_scores_table(scores, mode, ctx, m).await, + ScoreListStyle::Grid => grid::display_scores_grid(scores, mode, ctx, m).await, + } + } + } + + pub mod grid { + use crate::discord::{ + cache::save_beatmap, BeatmapCache, BeatmapMetaCache, BeatmapWithMode, + }; + use crate::models::{Mode, Score}; + use serenity::{framework::standard::CommandResult, model::channel::Message}; + use youmubot_prelude::*; + + pub async fn display_scores_grid<'a>( + scores: Vec, + mode: Mode, + ctx: &'a Context, + m: &'a Message, + ) -> CommandResult { + if scores.is_empty() { + m.reply(&ctx, "No plays found").await?; + return Ok(()); + } + + paginate_reply( + Paginate { scores, mode }, + ctx, + m, + std::time::Duration::from_secs(60), + ) + .await?; + Ok(()) + } + + pub struct Paginate { + scores: Vec, + mode: Mode, + } + + #[async_trait] + impl pagination::Paginate for Paginate { + async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result { + let data = ctx.data.read().await; + let client = data.get::().unwrap(); + let osu = data.get::().unwrap(); + let beatmap_cache = data.get::().unwrap(); + let page = page as usize; + let score = &self.scores[page]; + + let hourglass = msg.react(ctx, '⌛').await?; + let mode = self.mode; + let beatmap = osu.get_beatmap(score.beatmap_id, mode).await?; + let content = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?; + let bm = BeatmapWithMode(beatmap, mode); + let user = client + .user(crate::request::UserID::ID(score.user_id), |f| f) + .await? + .ok_or_else(|| Error::msg("user not found"))?; + + msg.edit(ctx, |e| { + e.embed(|e| { + crate::discord::embeds::score_embed(score, &bm, &content, &user) + .footer(format!("Page {}/{}", page + 1, self.scores.len())) + .build(e) + }) + }) + .await?; + save_beatmap(&*ctx.data.read().await, msg.channel_id, &bm).await?; + + // End + hourglass.delete(ctx).await?; + Ok(true) + } + + fn len(&self) -> Option { + Some(self.scores.len()) + } + } + } + + pub mod table { + use crate::discord::{Beatmap, BeatmapCache, BeatmapInfo, BeatmapMetaCache}; + use crate::models::{Mode, Score}; + use serenity::{framework::standard::CommandResult, model::channel::Message}; + use youmubot_prelude::*; + + pub async fn display_scores_table<'a>( + scores: Vec, + mode: Mode, + ctx: &'a Context, + m: &'a Message, + ) -> CommandResult { + if scores.is_empty() { + m.reply(&ctx, "No plays found").await?; + return Ok(()); + } + + paginate_reply( + Paginate { scores, mode }, + ctx, + m, + std::time::Duration::from_secs(60), + ) + .await?; + Ok(()) + } + + pub struct Paginate { + scores: Vec, + mode: Mode, + } + + impl Paginate { + fn total_pages(&self) -> usize { + (self.scores.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE + } + } + + const ITEMS_PER_PAGE: usize = 5; + + #[async_trait] + impl pagination::Paginate for Paginate { + async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result { + let data = ctx.data.read().await; + let osu = data.get::().unwrap(); + let beatmap_cache = data.get::().unwrap(); + let page = page as usize; + let start = page * ITEMS_PER_PAGE; + let end = self.scores.len().min(start + ITEMS_PER_PAGE); + if start >= end { + return Ok(false); + } + + let hourglass = msg.react(ctx, '⌛').await?; + let plays = &self.scores[start..end]; + let mode = self.mode; + let beatmaps = plays + .iter() + .map(|play| async move { + let beatmap = osu.get_beatmap(play.beatmap_id, mode).await?; + let info = { + let b = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?; + mode.to_oppai_mode() + .and_then(|mode| b.get_info_with(Some(mode), play.mods).ok()) + }; + Ok((beatmap, info)) as Result<(Beatmap, Option)> + }) + .collect::>() + .map(|v| v.ok()) + .collect::>(); + let pp = plays + .iter() + .map(|p| async move { + match p.pp.map(|pp| format!("{:.2}pp", pp)) { + Some(v) => Ok(v), + None => { + let b = beatmap_cache.get_beatmap(p.beatmap_id).await?; + let r: Result<_> = Ok(mode + .to_oppai_mode() + .and_then(|op| { + b.get_pp_from( + oppai_rs::Combo::NonFC { + max_combo: p.max_combo as u32, + misses: p.count_miss as u32, + }, + oppai_rs::Accuracy::from_hits( + p.count_100 as u32, + p.count_50 as u32, + ), + Some(op), + p.mods, + ) + .ok() + .map(|pp| format!("{:.2}pp [?]", pp)) + }) + .unwrap_or_else(|| "-".to_owned())); + r + } + } + }) + .collect::>() + .map(|v| v.unwrap_or_else(|_| "-".to_owned())) + .collect::>(); + let (beatmaps, pp) = future::join(beatmaps, pp).await; + + let ranks = plays + .iter() + .enumerate() + .map(|(i, p)| match p.rank { + crate::models::Rank::F => beatmaps[i] + .as_ref() + .and_then(|(_, i)| i.map(|i| i.objects)) + .map(|total| { + (p.count_300 + p.count_100 + p.count_50 + p.count_miss) as f64 + / (total as f64) + * 100.0 + }) + .map(|p| format!("F [{:.0}%]", p)) + .unwrap_or_else(|| "F".to_owned()), + v => v.to_string(), + }) + .collect::>(); + + let beatmaps = beatmaps + .into_iter() + .enumerate() + .map(|(i, b)| { + let play = &plays[i]; + b.map(|(beatmap, info)| { + format!( + "[{:.1}*] {} - {} [{}] ({})", + info.map(|i| i.stars as f64) + .unwrap_or(beatmap.difficulty.stars), + beatmap.artist, + beatmap.title, + beatmap.difficulty_name, + beatmap.short_link(Some(self.mode), Some(play.mods)), + ) + }) + .unwrap_or_else(|| "FETCH_FAILED".to_owned()) + }) + .collect::>(); + + let pw = pp.iter().map(|v| v.len()).max().unwrap_or(2); + /*mods width*/ + let mw = plays + .iter() + .map(|v| v.mods.to_string().len()) + .max() + .unwrap() + .max(4); + /*beatmap names*/ + let bw = beatmaps.iter().map(|v| v.len()).max().unwrap().max(7); + /* ranks width */ + let rw = ranks.iter().map(|v| v.len()).max().unwrap().max(5); + + let mut m = serenity::utils::MessageBuilder::new(); + // Table header + m.push_line(format!( + " # | {:pw$} | accuracy | {:rw$} | {:mw$} | {:bw$}", + "pp", + "ranks", + "mods", + "beatmap", + rw = rw, + pw = pw, + mw = mw, + bw = bw + )); + m.push_line(format!( + "------{:-3} | {:>pw$} | {:>8} | {:^rw$} | {:mw$} | {:bw$}", + id + start + 1, + pp[id], + format!("{:.2}%", play.accuracy(self.mode)), + ranks[id], + play.mods.to_string(), + beatmap, + rw = rw, + pw = pw, + mw = mw, + bw = bw + )); + } + // End + let table = m.build().replace("```", "\\`\\`\\`"); + let mut m = serenity::utils::MessageBuilder::new(); + m.push_codeblock(table, None).push_line(format!( + "Page **{}/{}**", + page + 1, + self.total_pages() + )); + if self.mode.to_oppai_mode().is_none() { + m.push_line("Note: star difficulty doesn't reflect mods applied."); + } else { + m.push_line("[?] means pp was predicted by oppai-rs."); + } + msg.edit(ctx, |f| f.content(m.to_string())).await?; + hourglass.delete(ctx).await?; + Ok(true) + } + + fn len(&self) -> Option { + Some(self.total_pages()) + } + } + } +} mod beatmapset { use crate::{ @@ -71,6 +413,10 @@ mod beatmapset { #[async_trait] impl pagination::Paginate for Paginate { + fn len(&self) -> Option { + Some(self.maps.len()) + } + async fn render( &mut self, page: u8, diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 3fbada3..1b6467c 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -169,6 +169,7 @@ pub(crate) struct ScoreEmbedBuilder<'a> { u: &'a User, top_record: Option, world_record: Option, + footer: Option, } impl<'a> ScoreEmbedBuilder<'a> { @@ -180,6 +181,10 @@ impl<'a> ScoreEmbedBuilder<'a> { self.world_record = Some(rank); self } + pub fn footer(&mut self, footer: impl Into) -> &mut Self { + self.footer = Some(footer.into()); + self + } } pub(crate) fn score_embed<'a>( @@ -195,12 +200,13 @@ pub(crate) fn score_embed<'a>( u, top_record: None, world_record: None, + footer: None, } } impl<'a> ScoreEmbedBuilder<'a> { #[allow(clippy::many_single_char_names)] - pub fn build<'b>(&self, m: &'b mut CreateEmbed) -> &'b mut CreateEmbed { + pub fn build<'b>(&mut self, m: &'b mut CreateEmbed) -> &'b mut CreateEmbed { let mode = self.bm.mode(); let b = &self.bm.0; let s = self.s; @@ -358,8 +364,12 @@ impl<'a> ScoreEmbedBuilder<'a> { ) .field("Map stats", diff.format_info(mode, s.mods, b), false) .timestamp(&s.date); + let mut footer = self.footer.take().unwrap_or_else(String::new); if mode.to_oppai_mode().is_none() && s.mods != Mods::NOMOD { - m.footer(|f| f.text("Star difficulty does not reflect game mods.")); + footer += " Star difficulty does not reflect game mods."; + } + if !footer.is_empty() { + m.footer(|f| f.text(footer)); } m } diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 2e3cf7d..3a34560 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -1,7 +1,8 @@ use crate::{ discord::beatmap_cache::BeatmapMetaCache, - discord::oppai_cache::{BeatmapCache, BeatmapInfo, OppaiAccuracy}, - models::{Beatmap, Mode, Mods, Score, User}, + discord::display::ScoreListStyle, + discord::oppai_cache::{BeatmapCache, BeatmapInfo}, + models::{Beatmap, Mode, Mods, User}, request::UserID, Client as OsuHttpClient, }; @@ -31,7 +32,7 @@ use db::OsuUser; use db::{OsuLastBeatmap, OsuSavedUsers, OsuUserBests}; use embeds::{beatmap_embed, score_embed, user_embed}; pub use hook::hook; -use server_rank::{LEADERBOARD_COMMAND, SERVER_RANK_COMMAND, UPDATE_LEADERBOARD_COMMAND}; +use server_rank::{SERVER_RANK_COMMAND, UPDATE_LEADERBOARD_COMMAND}; /// The osu! client. pub(crate) struct OsuClient; @@ -107,7 +108,6 @@ pub fn setup( check, top, server_rank, - leaderboard, update_leaderboard )] #[default_command(std)] @@ -313,210 +313,16 @@ impl FromStr for Nth { } } -async fn list_plays<'a>( - plays: Vec, - mode: Mode, - ctx: &'a Context, - m: &'a Message, -) -> CommandResult { - let plays = Arc::new(plays); - if plays.is_empty() { - m.reply(&ctx, "No plays found").await?; - return Ok(()); - } - - const ITEMS_PER_PAGE: usize = 5; - let total_pages = (plays.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; - paginate_reply_fn( - move |page, ctx: &Context, msg| { - let plays = plays.clone(); - Box::pin(async move { - let data = ctx.data.read().await; - let osu = data.get::().unwrap(); - let beatmap_cache = data.get::().unwrap(); - let page = page as usize; - let start = page * ITEMS_PER_PAGE; - let end = plays.len().min(start + ITEMS_PER_PAGE); - if start >= end { - return Ok(false); - } - - let hourglass = msg.react(ctx, '⌛').await?; - let plays = &plays[start..end]; - let beatmaps = plays - .iter() - .map(|play| async move { - let beatmap = osu.get_beatmap(play.beatmap_id, mode).await?; - let info = { - let b = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?; - mode.to_oppai_mode() - .and_then(|mode| b.get_info_with(Some(mode), play.mods).ok()) - }; - Ok((beatmap, info)) as Result<(Beatmap, Option)> - }) - .collect::>() - .map(|v| v.ok()) - .collect::>(); - let pp = plays - .iter() - .map(|p| async move { - match p.pp.map(|pp| format!("{:.2}pp", pp)) { - Some(v) => Ok(v), - None => { - let b = beatmap_cache.get_beatmap(p.beatmap_id).await?; - let r: Result<_> = Ok(mode - .to_oppai_mode() - .and_then(|op| { - b.get_pp_from( - oppai_rs::Combo::NonFC { - max_combo: p.max_combo as u32, - misses: p.count_miss as u32, - }, - OppaiAccuracy::from_hits( - p.count_100 as u32, - p.count_50 as u32, - ), - Some(op), - p.mods, - ) - .ok() - .map(|pp| format!("{:.2}pp [?]", pp)) - }) - .unwrap_or_else(|| "-".to_owned())); - r - } - } - }) - .collect::>() - .map(|v| v.unwrap_or_else(|_| "-".to_owned())) - .collect::>(); - let (beatmaps, pp) = future::join(beatmaps, pp).await; - - let ranks = plays - .iter() - .enumerate() - .map(|(i, p)| match p.rank { - crate::models::Rank::F => beatmaps[i] - .as_ref() - .and_then(|(_, i)| i.map(|i| i.objects)) - .map(|total| { - (p.count_300 + p.count_100 + p.count_50 + p.count_miss) as f64 - / (total as f64) - * 100.0 - }) - .map(|p| format!("F [{:.0}%]", p)) - .unwrap_or_else(|| "F".to_owned()), - v => v.to_string(), - }) - .collect::>(); - - let beatmaps = beatmaps - .into_iter() - .enumerate() - .map(|(i, b)| { - let play = &plays[i]; - b.map(|(beatmap, info)| { - format!( - "[{:.1}*] {} - {} [{}] ({})", - info.map(|i| i.stars as f64) - .unwrap_or(beatmap.difficulty.stars), - beatmap.artist, - beatmap.title, - beatmap.difficulty_name, - beatmap.short_link(Some(mode), Some(play.mods)), - ) - }) - .unwrap_or_else(|| "FETCH_FAILED".to_owned()) - }) - .collect::>(); - - let pw = pp.iter().map(|v| v.len()).max().unwrap_or(2); - /*mods width*/ - let mw = plays - .iter() - .map(|v| v.mods.to_string().len()) - .max() - .unwrap() - .max(4); - /*beatmap names*/ - let bw = beatmaps.iter().map(|v| v.len()).max().unwrap().max(7); - /* ranks width */ - let rw = ranks.iter().map(|v| v.len()).max().unwrap().max(5); - - let mut m = MessageBuilder::new(); - // Table header - m.push_line(format!( - " # | {:pw$} | accuracy | {:rw$} | {:mw$} | {:bw$}", - "pp", - "ranks", - "mods", - "beatmap", - rw = rw, - pw = pw, - mw = mw, - bw = bw - )); - m.push_line(format!( - "------{:-3} | {:>pw$} | {:>8} | {:^rw$} | {:mw$} | {:bw$}", - id + start + 1, - pp[id], - format!("{:.2}%", play.accuracy(mode)), - ranks[id], - play.mods.to_string(), - beatmap, - rw = rw, - pw = pw, - mw = mw, - bw = bw - )); - } - // End - let table = m.build().replace("```", "\\`\\`\\`"); - let mut m = MessageBuilder::new(); - m.push_codeblock(table, None).push_line(format!( - "Page **{}/{}**", - page + 1, - total_pages - )); - if mode.to_oppai_mode().is_none() { - m.push_line("Note: star difficulty doesn't reflect mods applied."); - } else { - m.push_line("[?] means pp was predicted by oppai-rs."); - } - msg.edit(ctx, |f| f.content(m.to_string())).await?; - hourglass.delete(ctx).await?; - Ok(true) - }) - }, - ctx, - m, - std::time::Duration::from_secs(60), - ) - .await?; - Ok(()) -} - #[command] +#[aliases("rs", "rc")] #[description = "Gets an user's recent play"] -#[usage = "#[the nth recent play = --all] / [mode (std, taiko, mania, catch) = std] / [username / user id = your saved id]"] +#[usage = "#[the nth recent play = --all] / [style (table or grid) = --table] / [mode (std, taiko, mania, catch) = std] / [username / user id = your saved id]"] #[example = "#1 / taiko / natsukagami"] -#[max_args(3)] +#[max_args(4)] pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let data = ctx.data.read().await; let nth = args.single::().unwrap_or(Nth::All); + let style = args.single::().unwrap_or_default(); let mode = args.single::().unwrap_or(ModeArg(Mode::Std)).0; let user = to_user_id_query(args.single::().ok(), &*data, msg).await?; @@ -556,7 +362,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu let plays = osu .user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50)) .await?; - list_plays(plays, mode, ctx, msg).await?; + style.display_scores(plays, mode, ctx, msg).await?; } } Ok(()) @@ -628,11 +434,12 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult #[command] #[aliases("c", "chk")] -#[usage = "[username or tag = yourself]"] +#[usage = "[style (table or grid) = --table] / [username or tag = yourself] / [mods to filter]"] #[description = "Check your own or someone else's best record on the last beatmap. Also stores the result if possible."] -#[max_args(1)] +#[max_args(3)] pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let data = ctx.data.read().await; + let mods = args.find::().unwrap_or(Mods::NOMOD); let bm = cache::get_beatmap(&*data, msg.channel_id).await?; match bm { @@ -643,6 +450,9 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul Some(bm) => { let b = &bm.0; let m = bm.1; + let style = args + .single::() + .unwrap_or(ScoreListStyle::Grid); let username_arg = args.single::().ok(); let user_id = match username_arg.as_ref() { Some(UsernameArg::Tagged(v)) => Some(*v), @@ -652,38 +462,34 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul let user = to_user_id_query(username_arg, &*data, msg).await?; let osu = data.get::().unwrap(); - let oppai = data.get::().unwrap(); - - let content = oppai.get_beatmap(b.beatmap_id).await?; let user = osu .user(user, |f| f) .await? .ok_or_else(|| Error::msg("User not found"))?; - let scores = osu + let mut scores = osu .scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m)) - .await?; + .await? + .into_iter() + .filter(|s| s.mods.contains(mods)) + .collect::>(); + scores.sort_by(|a, b| b.pp.unwrap().partial_cmp(&a.pp.unwrap()).unwrap()); if scores.is_empty() { msg.reply(&ctx, "No scores found").await?; - } - - for score in scores.iter() { - msg.channel_id - .send_message(&ctx, |c| { - c.embed(|m| score_embed(&score, &bm, &content, &user).build(m)) - }) - .await?; + return Ok(()); } if let Some(user_id) = user_id { // Save to database data.get::() .unwrap() - .save(user_id, m, scores) + .save(user_id, m, scores.clone()) .await .pls_ok(); } + + style.display_scores(scores, m, ctx, msg).await?; } } @@ -692,12 +498,13 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul #[command] #[description = "Get the n-th top record of an user."] -#[usage = "[mode (std, taiko, catch, mania)] = std / #[n-th = --all] / [username or user_id = your saved user id]"] -#[example = "taiko / #2 / natsukagami"] -#[max_args(3)] +#[usage = "#[n-th = --all] / [style (table or grid) = --table] / [mode (std, taiko, catch, mania)] = std / [username or user_id = your saved user id]"] +#[example = "#2 / taiko / natsukagami"] +#[max_args(4)] pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let data = ctx.data.read().await; let nth = args.single::().unwrap_or(Nth::All); + let style = args.single::().unwrap_or_default(); let mode = args .single::() .map(|ModeArg(t)| t) @@ -750,7 +557,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let plays = osu .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) .await?; - list_plays(plays, mode, ctx, msg).await?; + style.display_scores(plays, mode, ctx, msg).await?; } } Ok(()) diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 8c7c627..ab0bb5b 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -5,6 +5,7 @@ use super::{ }; use crate::{ discord::{ + display::ScoreListStyle, oppai_cache::{BeatmapCache, OppaiAccuracy}, BeatmapWithMode, }, @@ -151,23 +152,34 @@ enum OrderBy { Score, } -impl From<&str> for OrderBy { - fn from(s: &str) -> Self { - if s == "--score" { - Self::Score - } else { - Self::PP +impl Default for OrderBy { + fn default() -> Self { + Self::PP + } +} + +impl std::str::FromStr for OrderBy { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "--score" => Ok(OrderBy::Score), + "--pp" => Ok(OrderBy::PP), + _ => Err(Error::msg("unknown value")), } } } -#[command("updatelb")] -#[description = "Update the leaderboard on the last seen beatmap"] -#[usage = "[--score to sort by score, default to sort by pp]"] -#[max_args(1)] +#[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]"] +#[description = "See the server's ranks on the last seen beatmap"] +#[max_args(2)] #[only_in(guilds)] -pub async fn update_leaderboard(ctx: &Context, m: &Message, args: Args) -> CommandResult { - let sort_order = OrderBy::from(args.rest()); +pub async fn update_leaderboard(ctx: &Context, m: &Message, mut args: Args) -> CommandResult { + let sort_order = args.single::().unwrap_or_default(); + let style = args.single::().unwrap_or_default(); + let mods = args.find::().unwrap_or(Mods::NOMOD); let guild = m.guild_id.unwrap(); let data = ctx.data.read().await; @@ -246,34 +258,16 @@ pub async fn update_leaderboard(ctx: &Context, m: &Message, args: Args) -> Comma .await .ok(); drop(update_lock); - show_leaderboard(ctx, m, bm, sort_order).await -} - -#[command("leaderboard")] -#[aliases("lb", "bmranks", "br", "cc")] -#[usage = "[--score to sort by score, default to sort by pp]"] -#[description = "See the server's ranks on the last seen beatmap"] -#[max_args(1)] -#[only_in(guilds)] -pub async fn leaderboard(ctx: &Context, m: &Message, args: Args) -> CommandResult { - let sort_order = OrderBy::from(args.rest()); - - let data = ctx.data.read().await; - let bm = match get_beatmap(&*data, m.channel_id).await? { - Some(bm) => bm, - None => { - m.reply(&ctx, "No beatmap queried on this channel.").await?; - return Ok(()); - } - }; - show_leaderboard(ctx, m, bm, sort_order).await + show_leaderboard(ctx, m, bm, mods, sort_order, style).await } async fn show_leaderboard( ctx: &Context, m: &Message, bm: BeatmapWithMode, + mods: Mods, order: OrderBy, + style: ScoreListStyle, ) -> CommandResult { let data = ctx.data.read().await; @@ -295,31 +289,6 @@ async fn show_leaderboard( }) }; - // Run a check on the user once too! - { - let user = data - .get::() - .unwrap() - .by_user_id(m.author.id) - .await? - .map(|v| v.id); - if let Some(id) = user { - let osu = data.get::().unwrap(); - if let Ok(scores) = osu - .scores(bm.0.beatmap_id, |f| f.user(UserID::ID(id))) - .await - { - if !scores.is_empty() { - data.get::() - .unwrap() - .save(m.author.id, mode, scores) - .await - .pls_ok(); - } - } - } - } - let guild = m.guild_id.expect("Guild-only command"); let member_cache = data.get::().unwrap(); let scores = { @@ -337,6 +306,7 @@ async fn show_leaderboard( let mut scores: Vec<(f64, String, Score)> = scores .into_iter() + .filter(|(_, score)| score.mods.contains(mods)) .map(|(user_id, score)| { member_cache .query(&ctx, user_id, guild) @@ -381,6 +351,19 @@ async fn show_leaderboard( .await?; return Ok(()); } + + if let ScoreListStyle::Grid = style { + style + .display_scores( + scores.into_iter().map(|(_, _, a)| a).collect(), + mode, + ctx, + m, + ) + .await?; + return Ok(()); + } + paginate_reply_fn( move |page: u8, ctx: &Context, m: &mut Message| { const ITEMS_PER_PAGE: usize = 5; diff --git a/youmubot-prelude/src/pagination.rs b/youmubot-prelude/src/pagination.rs index 9c6b0bd..488f64f 100644 --- a/youmubot-prelude/src/pagination.rs +++ b/youmubot-prelude/src/pagination.rs @@ -12,6 +12,8 @@ use tokio::time as tokio_time; const ARROW_RIGHT: &str = "➡️"; const ARROW_LEFT: &str = "⬅️"; +const REWIND: &str = "⏪"; +const FAST_FORWARD: &str = "⏩"; /// A trait that provides the implementation of a paginator. #[async_trait::async_trait] @@ -39,6 +41,16 @@ pub trait Paginate: Send + Sized { .await .map(Some) } + + /// Return the number of pages, if it is known in advance. + /// If this is given, bounds-check will be done outside of `prerender` / `render`. + fn len(&self) -> Option { + None + } + + fn is_empty(&self) -> Option { + self.len().map(|v| v == 0) + } } #[async_trait::async_trait] @@ -90,15 +102,30 @@ async fn paginate_with_first_message( mut message: Message, timeout: std::time::Duration, ) -> Result<()> { + pager.prerender(&ctx, &mut message).await?; + pager.render(0, ctx, &mut message).await?; + // Just quit if there is only one page + if pager.len().filter(|&v| v == 1).is_some() { + return Ok(()); + } // React to the message + let large_count = pager.len().filter(|&p| p > 10).is_some(); + if large_count { + // add >> and << buttons + message.react(&ctx, ReactionType::try_from(REWIND)?).await?; + } message .react(&ctx, ReactionType::try_from(ARROW_LEFT)?) .await?; message .react(&ctx, ReactionType::try_from(ARROW_RIGHT)?) .await?; - pager.prerender(&ctx, &mut message).await?; - pager.render(0, ctx, &mut message).await?; + if large_count { + // add >> and << buttons + message + .react(&ctx, ReactionType::try_from(FAST_FORWARD)?) + .await?; + } // Build a reaction collector let mut reaction_collector = message.await_reactions(&ctx).removed(true).await; let mut page = 0; @@ -167,21 +194,33 @@ pub async fn handle_pagination_reaction( let reaction = match reaction { ReactionAction::Added(v) | ReactionAction::Removed(v) => v, }; + let pages = pager.len(); + let fast = pages.map(|v| v / 10).unwrap_or(5).max(5) as u8; match &reaction.emoji { - ReactionType::Unicode(ref s) => match s.as_str() { - ARROW_LEFT if page == 0 => Ok(page), - ARROW_LEFT => Ok(if pager.render(page - 1, ctx, message).await? { - page - 1 + ReactionType::Unicode(ref s) => { + let new_page = match s.as_str() { + ARROW_LEFT | REWIND if page == 0 => return Ok(page), + ARROW_LEFT => page - 1, + REWIND => { + if page < fast { + 0 + } else { + page - fast + } + } + ARROW_RIGHT if pages.filter(|&pages| page as usize + 1 >= pages).is_some() => { + return Ok(page) + } + ARROW_RIGHT => page + 1, + FAST_FORWARD => (pages.unwrap() as u8 - 1).min(page + fast), + _ => return Ok(page), + }; + Ok(if pager.render(new_page, ctx, message).await? { + new_page } else { page - }), - ARROW_RIGHT => Ok(if pager.render(page + 1, ctx, message).await? { - page + 1 - } else { - page - }), - _ => Ok(page), - }, + }) + } _ => Ok(page), } }