Update osu to respond to the new sql format and rewrite announcer

This commit is contained in:
Natsu Kagami 2024-08-04 20:17:58 +02:00 committed by Natsu Kagami
parent e733364d15
commit a4407df97c
4 changed files with 349 additions and 323 deletions

View file

@ -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::builder::CreateMessage;
use serenity::{ use serenity::{
@ -14,6 +17,7 @@ use youmubot_prelude::announcer::CacheAndHttp;
use youmubot_prelude::stream::TryStreamExt; use youmubot_prelude::stream::TryStreamExt;
use youmubot_prelude::*; use youmubot_prelude::*;
use crate::discord::db::OsuUserMode;
use crate::{ use crate::{
discord::cache::save_beatmap, discord::cache::save_beatmap,
discord::oppai_cache::BeatmapContent, discord::oppai_cache::BeatmapContent,
@ -22,22 +26,21 @@ use crate::{
Client as Osu, Client as Osu,
}; };
use super::db::{OsuSavedUsers, OsuUser}; use super::db::OsuUser;
use super::interaction::score_components; use super::interaction::score_components;
use super::{calculate_weighted_map_length, OsuEnv}; use super::{calculate_weighted_map_length, OsuEnv};
use super::{embeds::score_embed, BeatmapWithMode}; use super::{embeds::score_embed, BeatmapWithMode};
/// osu! announcer's unique announcer key. /// osu! announcer's unique announcer key.
pub const ANNOUNCER_KEY: &str = "osu"; pub const ANNOUNCER_KEY: &str = "osu";
const MAX_FAILURES: u8 = 64;
/// The announcer struct implementing youmubot_prelude::Announcer /// The announcer struct implementing youmubot_prelude::Announcer
pub struct Announcer { pub struct Announcer {}
client: Arc<Osu>,
}
impl Announcer { impl Announcer {
pub fn new(client: Arc<Osu>) -> Self { pub fn new() -> Self {
Self { client } Self {}
} }
} }
@ -45,71 +48,26 @@ impl Announcer {
impl youmubot_prelude::Announcer for Announcer { impl youmubot_prelude::Announcer for Announcer {
async fn updates( async fn updates(
&mut self, &mut self,
c: CacheAndHttp, ctx: CacheAndHttp,
d: AppData, d: AppData,
channels: MemberToChannels, channels: MemberToChannels,
) -> Result<()> { ) -> Result<()> {
let env = d.read().await.get::<OsuEnv>().unwrap().clone();
// For each user... // For each user...
let users = { let users = env.saved_users.all().await?;
let env = d.read().await.get::<OsuEnv>().unwrap().clone();
env.saved_users.all().await?
};
let now = chrono::Utc::now();
users users
.into_iter() .into_iter()
.map(|mut osu_user| { .map(
let user_id = osu_user.user_id; |osu_user| {
let channels = &channels; channels
let ctx = Context { .channels_of(ctx.clone(), osu_user.user_id)
c: c.clone(), .then(|chs| self.update_user(ctx.clone(), &env, osu_user, chs))
data: d.clone(), .then(|new_user| env.saved_users.save(new_user))
}; .map(|r| {
let s = &*self; r.pls_ok();
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)
}) })
.collect::<stream::FuturesOrdered<_>>() }, // self.update_user()
.try_collect::<Vec<_>>() )
.await
{
Ok(v) => {
osu_user.pp = v
.iter()
.map(|u| u.pp)
.collect::<Vec<_>>()
.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::<OsuSavedUsers>()
.unwrap()
.save(osu_user)
.await
.pls_ok();
println!("updating {} done", id);
}
Err(e) => {
eprintln!("osu: Cannot update {}: {}", osu_user.id, e);
}
};
}
})
.collect::<stream::FuturesUnordered<_>>() .collect::<stream::FuturesUnordered<_>>()
.collect::<()>() .collect::<()>()
.await; .await;
@ -118,98 +76,128 @@ impl youmubot_prelude::Announcer for Announcer {
} }
impl Announcer { impl Announcer {
/// Handles an user/mode scan, announces all possible new scores, return the new pp value. async fn update_user(
async fn handle_user_mode(
&self, &self,
ctx: &Context, ctx: impl CacheHttp + Clone + 'static,
env: &OsuEnv,
mut user: OsuUser,
broadcast_to: Vec<ChannelId>,
) -> 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::<FuturesUnordered<_>>()
.filter_map(|v| future::ready(v.pls_ok()))
.collect::<Vec<_>>()
.await
.into_iter();
top.chain(recents)
.map(|v| v.send_message(&ctx, &env, mention, &broadcast_to))
.collect::<FuturesUnordered<_>>()
.filter_map(|v| future::ready(v.pls_ok().map(|_| ())))
.collect::<()>()
.await
});
}
user.failures = 0;
user
}
fn is_announceable_date(
s: DateTime<Utc>,
last_update: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> 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<chrono::Utc>, now: chrono::DateTime<chrono::Utc>,
osu_user: &OsuUser, osu_user: &OsuUser,
user_id: UserId,
channels: Vec<ChannelId>,
mode: Mode, mode: Mode,
) -> Result<User, Error> { ) -> Result<(User, Vec<Score>, Vec<UserEventRank>), Error> {
let days_since_last_update = (now - osu_user.last_update).num_days() + 1; let stats = osu_user.modes.get(&mode).cloned();
let last_update = osu_user.last_update; let last_update = stats.as_ref().map(|v| v.last_update);
let (scores, user) = { let user_id = UserID::ID(osu_user.id);
let scores = self.scan_user(osu_user, mode).await?; let user = {
let user = self let days_since_last_update = stats
.client .as_ref()
.user(&UserID::ID(osu_user.id), |f| { .map(|v| (now - v.last_update).num_days() + 1)
f.mode(mode) .unwrap_or(30);
.event_days(days_since_last_update.min(31) as u8) env.client.user(&user_id, move |f| {
}) f.mode(mode)
.await? .event_days(days_since_last_update.min(31) as u8)
.ok_or_else(|| Error::msg("user not found"))?; })
(scores, user)
}; };
let client = self.client.clone(); let top_scores = env
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::<stream::FuturesUnordered<_>>()
.filter_map(|u| future::ready(u.pls_ok()))
.collect::<Vec<_>>()
.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::<stream::FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await
.pls_ok();
});
Ok(_user)
}
async fn scan_user(&self, u: &OsuUser, mode: Mode) -> Result<Vec<(u8, Score)>, Error> {
let scores = self
.client .client
.user_best(UserID::ID(u.id), |f| f.mode(mode).limit(25)) .user_best(user_id.clone(), |f| f.mode(mode).limit(100));
.await?; let (user, top_scores) = try_join!(user, top_scores)?;
let scores = scores let mut user = user.unwrap();
// if top scores exist, user would too
let events = std::mem::replace(&mut user.events, vec![])
.into_iter() .into_iter()
.enumerate() .filter_map(|v| v.to_event_rank())
.filter(|(_, s)| s.date >= u.last_update) .filter(|s| Self::is_announceable_date(s.date, last_update, now))
.map(|(i, v)| ((i + 1) as u8, v)) .collect::<Vec<_>>();
.collect(); Ok((user, top_scores, events))
Ok(scores)
} }
async fn std_weighted_map_length(ctx: &Context, u: &OsuUser) -> Result<f64> {
let env = ctx.data.read().await.get::<OsuEnv>().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> { struct CollectedScore<'a> {
@ -217,27 +205,15 @@ struct CollectedScore<'a> {
pub score: Score, pub score: Score,
pub mode: Mode, pub mode: Mode,
pub kind: ScoreType, pub kind: ScoreType,
pub discord_user: UserId,
pub channels: &'a [ChannelId],
} }
impl<'a> CollectedScore<'a> { impl<'a> CollectedScore<'a> {
fn from_top_score( fn from_top_score(user: &'a User, score: Score, mode: Mode, rank: u8) -> Self {
user: &'a User,
score: Score,
mode: Mode,
rank: u8,
discord_user: UserId,
channels: &'a [ChannelId],
) -> Self {
Self { Self {
user, user,
score, score,
mode, mode,
kind: ScoreType::TopRecord(rank), kind: ScoreType::TopRecord(rank),
discord_user,
channels,
} }
} }
@ -245,8 +221,6 @@ impl<'a> CollectedScore<'a> {
osu: &Osu, osu: &Osu,
user: &'a User, user: &'a User,
event: UserEventRank, event: UserEventRank,
discord_user: UserId,
channels: &'a [ChannelId],
) -> Result<CollectedScore<'a>> { ) -> Result<CollectedScore<'a>> {
let scores = osu let scores = osu
.scores(event.beatmap_id, |f| { .scores(event.beatmap_id, |f| {
@ -258,32 +232,40 @@ impl<'a> CollectedScore<'a> {
.find(|s| (s.date - event.date).abs() < chrono::TimeDelta::seconds(5)) .find(|s| (s.date - event.date).abs() < chrono::TimeDelta::seconds(5))
{ {
Some(v) => v, 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 { Ok(Self {
user, user,
score, score,
mode: event.mode, mode: event.mode,
kind: ScoreType::WorldRecord(event.rank), kind: ScoreType::WorldRecord(event.rank),
discord_user,
channels,
}) })
} }
} }
impl<'a> CollectedScore<'a> { impl<'a> CollectedScore<'a> {
async fn send_message(self, ctx: &Context) -> Result<Vec<Message>> { async fn send_message(
let (bm, content) = self.get_beatmap(ctx).await?; self,
self.channels ctx: impl CacheHttp,
env: &OsuEnv,
mention: UserId,
channels: &[ChannelId],
) -> Result<Vec<Message>> {
let (bm, content) = self.get_beatmap(env).await?;
channels
.iter() .iter()
.map(|c| self.send_message_to(*c, ctx, &bm, &content)) .map(|c| self.send_message_to(mention, *c, &ctx, env, &bm, &content))
.collect::<stream::FuturesUnordered<_>>() .collect::<stream::FuturesUnordered<_>>()
.try_collect() .try_collect()
.await .await
} }
async fn get_beatmap(&self, ctx: &Context) -> Result<(BeatmapWithMode, BeatmapContent)> { async fn get_beatmap(&self, env: &OsuEnv) -> Result<(BeatmapWithMode, BeatmapContent)> {
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
let beatmap = env let beatmap = env
.beatmaps .beatmaps
.get_beatmap_default(self.score.beatmap_id) .get_beatmap_default(self.score.beatmap_id)
@ -294,12 +276,14 @@ impl<'a> CollectedScore<'a> {
async fn send_message_to( async fn send_message_to(
&self, &self,
mention: UserId,
channel: ChannelId, channel: ChannelId,
ctx: &Context, ctx: impl CacheHttp,
env: &OsuEnv,
bm: &BeatmapWithMode, bm: &BeatmapWithMode,
content: &BeatmapContent, content: &BeatmapContent,
) -> Result<Message> { ) -> Result<Message> {
let guild = match channel.to_channel(&ctx.c).await?.guild() { let guild = match channel.to_channel(&ctx).await?.guild() {
Some(gc) => gc.guild_id, Some(gc) => gc.guild_id,
None => { None => {
eprintln!("Not a guild channel: {}", channel); 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, Ok(mem) => mem,
Err(e) => { Err(e) => {
eprintln!("Cannot get member {}: {}", self.discord_user, e); eprintln!("Cannot get member {}: {}", mention, e);
return Err(e.into()); return Err(e.into());
} }
}; };
let m = channel let m = channel
.send_message( .send_message(
ctx.c.http(), &ctx,
CreateMessage::new() CreateMessage::new()
.content(match self.kind { .content(match self.kind {
ScoreType::TopRecord(_) => { ScoreType::TopRecord(_) => {
format!("New top record from {}!", self.discord_user.mention()) format!("New top record from {}!", mention.mention())
} }
ScoreType::WorldRecord(rank) => { ScoreType::WorldRecord(rank) => {
if rank <= 100 { if rank <= 100 {
format!( format!("New leaderboard record from {}!", mention.mention())
"New leaderboard record from {}!",
self.discord_user.mention()
)
} else { } else {
format!("New leaderboard record from **{}**!", member.distinct()) format!("New leaderboard record from **{}**!", member.distinct())
} }
@ -345,8 +326,6 @@ impl<'a> CollectedScore<'a> {
) )
.await?; .await?;
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
save_beatmap(&env, channel, bm).await.pls_ok(); save_beatmap(&env, channel, bm).await.pls_ok();
Ok(m) Ok(m)
} }

View file

@ -1,4 +1,5 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap as Map;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -29,17 +30,16 @@ impl OsuSavedUsers {
impl OsuSavedUsers { impl OsuSavedUsers {
/// Get all users /// Get all users
pub async fn all(&self) -> Result<Vec<OsuUser>> { pub async fn all(&self) -> Result<Vec<OsuUser>> {
let mut conn = self.pool.acquire().await?; Ok(model::OsuUser::all(&self.pool)
model::OsuUser::all(&mut *conn) .await?
.map(|v| v.map(OsuUser::from).map_err(Error::from)) .into_iter()
.try_collect() .map(|v| v.into())
.await .collect())
} }
/// Get an user by their user_id. /// Get an user by their user_id.
pub async fn by_user_id(&self, user_id: UserId) -> Result<Option<OsuUser>> { pub async fn by_user_id(&self, user_id: UserId) -> Result<Option<OsuUser>> {
let mut conn = self.pool.acquire().await?; let u = model::OsuUser::by_user_id(user_id.get() as i64, &self.pool)
let u = model::OsuUser::by_user_id(user_id.get() as i64, &mut *conn)
.await? .await?
.map(OsuUser::from); .map(OsuUser::from);
Ok(u) Ok(u)
@ -47,15 +47,17 @@ impl OsuSavedUsers {
/// Save the given user. /// Save the given user.
pub async fn save(&self, u: OsuUser) -> Result<()> { pub async fn save(&self, u: OsuUser) -> Result<()> {
let mut conn = self.pool.acquire().await?; let mut tx = self.pool.begin().await?;
Ok(model::OsuUser::from(u).store(&mut *conn).await?) model::OsuUser::from(u).store(&mut tx).await?;
tx.commit().await?;
Ok(())
} }
/// Save the given user as a completely new user. /// Save the given user as a completely new user.
pub async fn new_user(&self, u: OsuUser) -> Result<()> { pub async fn new_user(&self, u: OsuUser) -> Result<()> {
let mut t = self.pool.begin().await?; let mut t = self.pool.begin().await?;
model::OsuUser::delete(u.user_id.get() as i64, &mut *t).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?; t.commit().await?;
Ok(()) Ok(())
} }
@ -107,25 +109,30 @@ pub struct OsuUser {
pub user_id: UserId, pub user_id: UserId,
pub username: Cow<'static, str>, pub username: Cow<'static, str>,
pub id: u64, pub id: u64,
pub last_update: DateTime<Utc>, pub modes: Map<Mode, OsuUserMode>,
pub pp: [Option<f64>; 4],
pub std_weighted_map_length: Option<f64>,
/// More than 5 failures => gone /// More than 5 failures => gone
pub failures: u8, 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<Utc>,
}
impl From<OsuUser> for model::OsuUser { impl From<OsuUser> for model::OsuUser {
fn from(u: OsuUser) -> Self { fn from(u: OsuUser) -> Self {
Self { Self {
user_id: u.user_id.get() as i64, user_id: u.user_id.get() as i64,
username: Some(u.username.into_owned()), username: Some(u.username.into_owned()),
id: u.id as i64, id: u.id as i64,
last_update: u.last_update, modes: u
pp_std: u.pp[Mode::Std as usize], .modes
pp_taiko: u.pp[Mode::Taiko as usize], .into_iter()
pp_catch: u.pp[Mode::Catch as usize], .map(|(k, v)| (k as u8, v.into()))
pp_mania: u.pp[Mode::Mania as usize], .collect(),
std_weighted_map_length: u.std_weighted_map_length,
failures: u.failures, failures: u.failures,
} }
} }
@ -137,19 +144,38 @@ impl From<model::OsuUser> for OsuUser {
user_id: UserId::new(u.user_id as u64), user_id: UserId::new(u.user_id as u64),
username: u.username.map(Cow::Owned).unwrap_or("unknown".into()), username: u.username.map(Cow::Owned).unwrap_or("unknown".into()),
id: u.id as u64, id: u.id as u64,
last_update: u.last_update, modes: u
pp: [0, 1, 2, 3].map(|v| match Mode::from(v) { .modes
Mode::Std => u.pp_std, .into_iter()
Mode::Taiko => u.pp_taiko, .map(|(k, v)| (k.into(), v.into()))
Mode::Catch => u.pp_catch, .collect(),
Mode::Mania => u.pp_mania,
}),
std_weighted_map_length: u.std_weighted_map_length,
failures: u.failures, failures: u.failures,
} }
} }
} }
impl From<OsuUserMode> 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<model::OsuUserMode> 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)] #[allow(dead_code)]
mod legacy { mod legacy {
use std::collections::HashMap; use std::collections::HashMap;

View file

@ -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 futures_util::join;
use interaction::{beatmap_components, score_components}; use interaction::{beatmap_components, score_components};
use rand::seq::IteratorRandom; use rand::seq::IteratorRandom;
@ -14,7 +15,7 @@ use serenity::{
utils::MessageBuilder, utils::MessageBuilder,
}; };
use db::{OsuLastBeatmap, OsuSavedUsers, OsuUser}; use db::{OsuLastBeatmap, OsuSavedUsers, OsuUser, OsuUserMode};
use embeds::{beatmap_embed, score_embed, user_embed}; use embeds::{beatmap_embed, score_embed, user_embed};
pub use hook::{dot_osu_hook, hook, score_hook}; pub use hook::{dot_osu_hook, hook, score_hook};
use server_rank::{SERVER_RANK_COMMAND, SHOW_LEADERBOARD_COMMAND}; 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()); let beatmap_cache = BeatmapMetaCache::new(osu_client.clone(), prelude.sql.clone());
// Announcer // Announcer
announcers.add( announcers.add(announcer::ANNOUNCER_KEY, announcer::Announcer::new());
announcer::ANNOUNCER_KEY,
announcer::Announcer::new(osu_client.clone()),
);
// Legacy data // Legacy data
data.insert::<OsuLastBeatmap>(last_beatmaps.clone()); data.insert::<OsuLastBeatmap>(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<()> { async fn add_user(target: serenity::model::id::UserId, user: User, env: &OsuEnv) -> Result<()> {
let pp_fut = async { let modes = [Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania]
[Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania] .into_iter()
.into_iter() .map(|mode| async move {
.map(|mode| async move { let pp = async {
env.client env.client
.user(&UserID::ID(user.id), |f| f.mode(mode)) .user(&UserID::ID(user.id), |f| f.mode(mode))
.await .await
.unwrap_or_else(|err| { .pls_ok()
eprintln!("{}", err); .unwrap_or(None)
None
})
.and_then(|u| u.pp) .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::<stream::FuturesOrdered<_>>() })
.collect::<Vec<_>>() .collect::<stream::FuturesOrdered<_>>()
.await .filter_map(|v| future::ready(v))
}; .collect::<Map<_, _>>()
.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);
let u = OsuUser { let u = OsuUser {
user_id: target, user_id: target,
username: user.username.into(), username: user.username.into(),
id: user.id, id: user.id,
failures: 0, failures: 0,
last_update: chrono::Utc::now(), modes,
pp: pp.try_into().unwrap(),
std_weighted_map_length: std_weight_map_length,
}; };
env.saved_users.new_user(u).await?; env.saved_users.new_user(u).await?;
Ok(()) Ok(())

View file

@ -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 pagination::paginate_with_first_message;
use serenity::{ use serenity::{
all::GuildId, all::{GuildId, Member},
builder::EditMessage, builder::EditMessage,
framework::standard::{macros::command, Args, CommandResult}, framework::standard::{macros::command, Args, CommandResult},
model::channel::Message, model::channel::Message,
@ -17,7 +17,7 @@ use youmubot_prelude::{
}; };
use crate::{ use crate::{
discord::{display::ScoreListStyle, oppai_cache::Accuracy, BeatmapWithMode}, discord::{db::OsuUser, display::ScoreListStyle, oppai_cache::Accuracy, BeatmapWithMode},
models::{Mode, Mods}, models::{Mode, Mods},
request::UserID, request::UserID,
Score, Score,
@ -25,21 +25,57 @@ use crate::{
use super::{ModeArg, OsuEnv}; use super::{ModeArg, OsuEnv};
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum RankQuery { enum RankQuery {
Total, #[default]
PP,
TotalPP,
MapLength, 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::<f64>()).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 { impl FromStr for RankQuery {
type Err = <ModeArg as FromStr>::Err; type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
match s { match s {
"total" => Ok(RankQuery::Total), "pp" => Ok(RankQuery::PP),
"total" | "total-pp" => Ok(RankQuery::TotalPP),
"map-length" => Ok(RankQuery::MapLength), "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)] #[only_in(guilds)]
pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandResult { pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandResult {
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone(); let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
let mode = args let mode = args.find::<ModeArg>().map(|v| v.0).unwrap_or(Mode::Std);
.single::<RankQuery>() let query = args.single::<RankQuery>().unwrap_or_default();
.unwrap_or(RankQuery::Mode(Mode::Std));
let guild = m.guild_id.expect("Guild-only command"); let guild = m.guild_id.expect("Guild-only command");
let osu_users = env let mut users = env
.saved_users .saved_users
.all() .all()
.await? .await?
.into_iter() .into_iter()
.map(|v| (v.user_id, v)) .map(|v| (v.user_id, v))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let mut users = env
let users = env
.prelude .prelude
.members .members
.query_members(&ctx, guild) .query_members(&ctx, guild)
.await? .await?
.iter() .iter()
.filter_map(|m| osu_users.get(&m.user.id).map(|ou| (m, ou))) .filter_map(|m| users.remove(&m.user.id).map(|ou| (m.clone(), ou)))
.filter_map(|(member, osu_user)| { .collect::<Vec<_>>();
let pp = match mode { let last_update = users
RankQuery::Total if osu_user.pp.iter().any(|v| v.is_some_and(|v| v > 0.0)) => { .iter()
Some(osu_user.pp.iter().map(|v| v.unwrap_or(0.0)).sum()) .filter_map(|(_, u)| {
} if query == RankQuery::TotalPP {
RankQuery::MapLength => osu_user.pp.get(Mode::Std as usize).and_then(|v| *v), u.modes.values().map(|v| v.last_update).min()
RankQuery::Mode(m) => osu_user.pp.get(m as usize).and_then(|v| *v), } else {
_ => None, u.modes.get(&mode).map(|v| v.last_update)
}?; }
Some((pp, member.user.name.clone(), osu_user))
}) })
.collect::<Vec<_>>(); .min();
let last_update = users.iter().map(|(_, _, a)| a.last_update).min(); let sort_fn: Box<dyn Fn(&(Member, OsuUser), &(Member, OsuUser)) -> std::cmp::Ordering> =
let mut users = users match query {
.into_iter() RankQuery::PP => Box::new(|(_, a), (_, b)| {
.map(|(a, b, u)| (a, (b, u.clone()))) a.modes
.collect::<Vec<_>>(); .get(&mode)
if matches!(mode, RankQuery::MapLength) { .map(|v| v.pp)
users.sort_by(|(_, (_, a)), (_, (_, b))| { .partial_cmp(&b.modes.get(&mode).map(|v| v.pp))
(b.std_weighted_map_length) .unwrap()
.partial_cmp(&a.std_weighted_map_length) }),
.unwrap_or(std::cmp::Ordering::Equal) RankQuery::TotalPP => Box::new(|(_, a), (_, b)| {
}); a.modes
} else { .values()
users.sort_by(|(a, _), (b, _)| (*b).partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal)); .map(|v| v.pp)
} .sum::<f64>()
.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() { if users.is_empty() {
m.reply(&ctx, "No saved users in the current server...") 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); return Ok(false);
} }
let users = &users[start..end]; let users = &users[start..end];
let table = if matches!(mode, RankQuery::Mode(Mode::Std) | RankQuery::MapLength) { let table = {
const HEADERS: [&'static str; 5] = const HEADERS: [&'static str; 5] =
["#", "pp", "Map length", "Username", "Member"]; ["#", "pp", "Map length", "Username", "Member"];
const ALIGNS: [Align; 5] = [Right, Right, Right, Left, Left]; 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 let table = users
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, (pp, (mem, ou)))| { .map(|(i, (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(),
};
[ [
format!("{}", 1 + i + start), format!("{}", 1 + i + start),
format!("{:.2}", pp), RankQuery::PP.extract_row(mode, ou).to_string(),
map_length, RankQuery::MapLength.extract_row(mode, ou).to_string(),
ou.username.clone().into_owned(), ou.username.to_string(),
mem.clone(), mem.distinct(),
]
})
.collect::<Vec<_>>();
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(),
] ]
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();