diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index 7c36144..4fe9dc1 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -19,6 +19,7 @@ use youmubot_prelude::announcer::CacheAndHttp; use youmubot_prelude::stream::TryStreamExt; use youmubot_prelude::*; +use crate::discord::calculate_weighted_map_age; use crate::discord::db::OsuUserMode; use crate::{ discord::cache::save_beatmap, @@ -114,7 +115,10 @@ impl Announcer { .await .pls_ok() .unwrap_or(0.0), - map_age: 0, // soon + map_age: calculate_weighted_map_age(&top, &env.beatmaps, mode) + .await + .pls_ok() + .unwrap_or(0), last_update: now, }; let last = user.modes.insert(mode, stats); diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index 97753ef..4c66846 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -457,8 +457,28 @@ impl<'a> ScoreEmbedBuilder<'a> { pub(crate) fn user_embed( u: User, map_length: f64, + map_age: i64, best: Option<(Score, BeatmapWithMode, BeatmapInfo)>, ) -> CreateEmbed { + let mut stats = Vec::<(&'static str, String, bool)>::new(); + if map_length > 0.0 { + stats.push(( + "Weighted Map Length", + { + let secs = map_length.floor() as u64; + let minutes = secs / 60; + let seconds = map_length - (60 * minutes) as f64; + format!( + "**{}**mins **{:05.2}**s (**{:.2}**s)", + minutes, seconds, map_length + ) + }, + true, + )) + } + if map_age > 0 { + stats.push(("Weighted Map Age", format!("", map_age), true)) + } CreateEmbed::new() .title(MessageBuilder::new().push_safe(u.username).build()) .url(format!("https://osu.ppy.sh/users/{}", u.id)) @@ -504,19 +524,7 @@ pub(crate) fn user_embed( ), false, ) - .field( - "Weighted Map Length", - { - let secs = map_length.floor() as u64; - let minutes = secs / 60; - let seconds = map_length - (60 * minutes) as f64; - format!( - "**{}** minutes **{:05.2}** seconds (**{:.2}**s)", - minutes, seconds, map_length - ) - }, - false, - ) + .fields(stats) .field( format!("Level {:.0}", u.level), format!( diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index dee3361..19e9fd9 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -1,6 +1,7 @@ use std::{borrow::Borrow, collections::HashMap as Map, str::FromStr, sync::Arc}; use chrono::Utc; +use future::try_join; use futures_util::join; use interaction::{beatmap_components, score_components}; use rand::seq::IteratorRandom; @@ -19,6 +20,7 @@ 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}; +use stream::FuturesOrdered; use youmubot_prelude::announcer::AnnouncerHandler; use youmubot_prelude::{stream::FuturesUnordered, *}; @@ -378,7 +380,7 @@ async fn add_user(target: serenity::model::id::UserId, user: User, env: &OsuEnv) .unwrap_or(None) .and_then(|u| u.pp) }; - let map_length = async { + let map_length_age = async { let scores = env .client .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) @@ -386,22 +388,29 @@ async fn add_user(target: serenity::model::id::UserId, user: User, env: &OsuEnv) .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(), - }, + calculate_weighted_map_length(&scores, &env.beatmaps, mode) + .await + .pls_ok(), + calculate_weighted_map_age(&scores, &env.beatmaps, mode) + .await + .pls_ok(), ) - }) + }; + let (pp, (map_length, map_age)) = join!(pp, map_length_age); + pp.zip(map_length) + .zip(map_age) + .map(|((pp, map_length), map_age)| { + ( + mode, + OsuUserMode { + pp, + map_length, + map_age, + last_update: Utc::now(), + }, + ) + }) }) .collect::>() .filter_map(|v| future::ready(v)) @@ -837,7 +846,9 @@ async fn get_user( let bests = osu_client .user_best(UserID::ID(u.id), |f| f.limit(100).mode(mode)) .await?; - let map_length = calculate_weighted_map_length(&bests, meta_cache, mode).await?; + let map_length = calculate_weighted_map_length(&bests, meta_cache, mode); + let map_age = calculate_weighted_map_age(&bests, meta_cache, mode); + let (map_length, map_age) = try_join(map_length, map_age).await?; let best = match bests.into_iter().next() { Some(m) => { let beatmap = meta_cache.get_beatmap(m.beatmap_id, mode).await?; @@ -858,7 +869,7 @@ async fn get_user( "{}: here is the user that you requested", msg.author )) - .embed(user_embed(u, map_length, best)), + .embed(user_embed(u, map_length, map_age, best)), ) .await?; } @@ -891,3 +902,36 @@ pub(in crate::discord) async fn calculate_weighted_map_length( .try_fold(0.0, |a, b| future::ready(Ok(a + b))) .await } + +pub(in crate::discord) async fn calculate_weighted_map_age( + from_scores: impl IntoIterator, + cache: &BeatmapMetaCache, + mode: Mode, +) -> Result { + const SCALING_FACTOR: f64 = 0.95; + let scales = (0..100) + .scan(1.0, |a, _| Some(*a * SCALING_FACTOR)) + .collect::>(); + let scores = from_scores + .into_iter() + .map(|s| async move { + let beatmap = cache.get_beatmap(s.beatmap_id, mode).await?; + Ok( + if let crate::ApprovalStatus::Ranked(at) = beatmap.approval { + at.timestamp() as f64 + } else { + 0.0 + }, + ) as Result<_> + }) + .collect::>() + .try_collect::>() + .await?; + Ok((scores + .iter() + .zip(scales.iter()) + .map(|(a, b)| a * b) + .sum::() + / scales.iter().take(scores.len()).sum::()) + .floor() as i64) +} diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 8f915cd..cdc4b28 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -1,5 +1,6 @@ use std::{borrow::Cow, collections::HashMap, str::FromStr, sync::Arc}; +use chrono::DateTime; use pagination::paginate_with_first_message; use serenity::{ all::{GuildId, Member}, @@ -31,17 +32,20 @@ enum RankQuery { PP, TotalPP, MapLength, - // MapAge, + MapAge { + newest_first: bool, + }, } impl RankQuery { - // fn col_name(&self) -> &'static str { - // match self { - // RankQuery::PP => "pp", - // RankQuery::TotalPP => "Total pp", - // RankQuery::MapLength => "Map length", - // } - // } + fn col_name(&self) -> &'static str { + match self { + RankQuery::PP => "pp", + RankQuery::TotalPP => "Total pp", + RankQuery::MapLength => "Map length", + RankQuery::MapAge { newest_first: _ } => "Map age", + } + } fn extract_row(&self, mode: Mode, ou: &OsuUser) -> Cow<'static, str> { match self { RankQuery::PP => ou @@ -63,6 +67,12 @@ impl RankQuery { format!("{}m{:05.2}s", minutes, seconds).into() }) .unwrap_or_else(|| "-".into()), + RankQuery::MapAge { newest_first: _ } => ou + .modes + .get(&mode) + .and_then(|v| DateTime::from_timestamp(v.map_age, 0)) + .map(|time| time.format("%F %T").to_string().into()) + .unwrap_or_else(|| "-".into()), } } } @@ -75,6 +85,10 @@ impl FromStr for RankQuery { "pp" => Ok(RankQuery::PP), "total" | "total-pp" => Ok(RankQuery::TotalPP), "map-length" => Ok(RankQuery::MapLength), + "age" | "map-age" => Ok(RankQuery::MapAge { newest_first: true }), + "old" | "age-old" | "map-age-old" => Ok(RankQuery::MapAge { + newest_first: false, + }), _ => Err(format!("not a query: {}", s)), } } @@ -143,6 +157,19 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR .unwrap() .reverse() }), + RankQuery::MapAge { newest_first } => Box::new(move |(_, a), (_, b)| { + let r = a + .modes + .get(&mode) + .map(|v| v.map_age) + .partial_cmp(&b.modes.get(&mode).map(|v| v.map_age)) + .unwrap(); + if newest_first { + r.reverse() + } else { + r + } + }), }; users.sort_unstable_by(sort_fn); @@ -169,20 +196,8 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR } let users = &users[start..end]; let table = match query { - RankQuery::PP | RankQuery::MapLength => { - let (headers, first_col, second_col) = if query == RankQuery::PP { - ( - ["#", "pp", "Map length", "Username", "Member"], - RankQuery::PP, - RankQuery::MapLength, - ) - } else { - ( - ["#", "Map length", "pp", "Username", "Member"], - RankQuery::MapLength, - RankQuery::PP, - ) - }; + RankQuery::MapAge { newest_first: _ } | RankQuery::MapLength => { + let headers = ["#", query.col_name(), "pp", "Username", "Member"]; const ALIGNS: [Align; 5] = [Right, Right, Right, Left, Left]; let table = users @@ -191,8 +206,8 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR .map(|(i, (mem, ou))| { [ format!("{}", 1 + i + start), - first_col.extract_row(mode, ou).to_string(), - second_col.extract_row(mode, ou).to_string(), + query.extract_row(mode, ou).to_string(), + RankQuery::PP.extract_row(mode, ou).to_string(), ou.username.to_string(), mem.distinct(), ] @@ -200,6 +215,31 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR .collect::>(); table_formatting(&headers, &ALIGNS, table) } + RankQuery::PP => { + const HEADERS: [&'static str; 6] = + ["#", "pp", "Map length", "Map age", "Username", "Member"]; + const ALIGNS: [Align; 6] = [Right, Right, Right, Right, Left, Left]; + + let table = users + .iter() + .enumerate() + .map(|(i, (mem, ou))| { + [ + format!("{}", 1 + i + start), + RankQuery::PP.extract_row(mode, ou).to_string(), + RankQuery::MapLength.extract_row(mode, ou).to_string(), + (RankQuery::MapAge { + newest_first: false, + }) + .extract_row(mode, ou) + .to_string(), + ou.username.to_string(), + mem.distinct(), + ] + }) + .collect::>(); + table_formatting(&HEADERS, &ALIGNS, table) + } RankQuery::TotalPP => { const HEADERS: [&'static str; 4] = ["#", "Total pp", "Username", "Member"]; const ALIGNS: [Align; 4] = [Right, Right, Left, Left];