diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index f1262fa..8d01fda 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -1,4 +1,7 @@ -use std::{convert::TryInto, sync::Arc}; +use chrono::{DateTime, Utc}; +use futures_util::try_join; +use std::sync::Arc; +use stream::FuturesUnordered; use serenity::builder::CreateMessage; use serenity::{ @@ -14,6 +17,7 @@ use youmubot_prelude::announcer::CacheAndHttp; use youmubot_prelude::stream::TryStreamExt; use youmubot_prelude::*; +use crate::discord::db::OsuUserMode; use crate::{ discord::cache::save_beatmap, discord::oppai_cache::BeatmapContent, @@ -22,22 +26,21 @@ use crate::{ Client as Osu, }; -use super::db::{OsuSavedUsers, OsuUser}; +use super::db::OsuUser; use super::interaction::score_components; use super::{calculate_weighted_map_length, OsuEnv}; use super::{embeds::score_embed, BeatmapWithMode}; /// osu! announcer's unique announcer key. pub const ANNOUNCER_KEY: &str = "osu"; +const MAX_FAILURES: u8 = 64; /// The announcer struct implementing youmubot_prelude::Announcer -pub struct Announcer { - client: Arc, -} +pub struct Announcer {} impl Announcer { - pub fn new(client: Arc) -> Self { - Self { client } + pub fn new() -> Self { + Self {} } } @@ -45,71 +48,26 @@ impl Announcer { impl youmubot_prelude::Announcer for Announcer { async fn updates( &mut self, - c: CacheAndHttp, + ctx: CacheAndHttp, d: AppData, channels: MemberToChannels, ) -> Result<()> { + let env = d.read().await.get::().unwrap().clone(); // For each user... - let users = { - let env = d.read().await.get::().unwrap().clone(); - env.saved_users.all().await? - }; - let now = chrono::Utc::now(); + let users = env.saved_users.all().await?; users .into_iter() - .map(|mut osu_user| { - let user_id = osu_user.user_id; - let channels = &channels; - let ctx = Context { - c: c.clone(), - data: d.clone(), - }; - let s = &*self; - async move { - let channels = channels.channels_of(ctx.c.clone(), user_id).await; - if channels.is_empty() { - return; // We don't wanna update an user without any active server - } - match [Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania] - .into_iter() - .map(|m| { - s.handle_user_mode(&ctx, now, &osu_user, user_id, channels.clone(), m) + .map( + |osu_user| { + channels + .channels_of(ctx.clone(), osu_user.user_id) + .then(|chs| self.update_user(ctx.clone(), &env, osu_user, chs)) + .then(|new_user| env.saved_users.save(new_user)) + .map(|r| { + r.pls_ok(); }) - .collect::>() - .try_collect::>() - .await - { - Ok(v) => { - osu_user.pp = v - .iter() - .map(|u| u.pp) - .collect::>() - .try_into() - .unwrap(); - osu_user.username = v.into_iter().next().unwrap().username.into(); - osu_user.last_update = now; - osu_user.std_weighted_map_length = - Self::std_weighted_map_length(&ctx, &osu_user) - .await - .pls_ok(); - let id = osu_user.id; - println!("{:?}", osu_user); - ctx.data - .read() - .await - .get::() - .unwrap() - .save(osu_user) - .await - .pls_ok(); - println!("updating {} done", id); - } - Err(e) => { - eprintln!("osu: Cannot update {}: {}", osu_user.id, e); - } - }; - } - }) + }, // self.update_user() + ) .collect::>() .collect::<()>() .await; @@ -118,98 +76,128 @@ impl youmubot_prelude::Announcer for Announcer { } impl Announcer { - /// Handles an user/mode scan, announces all possible new scores, return the new pp value. - async fn handle_user_mode( + async fn update_user( &self, - ctx: &Context, + ctx: impl CacheHttp + Clone + 'static, + env: &OsuEnv, + mut user: OsuUser, + broadcast_to: Vec, + ) -> OsuUser { + if user.failures == MAX_FAILURES { + return user; + } + const MODES: [Mode; 4] = [Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania]; + let now = chrono::Utc::now(); + let broadcast_to = Arc::new(broadcast_to); + for mode in MODES { + let (u, top, events) = match self.fetch_user_data(env, now, &user, mode).await { + Ok(v) => v, + Err(err) => { + eprintln!( + "[osu] Updating `{}`[{}] failed with: {}", + user.username, user.id, err + ); + user.failures += 1; + if user.failures == MAX_FAILURES { + eprintln!( + "[osu] Too many failures, disabling: `{}`[{}]", + user.username, user.id + ); + } + break; + } + }; + // update stats + let stats = OsuUserMode { + pp: u.pp.unwrap_or(0.0), + map_length: calculate_weighted_map_length(&top, &env.beatmaps, mode) + .await + .pls_ok() + .unwrap_or(0.0), + map_age: 0, // soon + last_update: now, + }; + let last = user.modes.insert(mode, stats); + let last_update = last.as_ref().map(|v| v.last_update); + + // broadcast + let mention = user.user_id; + let broadcast_to = broadcast_to.clone(); + let ctx = ctx.clone(); + let env = env.clone(); + spawn_future(async move { + let top = top + .into_iter() + .enumerate() + .filter(|(_, s)| Self::is_announceable_date(s.date, last_update, now)) + .map(|(rank, score)| { + CollectedScore::from_top_score(&u, score, mode, rank as u8) + }); + let recents = events + .into_iter() + .map(|e| CollectedScore::from_event(&env.client, &u, e)) + .collect::>() + .filter_map(|v| future::ready(v.pls_ok())) + .collect::>() + .await + .into_iter(); + top.chain(recents) + .map(|v| v.send_message(&ctx, &env, mention, &broadcast_to)) + .collect::>() + .filter_map(|v| future::ready(v.pls_ok().map(|_| ()))) + .collect::<()>() + .await + }); + } + user.failures = 0; + user + } + + fn is_announceable_date( + s: DateTime, + last_update: Option>, + now: DateTime, + ) -> bool { + (match last_update { + Some(lu) => s > lu, + None => true, + }) && s <= now + } + + /// Handles an user/mode scan, announces all possible new scores, return the new pp value. + async fn fetch_user_data( + &self, + env: &OsuEnv, now: chrono::DateTime, osu_user: &OsuUser, - user_id: UserId, - channels: Vec, mode: Mode, - ) -> Result { - let days_since_last_update = (now - osu_user.last_update).num_days() + 1; - let last_update = osu_user.last_update; - let (scores, user) = { - let scores = self.scan_user(osu_user, mode).await?; - let user = self - .client - .user(&UserID::ID(osu_user.id), |f| { - f.mode(mode) - .event_days(days_since_last_update.min(31) as u8) - }) - .await? - .ok_or_else(|| Error::msg("user not found"))?; - (scores, user) + ) -> Result<(User, Vec, Vec), Error> { + let stats = osu_user.modes.get(&mode).cloned(); + let last_update = stats.as_ref().map(|v| v.last_update); + let user_id = UserID::ID(osu_user.id); + let user = { + let days_since_last_update = stats + .as_ref() + .map(|v| (now - v.last_update).num_days() + 1) + .unwrap_or(30); + env.client.user(&user_id, move |f| { + f.mode(mode) + .event_days(days_since_last_update.min(31) as u8) + }) }; - let client = self.client.clone(); - let ctx = ctx.clone(); - let _user = user.clone(); - spawn_future(async move { - let event_scores = user - .events - .iter() - .filter_map(|u| u.to_event_rank()) - .filter(|u| u.mode == mode && u.date > last_update && u.date <= now) - .map(|ev| CollectedScore::from_event(&client, &user, ev, user_id, &channels[..])) - .collect::>() - .filter_map(|u| future::ready(u.pls_ok())) - .collect::>() - .await; - let top_scores = scores.into_iter().filter_map(|(rank, score)| { - if score.date > last_update && score.date <= now { - Some(CollectedScore::from_top_score( - &user, - score, - mode, - rank, - user_id, - &channels[..], - )) - } else { - None - } - }); - event_scores - .into_iter() - .chain(top_scores) - .map(|v| v.send_message(&ctx)) - .collect::>() - .try_collect::>() - .await - .pls_ok(); - }); - Ok(_user) - } - - async fn scan_user(&self, u: &OsuUser, mode: Mode) -> Result, Error> { - let scores = self + let top_scores = env .client - .user_best(UserID::ID(u.id), |f| f.mode(mode).limit(25)) - .await?; - let scores = scores + .user_best(user_id.clone(), |f| f.mode(mode).limit(100)); + let (user, top_scores) = try_join!(user, top_scores)?; + let mut user = user.unwrap(); + // if top scores exist, user would too + let events = std::mem::replace(&mut user.events, vec![]) .into_iter() - .enumerate() - .filter(|(_, s)| s.date >= u.last_update) - .map(|(i, v)| ((i + 1) as u8, v)) - .collect(); - Ok(scores) + .filter_map(|v| v.to_event_rank()) + .filter(|s| Self::is_announceable_date(s.date, last_update, now)) + .collect::>(); + Ok((user, top_scores, events)) } - - async fn std_weighted_map_length(ctx: &Context, u: &OsuUser) -> Result { - let env = ctx.data.read().await.get::().unwrap().clone(); - let scores = env - .client - .user_best(UserID::ID(u.id), |f| f.mode(Mode::Std).limit(100)) - .await?; - calculate_weighted_map_length(&scores, &env.beatmaps, Mode::Std).await - } -} - -#[derive(Clone)] -struct Context { - data: AppData, - c: CacheAndHttp, } struct CollectedScore<'a> { @@ -217,27 +205,15 @@ struct CollectedScore<'a> { pub score: Score, pub mode: Mode, pub kind: ScoreType, - - pub discord_user: UserId, - pub channels: &'a [ChannelId], } impl<'a> CollectedScore<'a> { - fn from_top_score( - user: &'a User, - score: Score, - mode: Mode, - rank: u8, - discord_user: UserId, - channels: &'a [ChannelId], - ) -> Self { + fn from_top_score(user: &'a User, score: Score, mode: Mode, rank: u8) -> Self { Self { user, score, mode, kind: ScoreType::TopRecord(rank), - discord_user, - channels, } } @@ -245,8 +221,6 @@ impl<'a> CollectedScore<'a> { osu: &Osu, user: &'a User, event: UserEventRank, - discord_user: UserId, - channels: &'a [ChannelId], ) -> Result> { let scores = osu .scores(event.beatmap_id, |f| { @@ -258,32 +232,40 @@ impl<'a> CollectedScore<'a> { .find(|s| (s.date - event.date).abs() < chrono::TimeDelta::seconds(5)) { Some(v) => v, - None => return Err(Error::msg("cannot get score for map...")), + None => { + return Err(Error::msg(format!( + "cannot get score for map..., event = {:?}", + event + ))) + } }; Ok(Self { user, score, mode: event.mode, kind: ScoreType::WorldRecord(event.rank), - discord_user, - channels, }) } } impl<'a> CollectedScore<'a> { - async fn send_message(self, ctx: &Context) -> Result> { - let (bm, content) = self.get_beatmap(ctx).await?; - self.channels + async fn send_message( + self, + ctx: impl CacheHttp, + env: &OsuEnv, + mention: UserId, + channels: &[ChannelId], + ) -> Result> { + let (bm, content) = self.get_beatmap(env).await?; + channels .iter() - .map(|c| self.send_message_to(*c, ctx, &bm, &content)) + .map(|c| self.send_message_to(mention, *c, &ctx, env, &bm, &content)) .collect::>() .try_collect() .await } - async fn get_beatmap(&self, ctx: &Context) -> Result<(BeatmapWithMode, BeatmapContent)> { - let env = ctx.data.read().await.get::().unwrap().clone(); + async fn get_beatmap(&self, env: &OsuEnv) -> Result<(BeatmapWithMode, BeatmapContent)> { let beatmap = env .beatmaps .get_beatmap_default(self.score.beatmap_id) @@ -294,12 +276,14 @@ impl<'a> CollectedScore<'a> { async fn send_message_to( &self, + mention: UserId, channel: ChannelId, - ctx: &Context, + ctx: impl CacheHttp, + env: &OsuEnv, bm: &BeatmapWithMode, content: &BeatmapContent, ) -> Result { - let guild = match channel.to_channel(&ctx.c).await?.guild() { + let guild = match channel.to_channel(&ctx).await?.guild() { Some(gc) => gc.guild_id, None => { eprintln!("Not a guild channel: {}", channel); @@ -307,27 +291,24 @@ impl<'a> CollectedScore<'a> { } }; - let member = match guild.member(&ctx.c, self.discord_user).await { + let member = match guild.member(&ctx, mention).await { Ok(mem) => mem, Err(e) => { - eprintln!("Cannot get member {}: {}", self.discord_user, e); + eprintln!("Cannot get member {}: {}", mention, e); return Err(e.into()); } }; let m = channel .send_message( - ctx.c.http(), + &ctx, CreateMessage::new() .content(match self.kind { ScoreType::TopRecord(_) => { - format!("New top record from {}!", self.discord_user.mention()) + format!("New top record from {}!", mention.mention()) } ScoreType::WorldRecord(rank) => { if rank <= 100 { - format!( - "New leaderboard record from {}!", - self.discord_user.mention() - ) + format!("New leaderboard record from {}!", mention.mention()) } else { format!("New leaderboard record from **{}**!", member.distinct()) } @@ -345,8 +326,6 @@ impl<'a> CollectedScore<'a> { ) .await?; - let env = ctx.data.read().await.get::().unwrap().clone(); - save_beatmap(&env, channel, bm).await.pls_ok(); Ok(m) } diff --git a/youmubot-osu/src/discord/db.rs b/youmubot-osu/src/discord/db.rs index ec7cc81..3323f83 100644 --- a/youmubot-osu/src/discord/db.rs +++ b/youmubot-osu/src/discord/db.rs @@ -1,4 +1,5 @@ use std::borrow::Cow; +use std::collections::HashMap as Map; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -29,17 +30,16 @@ impl OsuSavedUsers { impl OsuSavedUsers { /// Get all users pub async fn all(&self) -> Result> { - let mut conn = self.pool.acquire().await?; - model::OsuUser::all(&mut *conn) - .map(|v| v.map(OsuUser::from).map_err(Error::from)) - .try_collect() - .await + Ok(model::OsuUser::all(&self.pool) + .await? + .into_iter() + .map(|v| v.into()) + .collect()) } /// Get an user by their user_id. pub async fn by_user_id(&self, user_id: UserId) -> Result> { - let mut conn = self.pool.acquire().await?; - let u = model::OsuUser::by_user_id(user_id.get() as i64, &mut *conn) + let u = model::OsuUser::by_user_id(user_id.get() as i64, &self.pool) .await? .map(OsuUser::from); Ok(u) @@ -47,15 +47,17 @@ impl OsuSavedUsers { /// Save the given user. pub async fn save(&self, u: OsuUser) -> Result<()> { - let mut conn = self.pool.acquire().await?; - Ok(model::OsuUser::from(u).store(&mut *conn).await?) + let mut tx = self.pool.begin().await?; + model::OsuUser::from(u).store(&mut tx).await?; + tx.commit().await?; + Ok(()) } /// Save the given user as a completely new user. pub async fn new_user(&self, u: OsuUser) -> Result<()> { let mut t = self.pool.begin().await?; model::OsuUser::delete(u.user_id.get() as i64, &mut *t).await?; - model::OsuUser::from(u).store(&mut *t).await?; + model::OsuUser::from(u).store(&mut t).await?; t.commit().await?; Ok(()) } @@ -107,25 +109,30 @@ pub struct OsuUser { pub user_id: UserId, pub username: Cow<'static, str>, pub id: u64, - pub last_update: DateTime, - pub pp: [Option; 4], - pub std_weighted_map_length: Option, + pub modes: Map, /// More than 5 failures => gone pub failures: u8, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct OsuUserMode { + pub pp: f64, + pub map_length: f64, + pub map_age: i64, + pub last_update: DateTime, +} + impl From for model::OsuUser { fn from(u: OsuUser) -> Self { Self { user_id: u.user_id.get() as i64, username: Some(u.username.into_owned()), id: u.id as i64, - last_update: u.last_update, - pp_std: u.pp[Mode::Std as usize], - pp_taiko: u.pp[Mode::Taiko as usize], - pp_catch: u.pp[Mode::Catch as usize], - pp_mania: u.pp[Mode::Mania as usize], - std_weighted_map_length: u.std_weighted_map_length, + modes: u + .modes + .into_iter() + .map(|(k, v)| (k as u8, v.into())) + .collect(), failures: u.failures, } } @@ -137,19 +144,38 @@ impl From for OsuUser { user_id: UserId::new(u.user_id as u64), username: u.username.map(Cow::Owned).unwrap_or("unknown".into()), id: u.id as u64, - last_update: u.last_update, - pp: [0, 1, 2, 3].map(|v| match Mode::from(v) { - Mode::Std => u.pp_std, - Mode::Taiko => u.pp_taiko, - Mode::Catch => u.pp_catch, - Mode::Mania => u.pp_mania, - }), - std_weighted_map_length: u.std_weighted_map_length, + modes: u + .modes + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), failures: u.failures, } } } +impl From for model::OsuUserMode { + fn from(m: OsuUserMode) -> Self { + Self { + pp: m.pp, + map_length: m.map_length, + map_age: m.map_age, + last_update: m.last_update, + } + } +} + +impl From for OsuUserMode { + fn from(m: model::OsuUserMode) -> Self { + Self { + pp: m.pp, + map_length: m.map_length, + map_age: m.map_age, + last_update: m.last_update, + } + } +} + #[allow(dead_code)] mod legacy { use std::collections::HashMap; diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 0a8e88e..dee3361 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -1,5 +1,6 @@ -use std::{borrow::Borrow, str::FromStr, sync::Arc}; +use std::{borrow::Borrow, collections::HashMap as Map, str::FromStr, sync::Arc}; +use chrono::Utc; use futures_util::join; use interaction::{beatmap_components, score_components}; use rand::seq::IteratorRandom; @@ -14,7 +15,7 @@ use serenity::{ utils::MessageBuilder, }; -use db::{OsuLastBeatmap, OsuSavedUsers, OsuUser}; +use db::{OsuLastBeatmap, OsuSavedUsers, OsuUser, OsuUserMode}; use embeds::{beatmap_embed, score_embed, user_embed}; pub use hook::{dot_osu_hook, hook, score_hook}; use server_rank::{SERVER_RANK_COMMAND, SHOW_LEADERBOARD_COMMAND}; @@ -108,10 +109,7 @@ pub async fn setup( let beatmap_cache = BeatmapMetaCache::new(osu_client.clone(), prelude.sql.clone()); // Announcer - announcers.add( - announcer::ANNOUNCER_KEY, - announcer::Announcer::new(osu_client.clone()), - ); + announcers.add(announcer::ANNOUNCER_KEY, announcer::Announcer::new()); // Legacy data data.insert::(last_beatmaps.clone()); @@ -369,49 +367,53 @@ pub async fn forcesave(ctx: &Context, msg: &Message, mut args: Args) -> CommandR } async fn add_user(target: serenity::model::id::UserId, user: User, env: &OsuEnv) -> Result<()> { - let pp_fut = async { - [Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania] - .into_iter() - .map(|mode| async move { + let modes = [Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania] + .into_iter() + .map(|mode| async move { + let pp = async { env.client .user(&UserID::ID(user.id), |f| f.mode(mode)) .await - .unwrap_or_else(|err| { - eprintln!("{}", err); - None - }) + .pls_ok() + .unwrap_or(None) .and_then(|u| u.pp) + }; + let map_length = async { + let scores = env + .client + .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) + .await + .pls_ok() + .unwrap_or_else(|| vec![]); + + calculate_weighted_map_length(&scores, &env.beatmaps, mode) + .await + .pls_ok() + }; + let (pp, map_length) = join!(pp, map_length); + pp.zip(map_length).map(|(pp, map_length)| { + ( + mode, + OsuUserMode { + pp, + map_length, + map_age: 0, // TODO + last_update: Utc::now(), + }, + ) }) - .collect::>() - .collect::>() - .await - }; - - let std_weight_map_length_fut = async { - let scores = env - .client - .user_best(UserID::ID(user.id), |f| f.mode(Mode::Std).limit(100)) - .await - .unwrap_or_else(|err| { - eprintln!("{}", err); - vec![] - }); - - calculate_weighted_map_length(&scores, &env.beatmaps, Mode::Std) - .await - .pls_ok() - }; - - let (pp, std_weight_map_length) = join!(pp_fut, std_weight_map_length_fut); + }) + .collect::>() + .filter_map(|v| future::ready(v)) + .collect::>() + .await; let u = OsuUser { user_id: target, username: user.username.into(), id: user.id, failures: 0, - last_update: chrono::Utc::now(), - pp: pp.try_into().unwrap(), - std_weighted_map_length: std_weight_map_length, + modes, }; env.saved_users.new_user(u).await?; Ok(()) diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 350a03e..ddd82eb 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -1,8 +1,8 @@ -use std::{collections::HashMap, str::FromStr, sync::Arc}; +use std::{borrow::Cow, collections::HashMap, str::FromStr, sync::Arc}; use pagination::paginate_with_first_message; use serenity::{ - all::GuildId, + all::{GuildId, Member}, builder::EditMessage, framework::standard::{macros::command, Args, CommandResult}, model::channel::Message, @@ -17,7 +17,7 @@ use youmubot_prelude::{ }; use crate::{ - discord::{display::ScoreListStyle, oppai_cache::Accuracy, BeatmapWithMode}, + discord::{db::OsuUser, display::ScoreListStyle, oppai_cache::Accuracy, BeatmapWithMode}, models::{Mode, Mods}, request::UserID, Score, @@ -25,21 +25,57 @@ use crate::{ use super::{ModeArg, OsuEnv}; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] enum RankQuery { - Total, + #[default] + PP, + TotalPP, MapLength, - Mode(Mode), + // MapAge, +} + +impl RankQuery { + // fn col_name(&self) -> &'static str { + // match self { + // RankQuery::PP => "pp", + // RankQuery::TotalPP => "Total pp", + // RankQuery::MapLength => "Map length", + // } + // } + fn extract_row(&self, mode: Mode, ou: &OsuUser) -> Cow<'static, str> { + match self { + RankQuery::PP => ou + .modes + .get(&mode) + .map(|v| format!("{:.02}", v.pp).into()) + .unwrap_or_else(|| "-".into()), + RankQuery::TotalPP => { + format!("{:.02}", ou.modes.values().map(|v| v.pp).sum::()).into() + } + RankQuery::MapLength => ou + .modes + .get(&mode) + .map(|v| { + let len = v.map_length; + let trunc_secs = len.floor() as u64; + let minutes = trunc_secs / 60; + let seconds = len - (60 * minutes) as f64; + format!("{}m{:05.2}s", minutes, seconds).into() + }) + .unwrap_or_else(|| "-".into()), + } + } } impl FromStr for RankQuery { - type Err = ::Err; + type Err = String; fn from_str(s: &str) -> Result { match s { - "total" => Ok(RankQuery::Total), + "pp" => Ok(RankQuery::PP), + "total" | "total-pp" => Ok(RankQuery::TotalPP), "map-length" => Ok(RankQuery::MapLength), - _ => ModeArg::from_str(s).map(|ModeArg(m)| RankQuery::Mode(m)), + _ => Err(format!("not a query: {}", s)), } } } @@ -51,52 +87,61 @@ impl FromStr for RankQuery { #[only_in(guilds)] pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandResult { let env = ctx.data.read().await.get::().unwrap().clone(); - let mode = args - .single::() - .unwrap_or(RankQuery::Mode(Mode::Std)); + let mode = args.find::().map(|v| v.0).unwrap_or(Mode::Std); + let query = args.single::().unwrap_or_default(); let guild = m.guild_id.expect("Guild-only command"); - let osu_users = env + let mut users = env .saved_users .all() .await? .into_iter() .map(|v| (v.user_id, v)) .collect::>(); - - let users = env + let mut users = env .prelude .members .query_members(&ctx, guild) .await? .iter() - .filter_map(|m| osu_users.get(&m.user.id).map(|ou| (m, ou))) - .filter_map(|(member, osu_user)| { - let pp = match mode { - RankQuery::Total if osu_user.pp.iter().any(|v| v.is_some_and(|v| v > 0.0)) => { - Some(osu_user.pp.iter().map(|v| v.unwrap_or(0.0)).sum()) - } - RankQuery::MapLength => osu_user.pp.get(Mode::Std as usize).and_then(|v| *v), - RankQuery::Mode(m) => osu_user.pp.get(m as usize).and_then(|v| *v), - _ => None, - }?; - Some((pp, member.user.name.clone(), osu_user)) + .filter_map(|m| users.remove(&m.user.id).map(|ou| (m.clone(), ou))) + .collect::>(); + let last_update = users + .iter() + .filter_map(|(_, u)| { + if query == RankQuery::TotalPP { + u.modes.values().map(|v| v.last_update).min() + } else { + u.modes.get(&mode).map(|v| v.last_update) + } }) - .collect::>(); - let last_update = users.iter().map(|(_, _, a)| a.last_update).min(); - let mut users = users - .into_iter() - .map(|(a, b, u)| (a, (b, u.clone()))) - .collect::>(); - if matches!(mode, RankQuery::MapLength) { - users.sort_by(|(_, (_, a)), (_, (_, b))| { - (b.std_weighted_map_length) - .partial_cmp(&a.std_weighted_map_length) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } else { - users.sort_by(|(a, _), (b, _)| (*b).partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); - } + .min(); + let sort_fn: Box std::cmp::Ordering> = + match query { + RankQuery::PP => Box::new(|(_, a), (_, b)| { + a.modes + .get(&mode) + .map(|v| v.pp) + .partial_cmp(&b.modes.get(&mode).map(|v| v.pp)) + .unwrap() + }), + RankQuery::TotalPP => Box::new(|(_, a), (_, b)| { + a.modes + .values() + .map(|v| v.pp) + .sum::() + .partial_cmp(&b.modes.values().map(|v| v.pp).sum()) + .unwrap() + }), + RankQuery::MapLength => Box::new(|(_, a), (_, b)| { + a.modes + .get(&mode) + .map(|v| v.map_length) + .partial_cmp(&b.modes.get(&mode).map(|v| v.map_length)) + .unwrap() + }), + }; + users.sort_unstable_by(sort_fn); if users.is_empty() { m.reply(&ctx, "No saved users in the current server...") @@ -120,7 +165,7 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR return Ok(false); } let users = &users[start..end]; - let table = if matches!(mode, RankQuery::Mode(Mode::Std) | RankQuery::MapLength) { + let table = { const HEADERS: [&'static str; 5] = ["#", "pp", "Map length", "Username", "Member"]; const ALIGNS: [Align; 5] = [Right, Right, Right, Left, Left]; @@ -128,39 +173,13 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR let table = users .iter() .enumerate() - .map(|(i, (pp, (mem, ou)))| { - let map_length = match ou.std_weighted_map_length { - Some(len) => { - let trunc_secs = len.floor() as u64; - let minutes = trunc_secs / 60; - let seconds = len - (60 * minutes) as f64; - format!("{}m{:05.2}s", minutes, seconds) - } - None => "unknown".to_owned(), - }; + .map(|(i, (mem, ou))| { [ format!("{}", 1 + i + start), - format!("{:.2}", pp), - map_length, - ou.username.clone().into_owned(), - mem.clone(), - ] - }) - .collect::>(); - table_formatting(&HEADERS, &ALIGNS, table) - } else { - const HEADERS: [&'static str; 4] = ["#", "pp", "Username", "Member"]; - const ALIGNS: [Align; 4] = [Right, Right, Left, Left]; - - let table = users - .iter() - .enumerate() - .map(|(i, (pp, (mem, ou)))| { - [ - format!("{}", 1 + i + start), - format!("{:.2}", pp), - ou.username.clone().into_owned(), - mem.clone(), + RankQuery::PP.extract_row(mode, ou).to_string(), + RankQuery::MapLength.extract_row(mode, ou).to_string(), + ou.username.to_string(), + mem.distinct(), ] }) .collect::>();