Merge branch 'master' into osu-app-commands

This commit is contained in:
Natsu Kagami 2024-03-06 15:31:17 +01:00
commit 4d68ba5941
Signed by: nki
GPG key ID: 55A032EB38B49ADB
14 changed files with 253 additions and 83 deletions

4
Cargo.lock generated
View file

@ -1282,9 +1282,9 @@ dependencies = [
[[package]]
name = "mio"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"wasi",

View file

@ -15,6 +15,15 @@ All PRs welcome.
- `youmubot-core`: Core commands: admin, fun, community
- `youmubot-osu`: osu!-related commands.
## Working with `sqlx`
### Regenerate compiler information
From within `./youmubot-db-sql` run
```bash
cargo sqlx prepare --database-url "sqlite:$(realpath ..)/youmubot.db"
```
## License
Basically MIT.

View file

@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT\n INTO osu_users(user_id, username, id, last_update, pp_std, pp_taiko, pp_mania, pp_catch, failures)\n VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (user_id) WHERE id = ? DO UPDATE\n SET\n last_update = excluded.last_update,\n username = excluded.username,\n pp_std = excluded.pp_std,\n pp_taiko = excluded.pp_taiko,\n pp_mania = excluded.pp_mania,\n pp_catch = excluded.pp_catch,\n failures = excluded.failures\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "26a910506c0613936be169df13320dfd7f9d3df62a35ba63c61efc5de70e750d"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT\n user_id as \"user_id: i64\",\n username,\n id as \"id: i64\",\n last_update as \"last_update: DateTime\",\n pp_std, pp_taiko, pp_mania, pp_catch,\n failures as \"failures: u8\"\n FROM osu_users",
"query": "SELECT\n user_id as \"user_id: i64\",\n username,\n id as \"id: i64\",\n last_update as \"last_update: DateTime\",\n pp_std, pp_taiko, pp_mania, pp_catch,\n failures as \"failures: u8\",\n std_weighted_map_length\n FROM osu_users",
"describe": {
"columns": [
{
@ -47,6 +47,11 @@
"name": "failures: u8",
"ordinal": 8,
"type_info": "Int64"
},
{
"name": "std_weighted_map_length",
"ordinal": 9,
"type_info": "Float"
}
],
"parameters": {
@ -61,8 +66,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "5753fe315c9a55154d2d80e6d293dc8abffcf426b845624a42cd0bfefc75fb74"
"hash": "6ef67ca385287a4cef9fdd47bf4258ec9de4802d90dbb2ab48de32c1a4ada601"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT\n INTO osu_users(user_id, username, id, last_update, pp_std, pp_taiko, pp_mania, pp_catch, failures, std_weighted_map_length)\n VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (user_id) WHERE id = ? DO UPDATE\n SET\n last_update = excluded.last_update,\n username = excluded.username,\n pp_std = excluded.pp_std,\n pp_taiko = excluded.pp_taiko,\n pp_mania = excluded.pp_mania,\n pp_catch = excluded.pp_catch,\n failures = excluded.failures,\n std_weighted_map_length = excluded.std_weighted_map_length\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 11
},
"nullable": []
},
"hash": "a06efa1b12c2c7c9cf5b83bff796c0e59d61596cb609c4bb952edc2d64cec868"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT\n user_id as \"user_id: i64\",\n username,\n id as \"id: i64\",\n last_update as \"last_update: DateTime\",\n pp_std, pp_taiko, pp_mania, pp_catch,\n failures as \"failures: u8\"\n FROM osu_users WHERE id = ?",
"query": "SELECT\n user_id as \"user_id: i64\",\n username,\n id as \"id: i64\",\n last_update as \"last_update: DateTime\",\n pp_std, pp_taiko, pp_mania, pp_catch,\n failures as \"failures: u8\",\n std_weighted_map_length\n FROM osu_users WHERE id = ?",
"describe": {
"columns": [
{
@ -47,6 +47,11 @@
"name": "failures: u8",
"ordinal": 8,
"type_info": "Int64"
},
{
"name": "std_weighted_map_length",
"ordinal": 9,
"type_info": "Float"
}
],
"parameters": {
@ -61,8 +66,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "08f2568a69a14ae240a24264238d4abc7aea5eee67d6062d049f0d37031e4d7a"
"hash": "b098282e73cc6fd435330f6ecd446b1a1cd2aeb89517b7ee09e7e6f8d6e0cd79"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT\n user_id as \"user_id: i64\",\n username,\n id as \"id: i64\",\n last_update as \"last_update: DateTime\",\n pp_std, pp_taiko, pp_mania, pp_catch,\n failures as \"failures: u8\"\n FROM osu_users WHERE user_id = ?",
"query": "SELECT\n user_id as \"user_id: i64\",\n username,\n id as \"id: i64\",\n last_update as \"last_update: DateTime\",\n pp_std, pp_taiko, pp_mania, pp_catch,\n failures as \"failures: u8\",\n std_weighted_map_length\n FROM osu_users WHERE user_id = ?",
"describe": {
"columns": [
{
@ -47,6 +47,11 @@
"name": "failures: u8",
"ordinal": 8,
"type_info": "Int64"
},
{
"name": "std_weighted_map_length",
"ordinal": 9,
"type_info": "Float"
}
],
"parameters": {
@ -61,8 +66,9 @@
true,
true,
true,
false
false,
true
]
},
"hash": "700ec95294d9a4f21e3d7ff53f15f5dc739bffe8fedc19e35cbb576b6dd2e948"
"hash": "df0aa5065268e59c68990ab46ab4a90ec3137398e83b3d0c626209306804399a"
}

View file

@ -0,0 +1,5 @@
-- Add migration script here
ALTER TABLE osu_users
ADD COLUMN std_weighted_map_length DOUBLE NULL DEFAULT NULL;

View file

@ -14,6 +14,8 @@ pub struct OsuUser {
pub pp_catch: Option<f64>,
/// Number of consecutive update failures
pub failures: u8,
pub std_weighted_map_length: Option<f64>,
}
impl OsuUser {
@ -30,7 +32,8 @@ impl OsuUser {
id as "id: i64",
last_update as "last_update: DateTime",
pp_std, pp_taiko, pp_mania, pp_catch,
failures as "failures: u8"
failures as "failures: u8",
std_weighted_map_length
FROM osu_users WHERE user_id = ?"#,
user_id
)
@ -52,7 +55,8 @@ impl OsuUser {
id as "id: i64",
last_update as "last_update: DateTime",
pp_std, pp_taiko, pp_mania, pp_catch,
failures as "failures: u8"
failures as "failures: u8",
std_weighted_map_length
FROM osu_users WHERE id = ?"#,
osu_id
)
@ -74,7 +78,8 @@ impl OsuUser {
id as "id: i64",
last_update as "last_update: DateTime",
pp_std, pp_taiko, pp_mania, pp_catch,
failures as "failures: u8"
failures as "failures: u8",
std_weighted_map_length
FROM osu_users"#,
)
.fetch_many(conn)
@ -90,8 +95,8 @@ impl OsuUser {
{
query!(
r#"INSERT
INTO osu_users(user_id, username, id, last_update, pp_std, pp_taiko, pp_mania, pp_catch, failures)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
INTO osu_users(user_id, username, id, last_update, pp_std, pp_taiko, pp_mania, pp_catch, failures, std_weighted_map_length)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id) WHERE id = ? DO UPDATE
SET
last_update = excluded.last_update,
@ -100,7 +105,8 @@ impl OsuUser {
pp_taiko = excluded.pp_taiko,
pp_mania = excluded.pp_mania,
pp_catch = excluded.pp_catch,
failures = excluded.failures
failures = excluded.failures,
std_weighted_map_length = excluded.std_weighted_map_length
"#,
self.user_id,
self.username,
@ -111,7 +117,10 @@ impl OsuUser {
self.pp_mania,
self.pp_catch,
self.failures,
self.user_id).execute(conn).await?;
self.std_weighted_map_length,
self.user_id,
).execute(conn).await?;
Ok(())
}

View file

@ -1,4 +1,5 @@
use super::db::{OsuSavedUsers, OsuUser};
use super::OsuClient;
use super::{embeds::score_embed, BeatmapWithMode};
use crate::{
discord::beatmap_cache::BeatmapMetaCache,
@ -19,6 +20,7 @@ use serenity::{
};
use std::{convert::TryInto, sync::Arc};
use youmubot_prelude::announcer::CacheAndHttp;
use youmubot_prelude::stream::{FuturesUnordered, TryStreamExt};
use youmubot_prelude::*;
/// osu! announcer's unique announcer key.
@ -83,7 +85,12 @@ impl youmubot_prelude::Announcer for Announcer {
.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
@ -185,6 +192,31 @@ impl Announcer {
.collect();
Ok(scores)
}
async fn std_weighted_map_length(ctx: &Context, u: &OsuUser) -> Result<f64> {
let data = ctx.data.read().await;
let client = data.get::<OsuClient>().unwrap().clone();
let cache = data.get::<BeatmapMetaCache>().unwrap();
let scores = client
.user_best(UserID::ID(u.id), |f| f.mode(Mode::Std).limit(100))
.await?;
scores
.into_iter()
.enumerate()
.map(|(i, s)| async move {
let beatmap = cache.get_beatmap_default(s.beatmap_id).await?;
const SCALING_FACTOR: f64 = 0.975;
Ok(beatmap
.difficulty
.apply_mods(s.mods, 0.0 /* dont care */)
.drain_length
.as_secs_f64()
* (SCALING_FACTOR.powi(i as i32)))
})
.collect::<FuturesUnordered<_>>()
.try_fold(0.0, |a, b| future::ready(Ok(a + b)))
.await
}
}
#[derive(Clone)]

View file

@ -148,6 +148,7 @@ pub struct OsuUser {
pub id: u64,
pub last_update: DateTime<Utc>,
pub pp: [Option<f64>; 4],
pub std_weighted_map_length: Option<f64>,
/// More than 5 failures => gone
pub failures: u8,
}
@ -163,6 +164,7 @@ impl From<OsuUser> for model::OsuUser {
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,
failures: u.failures,
}
}
@ -181,6 +183,7 @@ impl From<model::OsuUser> for OsuUser {
Mode::Catch => u.pp_catch,
Mode::Mania => u.pp_mania,
}),
std_weighted_map_length: u.std_weighted_map_length,
failures: u.failures,
}
}

View file

@ -366,6 +366,7 @@ async fn add_user(
failures: 0,
last_update: chrono::Utc::now(),
pp: [None, None, None, None],
std_weighted_map_length: None,
};
data.get::<OsuSavedUsers>().unwrap().new_user(u).await?;
Ok(())

View file

@ -12,7 +12,6 @@ use crate::{
use poise::CreateReply;
use serenity::{
builder::EditMessage,
framework::standard::{macros::command, Args, CommandResult},
model::channel::Message,
utils::MessageBuilder,
@ -20,23 +19,93 @@ use serenity::{
use youmubot_prelude::{stream::FuturesUnordered, *};
#[derive(Debug, Clone, Copy)]
enum ModeOrTotal {
enum RankQuery {
Total,
MapLength,
Mode(Mode),
}
impl FromStr for ModeOrTotal {
impl FromStr for RankQuery {
type Err = <ModeArg as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s == "total" {
Ok(ModeOrTotal::Total)
} else {
ModeArg::from_str(s).map(|ModeArg(m)| ModeOrTotal::Mode(m))
match s {
"total" => Ok(RankQuery::Total),
"map-length" => Ok(RankQuery::MapLength),
_ => ModeArg::from_str(s).map(|ModeArg(m)| RankQuery::Mode(m)),
}
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
enum Align {
Left,
Middle,
Right,
}
impl Align {
fn pad(self, input: &str, len: usize) -> String {
match self {
Align::Left => format!("{:<len$}", input),
Align::Middle => format!("{:^len$}", input),
Align::Right => format!("{:>len$}", input),
}
}
}
fn table_formatting<const N: usize, S: AsRef<str> + std::fmt::Debug, Ts: AsRef<[[S; N]]>>(
headers: &[&'static str; N],
padding: &[Align; N],
table: Ts,
) -> String {
let table = table.as_ref();
// get length for each column
let lens = headers
.iter()
.enumerate()
.map(|(i, header)| {
table
.iter()
.map(|r| r.as_ref()[i].as_ref().len())
.max()
.unwrap_or(0)
.max(header.len())
})
.collect::<Vec<_>>();
// paint with message builder
let mut m = MessageBuilder::new();
m.push_line("```");
// headers first
for (i, header) in headers.iter().enumerate() {
if i > 0 {
m.push(" | ");
}
m.push(padding[i].pad(header, lens[i]));
}
m.push_line("");
// separator
m.push_line(format!(
"{:-<total$}",
"",
total = lens.iter().sum::<usize>() + (lens.len() - 1) * 3
));
// table itself
for row in table {
let row = row.as_ref();
for (i, cell) in row.iter().enumerate() {
if i > 0 {
m.push(" | ");
}
let cell = cell.as_ref();
m.push(padding[i].pad(cell, lens[i]));
}
m.push_line("");
}
m.push("```");
m.build()
}
#[command("ranks")]
#[description = "See the server's ranks"]
#[usage = "[mode (Std, Taiko, Catch, Mania) = Std]"]
@ -45,8 +114,8 @@ impl FromStr for ModeOrTotal {
pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandResult {
let data = ctx.data.read().await;
let mode = args
.single::<ModeOrTotal>()
.unwrap_or(ModeOrTotal::Mode(Mode::Std));
.single::<RankQuery>()
.unwrap_or(RankQuery::Mode(Mode::Std));
let guild = m.guild_id.expect("Guild-only command");
let member_cache = data.get::<MemberCache>().unwrap();
let osu_users = data
@ -64,10 +133,11 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR
.filter_map(|m| osu_users.get(&m.user.id).map(|ou| (m, ou)))
.filter_map(|(member, osu_user)| {
let pp = match mode {
ModeOrTotal::Total if osu_user.pp.iter().any(|v| v.is_some_and(|v| v > 0.0)) => {
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())
}
ModeOrTotal::Mode(m) => osu_user.pp.get(m as usize).and_then(|v| *v),
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))
@ -78,7 +148,15 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR
.into_iter()
.map(|(a, b, u)| (a, (b, u.clone())))
.collect::<Vec<_>>();
users.sort_by(|(a, _), (b, _)| (*b).partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
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));
}
if users.is_empty() {
m.reply(&ctx, "No saved users in the current server...")
@ -90,6 +168,7 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR
let last_update = last_update.unwrap();
paginate_reply_fn(
move |page: u8, _| {
use Align::*;
const ITEMS_PER_PAGE: usize = 10;
let users = users.clone();
Box::pin(async move {
@ -100,49 +179,61 @@ pub async fn server_rank(ctx: &Context, m: &Message, mut args: Args) -> CommandR
}
let total_len = users.len();
let users = &users[start..end];
let username_len = users
.iter()
.map(|(_, (_, u))| u.username.len())
.max()
.unwrap_or(8)
.max(8);
let member_len = users
.iter()
.map(|(_, (mem, _))| mem.len())
.max()
.unwrap_or(8)
.max(8);
let mut content = MessageBuilder::new();
content
.push_line("```")
let table = if matches!(mode, RankQuery::Mode(Mode::Std) | RankQuery::MapLength) {
const HEADERS: [&'static str; 5] =
["#", "pp", "Map length", "Username", "Member"];
const ALIGNS: [Align; 5] = [Right, Right, Right, Left, Left];
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(),
};
[
format!("{}", 1 + i + start),
format!("{:.2}", pp),
map_length,
ou.username.clone().into_owned(),
mem.clone(),
]
})
.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<_>>();
table_formatting(&HEADERS, &ALIGNS, table)
};
let content = MessageBuilder::new()
.push_line(table)
.push_line(format!(
"Rank | pp | {:uw$} | Member",
"Username",
uw = username_len
"Page **{}**/**{}**. Last updated: {}",
page + 1,
(total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE,
last_update.format("<t:%s:R>"),
))
.push_line(format!(
"------------------{:-<uw$}---{:-<mw$}",
"",
"",
uw = username_len,
mw = member_len
));
for (id, (pp, (member, u))) in users.iter().enumerate() {
content.push_line(format!(
"{:>4} | {:>8.2} | {:uw$} | {}",
format!("#{}", 1 + id + start),
pp,
u.username,
member,
uw = username_len
));
}
content.push_line("```").push_line(format!(
"Page **{}**/**{}**. Last updated: {}",
page + 1,
(total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE,
last_update.format("<t:%s:R>"),
));
.build();
Ok(Some(CreateReply::default().content(content.to_string())))
})
},

View file

@ -180,6 +180,7 @@ async fn paginate_with_first_message(
let mut reaction_collector = {
// message.await_reactions(ctx).removed(true).build();
let message_id = message.id;
let me = message.author.id;
collector::collect(&ctx.shard, move |event| {
match event {
serenity::all::Event::ReactionAdd(r) => Some(r.reaction.clone()),
@ -187,6 +188,7 @@ async fn paginate_with_first_message(
_ => None,
}
.filter(|r| r.message_id == message_id)
.filter(|r| r.user_id.is_some_and(|id| id != me))
})
};
let mut page = 0;