diff --git a/youmubot-osu/src/discord/db.rs b/youmubot-osu/src/discord/db.rs index 3952873..72b6616 100644 --- a/youmubot-osu/src/discord/db.rs +++ b/youmubot-osu/src/discord/db.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Utc}; -use crate::models::{Beatmap, Mode}; +use crate::models::{Beatmap, Mode, Score}; use serde::{Deserialize, Serialize}; use serenity::model::id::{ChannelId, UserId}; use std::collections::HashMap; @@ -12,6 +12,10 @@ pub type OsuSavedUsers = DB>; /// Save each channel's last requested beatmap. pub type OsuLastBeatmap = DB>; +/// Save each beatmap's plays by user. +pub type OsuUserBests = + DB>>>; + /// An osu! saved user. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct OsuUser { diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 8d1c7ce..a5b5052 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -27,10 +27,10 @@ pub(crate) mod oppai_cache; mod server_rank; use db::OsuUser; -use db::{OsuLastBeatmap, OsuSavedUsers}; +use db::{OsuLastBeatmap, OsuSavedUsers, OsuUserBests}; use embeds::{beatmap_embed, score_embed, user_embed}; pub use hook::hook; -use server_rank::SERVER_RANK_COMMAND; +use server_rank::{LEADERBOARD_COMMAND, SERVER_RANK_COMMAND}; /// The osu! client. pub(crate) struct OsuClient; @@ -58,6 +58,7 @@ pub fn setup( // Databases OsuSavedUsers::insert_into(&mut *data, &path.join("osu_saved_users.yaml"))?; OsuLastBeatmap::insert_into(&mut *data, &path.join("last_beatmaps.yaml"))?; + OsuUserBests::insert_into(&mut *data, &path.join("osu_user_bests.yaml"))?; // API client let http_client = data.get_cloned::(); @@ -79,7 +80,19 @@ pub fn setup( #[group] #[prefix = "osu"] #[description = "osu! related commands."] -#[commands(std, taiko, catch, mania, save, recent, last, check, top, server_rank)] +#[commands( + std, + taiko, + catch, + mania, + save, + recent, + last, + check, + top, + server_rank, + leaderboard +)] #[default_command(std)] struct Osu; @@ -249,7 +262,7 @@ fn list_plays(plays: Vec, mode: Mode, ctx: Context, m: &Message) -> Comma } let plays = &plays[start..end]; - let beatmaps = { + let beatmaps: Vec<&mut String> = { let b = &mut beatmaps[start..end]; b.par_iter_mut() .enumerate() @@ -452,7 +465,8 @@ pub fn last(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { #[command] #[aliases("c", "chk")] -#[description = "Check your own or someone else's best record on the last beatmap."] +#[usage = "[username or tag = yourself]"] +#[description = "Check your own or someone else's best record on the last beatmap. Also stores the result if possible."] #[max_args(1)] pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { let bm = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?; @@ -464,7 +478,13 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult Some(bm) => { let b = &bm.0; let m = bm.1; - let user = to_user_id_query(args.single::().ok(), &*ctx.data.read(), msg)?; + let username_arg = args.single::().ok(); + let user_id = match username_arg.as_ref() { + Some(UsernameArg::Tagged(v)) => Some(v.clone()), + None => Some(msg.author.id), + _ => None, + }; + let user = to_user_id_query(username_arg, &*ctx.data.read(), msg)?; let osu = ctx.data.get_cloned::(); let oppai = ctx.data.get_cloned::(); @@ -480,11 +500,20 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult msg.reply(&ctx, "No scores found")?; } - for score in scores.into_iter() { + 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, None, m)) })?; } + + if let Some(user_id) = user_id { + // Save to database + OsuUserBests::open(&*ctx.data.read()) + .borrow_mut()? + .entry((bm.0.beatmap_id, bm.1)) + .or_default() + .insert(user_id, scores); + } } } @@ -493,7 +522,7 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult #[command] #[description = "Get the n-th top record of an user."] -#[usage = "#[n-th = --all] / [mode (std, taiko, catch, mania) = std / [username or user_id = your saved user id]"] +#[usage = "#[n-th = --all] / [mode (std, taiko, catch, mania)] = std / [username or user_id = your saved user id]"] #[example = "#2 / taiko / natsukagami"] #[max_args(3)] pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult { diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 62f7d2a..236673d 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -1,5 +1,9 @@ -use super::{db::OsuSavedUsers, ModeArg}; -use crate::models::Mode; +use super::{ + cache::get_beatmap, + db::{OsuSavedUsers, OsuUserBests}, + ModeArg, +}; +use crate::models::{Mode, Score}; use serenity::{ builder::EditMessage, framework::standard::{macros::command, Args, CommandError as Error, CommandResult}, @@ -8,8 +12,6 @@ use serenity::{ }; use youmubot_prelude::*; -const ITEMS_PER_PAGE: usize = 10; - #[command("ranks")] #[description = "See the server's ranks"] #[usage = "[mode (Std, Taiko, Catch, Mania) = Std]"] @@ -45,6 +47,7 @@ pub fn server_rank(ctx: &mut Context, m: &Message, mut args: Args) -> CommandRes return Ok(()); } let last_update = last_update.unwrap(); + const ITEMS_PER_PAGE: usize = 10; ctx.data.get_cloned::().paginate_fn( ctx.clone(), m.channel_id, @@ -56,7 +59,7 @@ pub fn server_rank(ctx: &mut Context, m: &Message, mut args: Args) -> CommandRes } let total_len = users.len(); let users = &users[start..end]; - let username_len = users.iter().map(|(_, u)| u.len()).max().unwrap_or(8).max(8); + let username_len = users.iter().map(|(_, u)| u.len()).max().unwrap().max(8); let mut content = MessageBuilder::new(); content .push_line("```") @@ -84,3 +87,172 @@ pub fn server_rank(ctx: &mut Context, m: &Message, mut args: Args) -> CommandRes Ok(()) } + +#[command("leaderboard")] +#[aliases("lb", "bmranks", "br", "cc")] +#[description = "See the server's ranks on the last seen beatmap"] +#[max_args(0)] +#[only_in(guilds)] +pub fn leaderboard(ctx: &mut Context, m: &Message, mut _args: Args) -> CommandResult { + let bm = match get_beatmap(&*ctx.data.read(), m.channel_id)? { + Some(bm) => bm, + None => { + m.reply(&ctx, "No beatmap queried on this channel.")?; + return Ok(()); + } + }; + + let guild = m.guild_id.expect("Guild-only command"); + let scores = { + let users = OsuUserBests::open(&*ctx.data.read()); + let users = users.borrow()?; + let users = match users.get(&(bm.0.beatmap_id, bm.1)) { + None => { + m.reply( + &ctx, + "No scores have been recorded for this beatmap. Run `osu check` to scan for yours!", + )?; + return Ok(()); + } + Some(v) if v.is_empty() => { + m.reply( + &ctx, + "No scores have been recorded for this beatmap. Run `osu check` to scan for yours!", + )?; + return Ok(()); + } + Some(v) => v, + }; + + let mut scores: Vec<(f64, String, Score)> = users + .iter() + .filter_map(|(user_id, scores)| { + guild + .member(&ctx, user_id) + .ok() + .and_then(|m| Some((m.distinct(), scores))) + }) + .flat_map(|(user, scores)| scores.into_iter().map(move |v| (user.clone(), v.clone()))) + .filter_map(|(user, score)| score.pp.map(|v| (v, user, score))) + .collect::>(); + scores + .sort_by(|(a, _, _), (b, _, _)| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); + scores + }; + + if scores.is_empty() { + m.reply( + &ctx, + "No scores have been recorded for this beatmap. Run `osu check` to scan for yours!", + )?; + return Ok(()); + } + ctx.data.get_cloned::().paginate_fn( + ctx.clone(), + m.channel_id, + move |page: u8, e: &mut EditMessage| { + const ITEMS_PER_PAGE: usize = 5; + let start = (page as usize) * ITEMS_PER_PAGE; + let end = (start + ITEMS_PER_PAGE).min(scores.len()); + if start >= end { + return (e, Err(Error("No more items".to_owned()))); + } + let total_len = scores.len(); + let scores = &scores[start..end]; + // username width + let uw = scores + .iter() + .map(|(_, u, _)| u.len()) + .max() + .unwrap_or(8) + .max(8); + let accuracies = scores + .iter() + .map(|(_, _, v)| format!("{:.2}%", v.accuracy(bm.1))) + .collect::>(); + let aw = accuracies.iter().map(|v| v.len()).max().unwrap().max(3); + let misses = scores + .iter() + .map(|(_, _, v)| format!("{}", v.count_miss)) + .collect::>(); + let mw = misses.iter().map(|v| v.len()).max().unwrap().max(4); + let ranks = scores + .iter() + .map(|(_, _, v)| v.rank.to_string()) + .collect::>(); + let rw = ranks.iter().map(|v| v.len()).max().unwrap().max(4); + let pp = scores + .iter() + .map(|(pp, _, _)| format!("{:.2}", pp)) + .collect::>(); + let pw = pp.iter().map(|v| v.len()).max().unwrap_or(2); + /*mods width*/ + let mdw = scores + .iter() + .map(|(_, _, v)| v.mods.to_string().len()) + .max() + .unwrap() + .max(4); + let mut content = MessageBuilder::new(); + content + .push_line("```") + .push_line(format!( + "rank | {:>pw$} | {:mdw$} | {:rw$} | {:>aw$} | {:mw$} | {:uw$}", + "pp", + "mods", + "rank", + "acc", + "miss", + "user", + pw = pw, + mdw = mdw, + rw = rw, + aw = aw, + mw = mw, + uw = uw, + )) + .push_line(format!( + "-------{:-4} | {:>pw$} | {:>mdw$} | {:>rw$} | {:>aw$} | {:>mw$} | {:uw$}", + format!("#{}", 1 + id + start), + pp[id], + p.mods.to_string(), + ranks[id], + accuracies[id], + misses[id], + member, + pw = pw, + mdw = mdw, + rw = rw, + aw = aw, + mw = mw, + uw = uw, + )); + } + content.push_line("```").push_line(format!( + "Page **{}**/**{}**. Not seeing your scores? Run `osu check` to update.", + page + 1, + (total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE, + )); + (e.content(content.build()), Ok(())) + }, + std::time::Duration::from_secs(60), + )?; + + Ok(()) +}