osu: Server leaderboard command (#6)

* New db stores user best by beatmap id

* `check` command saves into the database

* Add leaderboard command

* Update youmubot-osu/src/discord/server_rank.rs

Swap ordering
This commit is contained in:
Natsu Kagami 2020-09-13 04:55:36 +00:00 committed by GitHub
parent bfd9d1c68d
commit ce0349859c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 220 additions and 15 deletions

View file

@ -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<HashMap<UserId, OsuUser>>;
/// Save each channel's last requested beatmap.
pub type OsuLastBeatmap = DB<HashMap<ChannelId, (Beatmap, Mode)>>;
/// Save each beatmap's plays by user.
pub type OsuUserBests =
DB<HashMap<(u64, Mode) /* Beatmap ID and Mode */, HashMap<UserId, Vec<Score>>>>;
/// An osu! saved user.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct OsuUser {

View file

@ -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::<HTTPClient>();
@ -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<Score>, 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::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let username_arg = args.single::<UsernameArg>().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::<OsuClient>();
let oppai = ctx.data.get_cloned::<BeatmapCache>();
@ -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 {

View file

@ -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::<ReactionWatcher>().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::<Vec<_>>();
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::<ReactionWatcher>().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::<Vec<_>>();
let aw = accuracies.iter().map(|v| v.len()).max().unwrap().max(3);
let misses = scores
.iter()
.map(|(_, _, v)| format!("{}", v.count_miss))
.collect::<Vec<_>>();
let mw = misses.iter().map(|v| v.len()).max().unwrap().max(4);
let ranks = scores
.iter()
.map(|(_, _, v)| v.rank.to_string())
.collect::<Vec<_>>();
let rw = ranks.iter().map(|v| v.len()).max().unwrap().max(4);
let pp = scores
.iter()
.map(|(pp, _, _)| format!("{:.2}", pp))
.collect::<Vec<_>>();
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!(
"-------{:-<pw$}---{:-<mdw$}---{:-<rw$}---{:-<aw$}---{:-<mw$}---{:-<uw$}",
"",
"",
"",
"",
"",
"",
pw = pw,
mdw = mdw,
rw = rw,
aw = aw,
mw = mw,
uw = uw,
));
for (id, (_, member, p)) in scores.iter().enumerate() {
content.push_line_safe(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(())
}