Merge pull request 'oppai integration and lots of osu! improvements' (#21) from oppai-integration into master

This commit is contained in:
Natsu Kagami 2020-06-15 01:55:41 +00:00
commit 200416765a
16 changed files with 835 additions and 164 deletions

View file

@ -10,12 +10,12 @@ trigger:
steps:
- name: format_check
image: rust:1.41
image: rust:1.44
commands:
- rustup component add rustfmt
- cargo fmt -- --check
- name: cargo_check
image: rust:1.41
image: rust:1.44
commands:
- cargo check
@ -31,7 +31,7 @@ trigger:
steps:
- name: build_release
image: rust:1.41
image: rust:1.44
commands:
- cargo build --release
- name: deploy

53
Cargo.lock generated
View file

@ -22,6 +22,14 @@ name = "adler32"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "ahash"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"const-random 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "aho-corasick"
version = "0.7.10"
@ -162,6 +170,24 @@ dependencies = [
"syn 1.0.23 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "const-random"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"const-random-macro 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)",
"proc-macro-hack 0.5.15 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "const-random-macro"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"getrandom 0.1.14 (registry+https://github.com/rust-lang/crates.io-index)",
"proc-macro-hack 0.5.15 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "core-foundation"
version = "0.7.0"
@ -244,6 +270,16 @@ dependencies = [
"sct 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "dashmap"
version = "3.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"ahash 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
"cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)",
"num_cpus 1.13.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "digest"
version = "0.8.1"
@ -796,6 +832,16 @@ dependencies = [
"vcpkg 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "oppai-rs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
"cc 1.0.54 (registry+https://github.com/rust-lang/crates.io-index)",
"libc 0.2.70 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "parking_lot"
version = "0.9.0"
@ -1710,7 +1756,9 @@ version = "0.1.0"
dependencies = [
"bitflags 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
"chrono 0.4.11 (registry+https://github.com/rust-lang/crates.io-index)",
"dashmap 3.11.4 (registry+https://github.com/rust-lang/crates.io-index)",
"lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"oppai-rs 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)",
"rayon 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"regex 1.3.7 (registry+https://github.com/rust-lang/crates.io-index)",
"reqwest 0.10.4 (registry+https://github.com/rust-lang/crates.io-index)",
@ -1737,6 +1785,7 @@ dependencies = [
"checksum Inflector 0.11.4 (registry+https://github.com/rust-lang/crates.io-index)" = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
"checksum addr2line 0.12.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a49806b9dadc843c61e7c97e72490ad7f7220ae249012fbda9ad0609457c0543"
"checksum adler32 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2"
"checksum ahash 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)" = "e8fd72866655d1904d6b0997d0b07ba561047d070fbe29de039031c641b61217"
"checksum aho-corasick 0.7.10 (registry+https://github.com/rust-lang/crates.io-index)" = "8716408b8bc624ed7f65d223ddb9ac2d044c0547b6fa4b0d554f3a9540496ada"
"checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"
"checksum backtrace 0.3.48 (registry+https://github.com/rust-lang/crates.io-index)" = "0df2f85c8a2abbe3b7d7e748052fdd9b76a0458fdeb16ad4223f5eca78c7c130"
@ -1756,6 +1805,8 @@ dependencies = [
"checksum cloudabi 0.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f"
"checksum codeforces 0.1.0 (git+https://github.com/natsukagami/rust-codeforces-api)" = "<none>"
"checksum command_attr 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c27d6155f93d880b6379d93ddc9b2417b3b69b715360c5f25525e4576338a381"
"checksum const-random 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "2f1af9ac737b2dd2d577701e59fd09ba34822f6f2ebdb30a7647405d9e55e16a"
"checksum const-random-macro 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "25e4c606eb459dd29f7c57b2e0879f2b6f14ee130918c2b78ccb58a9624e6c7a"
"checksum core-foundation 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171"
"checksum core-foundation-sys 0.7.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac"
"checksum crc32fast 1.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ba125de2af0df55319f41944744ad91c71113bf74a4646efff39afe1f6842db1"
@ -1765,6 +1816,7 @@ dependencies = [
"checksum crossbeam-queue 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "c695eeca1e7173472a32221542ae469b3e9aac3a4fc81f7696bcad82029493db"
"checksum crossbeam-utils 0.7.2 (registry+https://github.com/rust-lang/crates.io-index)" = "c3c7c73a2d1e9fc0886a08b93e98eb643461230d5f1925e4036204d5f2e261a8"
"checksum ct-logs 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4d3686f5fa27dbc1d76c751300376e167c5a43387f44bb451fd1c24776e49113"
"checksum dashmap 3.11.4 (registry+https://github.com/rust-lang/crates.io-index)" = "8cfcd41ae02d60edded204341d2798ba519c336c51a37330aa4b98a1128def32"
"checksum digest 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5"
"checksum dotenv 0.15.0 (registry+https://github.com/rust-lang/crates.io-index)" = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
"checksum dtoa 0.4.5 (registry+https://github.com/rust-lang/crates.io-index)" = "4358a9e11b9a09cf52383b451b49a169e8d797b68aa02301ff586d70d9661ea3"
@ -1830,6 +1882,7 @@ dependencies = [
"checksum openssl 0.10.29 (registry+https://github.com/rust-lang/crates.io-index)" = "cee6d85f4cb4c4f59a6a85d5b68a233d280c82e29e822913b9c8b129fbf20bdd"
"checksum openssl-probe 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
"checksum openssl-sys 0.9.56 (registry+https://github.com/rust-lang/crates.io-index)" = "f02309a7f127000ed50594f0b50ecc69e7c654e16d41b4e8156d1b3df8e0b52e"
"checksum oppai-rs 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4f143357550da5c04800333509df440fcbe5254120a5af05097a083caf23105b"
"checksum parking_lot 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f842b1982eb6c2fe34036a4fbfb06dd185a3f5c8edfaacdf7d1ea10b07de6252"
"checksum parking_lot_core 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "b876b1b9e7ac6e1a74a6da34d25c42e17e8862aa409cbbbdcfc8d86c6f3bc62b"
"checksum percent-encoding 2.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e"

View file

@ -15,6 +15,8 @@ bitflags = "1"
rayon = "1.1"
lazy_static = "1"
regex = "1"
oppai-rs = "0.2.0"
dashmap = "3.11.4"
youmubot-db = { path = "../youmubot-db" }
youmubot-prelude = { path = "../youmubot-prelude" }

View file

@ -1,8 +1,10 @@
use super::db::{OsuSavedUsers, OsuUser};
use super::{embeds::score_embed, BeatmapWithMode, OsuClient};
use crate::{
discord::beatmap_cache::BeatmapMetaCache,
discord::oppai_cache::BeatmapCache,
models::{Mode, Score},
request::{BeatmapRequestKind, UserID},
request::UserID,
Client as Osu,
};
use announcer::MemberToChannels;
@ -22,6 +24,8 @@ pub const ANNOUNCER_KEY: &'static str = "osu";
/// Announce osu! top scores.
pub fn updates(c: Arc<CacheAndHttp>, d: AppData, channels: MemberToChannels) -> CommandResult {
let osu = d.get_cloned::<OsuClient>();
let cache = d.get_cloned::<BeatmapMetaCache>();
let oppai = d.get_cloned::<BeatmapCache>();
// For each user...
let mut data = OsuSavedUsers::open(&*d.read()).borrow()?.clone();
for (user_id, osu_user) in data.iter_mut() {
@ -31,7 +35,18 @@ pub fn updates(c: Arc<CacheAndHttp>, d: AppData, channels: MemberToChannels) ->
}
osu_user.pp = match (&[Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania])
.par_iter()
.map(|m| handle_user_mode(c.clone(), &osu, &osu_user, *user_id, &channels[..], *m))
.map(|m| {
handle_user_mode(
c.clone(),
&osu,
&cache,
&oppai,
&osu_user,
*user_id,
&channels[..],
*m,
)
})
.collect::<Result<_, _>>()
{
Ok(v) => v,
@ -51,6 +66,8 @@ pub fn updates(c: Arc<CacheAndHttp>, d: AppData, channels: MemberToChannels) ->
fn handle_user_mode(
c: Arc<CacheAndHttp>,
osu: &Osu,
cache: &BeatmapMetaCache,
oppai: &BeatmapCache,
osu_user: &OsuUser,
user_id: UserId,
channels: &[ChannelId],
@ -62,23 +79,17 @@ fn handle_user_mode(
.ok_or(Error::from("user not found"))?;
scores
.into_par_iter()
.filter_map(|(rank, score)| {
let beatmap = osu
.beatmaps(BeatmapRequestKind::Beatmap(score.beatmap_id), |f| f)
.map(|v| BeatmapWithMode(v.into_iter().next().unwrap(), mode));
match beatmap {
Ok(v) => Some((rank, score, v)),
Err(e) => {
dbg!(e);
None
}
}
.map(|(rank, score)| -> Result<_, Error> {
let beatmap = cache.get_beatmap_default(score.beatmap_id)?;
let content = oppai.get_beatmap(beatmap.beatmap_id)?;
Ok((rank, score, BeatmapWithMode(beatmap, mode), content))
})
.for_each(|(rank, score, beatmap)| {
.filter_map(|v| v.ok())
.for_each(|(rank, score, beatmap, content)| {
for channel in (&channels).iter() {
if let Err(e) = channel.send_message(c.http(), |c| {
c.content(format!("New top record from {}!", user_id.mention()))
.embed(|e| score_embed(&score, &beatmap, &user, Some(rank), e))
.embed(|e| score_embed(&score, &beatmap, &content, &user, Some(rank), e))
}) {
dbg!(e);
}

View file

@ -0,0 +1,70 @@
use crate::{
models::{ApprovalStatus, Beatmap, Mode},
Client,
};
use dashmap::DashMap;
use serenity::framework::standard::CommandError;
use std::sync::Arc;
use youmubot_prelude::TypeMapKey;
/// BeatmapMetaCache intercepts beatmap-by-id requests and caches them for later recalling.
/// Does not cache non-Ranked beatmaps.
#[derive(Clone, Debug)]
pub struct BeatmapMetaCache {
client: Client,
cache: Arc<DashMap<(u64, Mode), Beatmap>>,
}
impl TypeMapKey for BeatmapMetaCache {
type Value = BeatmapMetaCache;
}
impl BeatmapMetaCache {
/// Create a new beatmap cache.
pub fn new(client: Client) -> Self {
BeatmapMetaCache {
client,
cache: Arc::new(DashMap::new()),
}
}
fn insert_if_possible(&self, id: u64, mode: Option<Mode>) -> Result<Beatmap, CommandError> {
let beatmap = self
.client
.beatmaps(crate::BeatmapRequestKind::Beatmap(id), |f| {
if let Some(mode) = mode {
f.mode(mode, true);
}
f
})
.and_then(|v| {
v.into_iter()
.next()
.ok_or(CommandError::from("beatmap not found"))
})?;
if let ApprovalStatus::Ranked(_) = beatmap.approval {
self.cache.insert((id, beatmap.mode), beatmap.clone());
};
Ok(beatmap)
}
/// Get the given beatmap
pub fn get_beatmap(&self, id: u64, mode: Mode) -> Result<Beatmap, CommandError> {
self.cache
.get(&(id, mode))
.map(|b| Ok(b.clone()))
.unwrap_or_else(|| self.insert_if_possible(id, Some(mode)))
}
/// Get a beatmap without a mode...
pub fn get_beatmap_default(&self, id: u64) -> Result<Beatmap, CommandError> {
(&[Mode::Std, Mode::Taiko, Mode::Catch, Mode::Mania])
.iter()
.filter_map(|&mode| {
self.cache
.get(&(id, mode))
.filter(|b| b.mode == mode)
.map(|b| Ok(b.clone()))
})
.next()
.unwrap_or_else(|| self.insert_if_possible(id, None))
}
}

View file

@ -1,5 +1,8 @@
use super::BeatmapWithMode;
use crate::models::{Beatmap, Mode, Rank, Score, User};
use crate::{
discord::oppai_cache::{BeatmapContent, BeatmapInfo},
models::{Beatmap, Mode, Mods, Rank, Score, User},
};
use chrono::Utc;
use serenity::{builder::CreateEmbed, utils::MessageBuilder};
use youmubot_prelude::*;
@ -12,7 +15,19 @@ fn format_mode(actual: Mode, original: Mode) -> String {
}
}
pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a mut CreateEmbed {
pub fn beatmap_embed<'a>(
b: &'_ Beatmap,
m: Mode,
mods: Mods,
info: Option<BeatmapInfo>,
c: &'a mut CreateEmbed,
) -> &'a mut CreateEmbed {
let mod_str = if mods == Mods::NOMOD {
"".to_owned()
} else {
format!(" {}", mods)
};
let diff = b.difficulty.apply_mods(mods);
c.title(
MessageBuilder::new()
.push_bold_safe(&b.artist)
@ -21,6 +36,7 @@ pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a
.push(" [")
.push_bold_safe(&b.difficulty_name)
.push("]")
.push(&mod_str)
.build(),
)
.author(|a| {
@ -34,27 +50,37 @@ pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a
.color(0xffb6c1)
.field(
"Star Difficulty",
format!("{:.2}", b.difficulty.stars),
false,
format!(
"{:.2}⭐",
info.map(|v| v.stars as f64).unwrap_or(b.difficulty.stars)
),
true,
)
.fields(Some(("Mods", mods, true)).filter(|_| mods != Mods::NOMOD))
.fields(info.map(|info| {
(
"Calculated pp",
format!(
"95%: **{:.2}**pp, 98%: **{:.2}**pp, 99%: **{:.2}**pp, 100%: **{:.2}**pp",
info.pp[0], info.pp[1], info.pp[2], info.pp[3]
),
false,
)
}))
.field(
"Length",
MessageBuilder::new()
.push_bold_safe(Duration(b.total_length))
.push_bold_safe(Duration(diff.total_length))
.push(" (")
.push_bold_safe(Duration(b.drain_length))
.push_bold_safe(Duration(diff.drain_length))
.push(" drain)")
.build(),
false,
)
.field("Circle Size", format!("{:.1}", b.difficulty.cs), true)
.field("Approach Rate", format!("{:.1}", b.difficulty.ar), true)
.field(
"Overall Difficulty",
format!("{:.1}", b.difficulty.od),
true,
)
.field("HP Drain", format!("{:.1}", b.difficulty.hp), true)
.field("Circle Size", format!("{:.1}", diff.cs), true)
.field("Approach Rate", format!("{:.1}", diff.ar), true)
.field("Overall Difficulty", format!("{:.1}", diff.od), true)
.field("HP Drain", format!("{:.1}", diff.hp), true)
.field("BPM", b.bpm.round(), true)
.fields(b.difficulty.max_combo.map(|v| ("Max combo", v, true)))
.field("Mode", format_mode(m, b.mode), true)
@ -83,13 +109,23 @@ pub fn beatmap_embed<'a>(b: &'_ Beatmap, m: Mode, c: &'a mut CreateEmbed) -> &'a
)
})
.push_line(format!(" [[Beatmapset]]({})", b.beatmapset_link()))
.push_line(&b.approval)
.push_line(format!(
"Short link: `{}`",
b.short_link(Some(m), Some(mods))
))
.push_bold_line(&b.approval)
.push("Language: ")
.push_bold(&b.language)
.push(" | Genre: ")
.push_bold(&b.genre)
.build(),
)
.footer(|f| {
if info.is_none() && mods != Mods::NOMOD {
f.text("Star difficulty not reflecting mods applied.");
}
f
})
}
const MAX_DIFFS: usize = 25 - 4;
@ -145,7 +181,7 @@ pub fn beatmapset_embed<'a>(
.field(
"Length",
MessageBuilder::new()
.push_bold_safe(Duration(b.total_length))
.push_bold_safe(Duration(b.difficulty.total_length))
.build(),
true,
)
@ -177,7 +213,11 @@ pub fn beatmapset_embed<'a>(
(
format!("[{}]", b.difficulty_name),
MessageBuilder::new()
.push(format!("[[Link]]({})", b.link()))
.push(format!(
"[[Link]]({}) (`{}`)",
b.link(),
b.short_link(m, None)
))
.push(", ")
.push_bold(format!("{:.2}", b.difficulty.stars))
.push(", ")
@ -191,7 +231,7 @@ pub fn beatmapset_embed<'a>(
.push(", HP")
.push_bold(format!("{:.1}", b.difficulty.hp))
.push(", ⌛ ")
.push_bold(format!("{}", Duration(b.drain_length)))
.push_bold(format!("{}", Duration(b.difficulty.drain_length)))
.build(),
false,
)
@ -201,6 +241,7 @@ pub fn beatmapset_embed<'a>(
pub(crate) fn score_embed<'a>(
s: &Score,
bm: &BeatmapWithMode,
content: &BeatmapContent,
u: &User,
top_record: Option<u8>,
m: &'a mut CreateEmbed,
@ -208,6 +249,11 @@ pub(crate) fn score_embed<'a>(
let mode = bm.mode();
let b = &bm.0;
let accuracy = s.accuracy(mode);
let stars = mode
.to_oppai_mode()
.and_then(|mode| content.get_info_with(Some(mode), s.mods).ok())
.map(|info| info.stars as f64)
.unwrap_or(b.difficulty.stars);
let score_line = match &s.rank {
Rank::SS | Rank::SSH => format!("SS"),
_ if s.perfect => format!("{:.2}% FC", accuracy),
@ -217,12 +263,27 @@ pub(crate) fn score_embed<'a>(
accuracy, s.max_combo, s.count_miss, v
),
};
let score_line =
s.pp.map(|pp| format!("{} | {:.2}pp", &score_line, pp))
.unwrap_or(score_line);
let pp = s.pp.map(|pp| format!("{:.2}pp", pp)).or_else(|| {
mode.to_oppai_mode()
.and_then(|op| {
content
.get_pp_from(
oppai_rs::Combo::non_fc(s.max_combo as u32, s.count_miss as u32),
accuracy as f32,
Some(op),
s.mods,
)
.ok()
})
.map(|pp| format!("{:.2}pp [?]", pp))
});
let score_line = pp
.map(|pp| format!("{} | {}", &score_line, pp))
.unwrap_or(score_line);
let top_record = top_record
.map(|v| format!("| #{} top record!", v))
.unwrap_or("".to_owned());
let diff = b.difficulty.apply_mods(s.mods);
m.author(|f| f.name(&u.username).url(u.link()).icon_url(u.avatar_url()))
.color(0xffb6c1)
.title(format!(
@ -232,7 +293,7 @@ pub(crate) fn score_embed<'a>(
b.title,
b.difficulty_name,
s.mods,
b.difficulty.stars,
stars,
b.creator,
score_line,
top_record
@ -245,15 +306,26 @@ pub(crate) fn score_embed<'a>(
false,
)
.field("Rank", &score_line, false)
.field(
"300s / 100s / 50s / misses",
format!(
"**{}** ({}) / **{}** ({}) / **{}** / **{}**",
s.count_300, s.count_geki, s.count_100, s.count_katu, s.count_50, s.count_miss
),
true,
)
.fields(s.pp.map(|pp| ("pp gained", format!("{:.2}pp", pp), true)))
.field("Creator", &b.creator, true)
.field("Mode", mode.to_string(), true)
.field(
"Map stats",
MessageBuilder::new()
.push(format!("[[Link]]({})", b.link()))
.push(format!(
"[[Link]]({}) (`{}`)",
b.link(),
b.short_link(Some(mode), Some(s.mods))
))
.push(", ")
.push_bold(format!("{:.2}", b.difficulty.stars))
.push_bold(format!("{:.2}", stars))
.push(", ")
.push_bold_line(
b.mode.to_string()
@ -264,24 +336,29 @@ pub(crate) fn score_embed<'a>(
},
)
.push("CS")
.push_bold(format!("{:.1}", b.difficulty.cs))
.push_bold(format!("{:.1}", diff.cs))
.push(", AR")
.push_bold(format!("{:.1}", b.difficulty.ar))
.push_bold(format!("{:.1}", diff.ar))
.push(", OD")
.push_bold(format!("{:.1}", b.difficulty.od))
.push_bold(format!("{:.1}", diff.od))
.push(", HP")
.push_bold(format!("{:.1}", b.difficulty.hp))
.push_bold(format!("{:.1}", diff.hp))
.push(", ⌛ ")
.push_bold(format!("{}", Duration(b.drain_length)))
.push_bold(format!("{}", Duration(diff.drain_length)))
.build(),
false,
)
.field("Played on", s.date.format("%F %T"), false)
.timestamp(&s.date)
.field("Played on", s.date.format("%F %T"), false);
if mode.to_oppai_mode().is_none() && s.mods != Mods::NOMOD {
m.footer(|f| f.text("Star difficulty does not reflect game mods."));
}
m
}
pub(crate) fn user_embed<'a>(
u: User,
best: Option<(Score, BeatmapWithMode)>,
best: Option<(Score, BeatmapWithMode, Option<BeatmapInfo>)>,
m: &'a mut CreateEmbed,
) -> &'a mut CreateEmbed {
m.title(u.username)
@ -323,8 +400,8 @@ pub(crate) fn user_embed<'a>(
),
false,
)
.fields(best.map(|(v, map)| {
let map = map.0;
.fields(best.map(|(v, map, info)| {
let BeatmapWithMode(map, mode) = map;
(
"Best Record",
MessageBuilder::new()
@ -348,8 +425,12 @@ pub(crate) fn user_embed<'a>(
MessageBuilder::new().push_bold_safe(&map.title).build(),
map.link()
))
.push(format!(" [{}]", map.difficulty_name))
.push(format!(" ({:.1}⭐)", map.difficulty.stars))
.push_line(format!(" [{}]", map.difficulty_name))
.push(format!(
"{:.1}⭐ | `{}`",
info.map(|i| i.stars as f64).unwrap_or(map.difficulty.stars),
map.short_link(Some(mode), Some(v.mods))
))
.build(),
false,
)

View file

@ -1,6 +1,8 @@
use super::OsuClient;
use crate::{
models::{Beatmap, Mode},
discord::beatmap_cache::BeatmapMetaCache,
discord::oppai_cache::{BeatmapCache, BeatmapInfo},
models::{Beatmap, Mode, Mods},
request::BeatmapRequestKind,
};
use lazy_static::lazy_static;
@ -11,6 +13,7 @@ use serenity::{
model::channel::Message,
utils::MessageBuilder,
};
use std::str::FromStr;
use youmubot_prelude::*;
use super::embeds::{beatmap_embed, beatmapset_embed};
@ -22,6 +25,9 @@ lazy_static! {
static ref NEW_LINK_REGEX: Regex = Regex::new(
r"https?://osu\.ppy\.sh/beatmapsets/(?P<set_id>\d+)/?(?:\#(?P<mode>osu|taiko|fruits|mania)(?:/(?P<beatmap_id>\d+)|/?))?(?:\+(?P<mods>[A-Z]+))?"
).unwrap();
static ref SHORT_LINK_REGEX: Regex = Regex::new(
r"/b/(?P<id>\d+)(?:/(?P<mode>osu|taiko|fruits|mania))?(?:\+(?P<mods>[A-Z]+))?"
).unwrap();
}
pub fn hook(ctx: &mut Context, msg: &Message) -> () {
@ -31,16 +37,21 @@ pub fn hook(ctx: &mut Context, msg: &Message) -> () {
let mut v = move || -> CommandResult {
let old_links = handle_old_links(ctx, &msg.content)?;
let new_links = handle_new_links(ctx, &msg.content)?;
let short_links = handle_short_links(ctx, &msg, &msg.content)?;
let mut last_beatmap = None;
for l in old_links.into_iter().chain(new_links.into_iter()) {
for l in old_links
.into_iter()
.chain(new_links.into_iter())
.chain(short_links.into_iter())
{
if let Err(v) = msg.channel_id.send_message(&ctx, |m| match l.embed {
EmbedType::Beatmap(b) => {
let t = handle_beatmap(&b, l.link, l.mode, l.mods, m);
EmbedType::Beatmap(b, info, mods) => {
let t = handle_beatmap(&b, info, l.link, l.mode, mods, m);
let mode = l.mode.unwrap_or(b.mode);
last_beatmap = Some(super::BeatmapWithMode(b, mode));
t
}
EmbedType::Beatmapset(b) => handle_beatmapset(b, l.link, l.mode, l.mods, m),
EmbedType::Beatmapset(b) => handle_beatmapset(b, l.link, l.mode, m),
}) {
println!("Error in osu! hook: {:?}", v)
}
@ -59,7 +70,7 @@ pub fn hook(ctx: &mut Context, msg: &Message) -> () {
}
enum EmbedType {
Beatmap(Beatmap),
Beatmap(Beatmap, Option<BeatmapInfo>, Mods),
Beatmapset(Vec<Beatmap>),
}
@ -67,12 +78,12 @@ struct ToPrint<'a> {
embed: EmbedType,
link: &'a str,
mode: Option<Mode>,
mods: Option<&'a str>,
}
fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> {
let osu = ctx.data.get_cloned::<OsuClient>();
let mut to_prints: Vec<ToPrint<'a>> = Vec::new();
let cache = ctx.data.get_cloned::<BeatmapCache>();
for capture in OLD_LINK_REGEX.captures_iter(content) {
let req_type = capture.name("link_type").unwrap().as_str();
let req = match req_type {
@ -100,11 +111,22 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
match req_type {
"b" => {
for b in beatmaps.into_iter() {
// collect beatmap info
let mods = capture
.name("mods")
.map(|v| Mods::from_str(v.as_str()).ok())
.flatten()
.unwrap_or(Mods::NOMOD);
let info = mode.unwrap_or(b.mode).to_oppai_mode().and_then(|mode| {
cache
.get_beatmap(b.beatmap_id)
.and_then(|b| b.get_info_with(Some(mode), mods))
.ok()
});
to_prints.push(ToPrint {
embed: EmbedType::Beatmap(b),
embed: EmbedType::Beatmap(b, info, mods),
link: capture.get(0).unwrap().as_str(),
mode,
mods: capture.name("mods").map(|v| v.as_str()),
})
}
}
@ -112,7 +134,6 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
embed: EmbedType::Beatmapset(beatmaps),
link: capture.get(0).unwrap().as_str(),
mode,
mods: capture.name("mods").map(|v| v.as_str()),
}),
_ => (),
}
@ -123,17 +144,11 @@ fn handle_old_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPrint<'a>>, Error> {
let osu = ctx.data.get_cloned::<OsuClient>();
let mut to_prints: Vec<ToPrint<'a>> = Vec::new();
let cache = ctx.data.get_cloned::<BeatmapCache>();
for capture in NEW_LINK_REGEX.captures_iter(content) {
let mode = capture.name("mode").and_then(|v| {
Some(match v.as_str() {
"osu" => Mode::Std,
"taiko" => Mode::Taiko,
"fruits" => Mode::Catch,
"mania" => Mode::Mania,
_ => return None,
})
});
let mods = capture.name("mods").map(|v| v.as_str());
let mode = capture
.name("mode")
.and_then(|v| Mode::parse_from_new_site(v.as_str()));
let link = capture.get(0).unwrap().as_str();
let req = match capture.name("beatmap_id") {
Some(ref v) => BeatmapRequestKind::Beatmap(v.as_str().parse()?),
@ -148,10 +163,23 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
match capture.name("beatmap_id") {
Some(_) => {
for beatmap in beatmaps.into_iter() {
// collect beatmap info
let mods = capture
.name("mods")
.and_then(|v| Mods::from_str(v.as_str()).ok())
.unwrap_or(Mods::NOMOD);
let info = mode
.unwrap_or(beatmap.mode)
.to_oppai_mode()
.and_then(|mode| {
cache
.get_beatmap(beatmap.beatmap_id)
.and_then(|b| b.get_info_with(Some(mode), mods))
.ok()
});
to_prints.push(ToPrint {
embed: EmbedType::Beatmap(beatmap),
embed: EmbedType::Beatmap(beatmap, info, mods),
link,
mods,
mode,
})
}
@ -159,7 +187,6 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
None => to_prints.push(ToPrint {
embed: EmbedType::Beatmapset(beatmaps),
link,
mods,
mode,
}),
}
@ -167,11 +194,61 @@ fn handle_new_links<'a>(ctx: &mut Context, content: &'a str) -> Result<Vec<ToPri
Ok(to_prints)
}
fn handle_short_links<'a>(
ctx: &mut Context,
msg: &Message,
content: &'a str,
) -> Result<Vec<ToPrint<'a>>, Error> {
if let Some(guild_id) = msg.guild_id {
if announcer::announcer_of(ctx, crate::discord::announcer::ANNOUNCER_KEY, guild_id)?
!= Some(msg.channel_id)
{
// Disable if we are not in the server's announcer channel
return Ok(vec![]);
}
}
let osu = ctx.data.get_cloned::<BeatmapMetaCache>();
let cache = ctx.data.get_cloned::<BeatmapCache>();
Ok(SHORT_LINK_REGEX
.captures_iter(content)
.map(|capture| -> Result<_, Error> {
let mode = capture
.name("mode")
.and_then(|v| Mode::parse_from_new_site(v.as_str()));
let id: u64 = capture.name("id").unwrap().as_str().parse()?;
let beatmap = match mode {
Some(mode) => osu.get_beatmap(id, mode),
None => osu.get_beatmap_default(id),
}?;
let mods = capture
.name("mods")
.and_then(|v| Mods::from_str(v.as_str()).ok())
.unwrap_or(Mods::NOMOD);
let info = mode
.unwrap_or(beatmap.mode)
.to_oppai_mode()
.and_then(|mode| {
cache
.get_beatmap(beatmap.beatmap_id)
.and_then(|b| b.get_info_with(Some(mode), mods))
.ok()
});
Ok(ToPrint {
embed: EmbedType::Beatmap(beatmap, info, mods),
link: capture.get(0).unwrap().as_str(),
mode,
})
})
.filter_map(|v| v.ok())
.collect())
}
fn handle_beatmap<'a, 'b>(
beatmap: &Beatmap,
info: Option<BeatmapInfo>,
link: &'_ str,
mode: Option<Mode>,
mods: Option<&'_ str>,
mods: Mods,
m: &'a mut CreateMessage<'b>,
) -> &'a mut CreateMessage<'b> {
m.content(
@ -180,14 +257,13 @@ fn handle_beatmap<'a, 'b>(
.push_mono_safe(link)
.build(),
)
.embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), b))
.embed(|b| beatmap_embed(beatmap, mode.unwrap_or(beatmap.mode), mods, info, b))
}
fn handle_beatmapset<'a, 'b>(
beatmaps: Vec<Beatmap>,
link: &'_ str,
mode: Option<Mode>,
mods: Option<&'_ str>,
m: &'a mut CreateMessage<'b>,
) -> &'a mut CreateMessage<'b> {
let mut beatmaps = beatmaps;

View file

@ -1,5 +1,7 @@
use crate::{
models::{Beatmap, Mode, Score, User},
discord::beatmap_cache::BeatmapMetaCache,
discord::oppai_cache::BeatmapCache,
models::{Beatmap, Mode, Mods, Score, User},
request::{BeatmapRequestKind, UserID},
Client as OsuHttpClient,
};
@ -16,10 +18,12 @@ use std::str::FromStr;
use youmubot_prelude::*;
mod announcer;
pub(crate) mod beatmap_cache;
mod cache;
mod db;
pub(crate) mod embeds;
mod hook;
pub(crate) mod oppai_cache;
mod server_rank;
use db::OsuUser;
@ -57,9 +61,14 @@ pub fn setup(
// API client
let http_client = data.get_cloned::<HTTPClient>();
data.insert::<OsuClient>(OsuHttpClient::new(
http_client,
let osu_client = OsuHttpClient::new(
http_client.clone(),
std::env::var("OSU_API_KEY").expect("Please set OSU_API_KEY as osu! api key."),
);
data.insert::<OsuClient>(osu_client.clone());
data.insert::<oppai_cache::BeatmapCache>(oppai_cache::BeatmapCache::new(http_client));
data.insert::<beatmap_cache::BeatmapMetaCache>(beatmap_cache::BeatmapMetaCache::new(
osu_client,
));
// Announcer
@ -171,11 +180,11 @@ struct ModeArg(Mode);
impl FromStr for ModeArg {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(ModeArg(match s {
"std" => Mode::Std,
"taiko" => Mode::Taiko,
"catch" => Mode::Catch,
"mania" => Mode::Mania,
Ok(ModeArg(match &s.to_lowercase()[..] {
"osu" | "std" => Mode::Std,
"taiko" | "osu!taiko" => Mode::Taiko,
"ctb" | "fruits" | "catch" | "osu!ctb" | "osu!catch" => Mode::Catch,
"osu!mania" | "mania" => Mode::Mania,
_ => return Err(format!("Unknown mode {}", s)),
}))
}
@ -221,7 +230,8 @@ impl FromStr for Nth {
fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> CommandResult {
let watcher = ctx.data.get_cloned::<ReactionWatcher>();
let osu = ctx.data.get_cloned::<OsuClient>();
let osu = ctx.data.get_cloned::<BeatmapMetaCache>();
let beatmap_cache = ctx.data.get_cloned::<BeatmapCache>();
if plays.is_empty() {
m.reply(&ctx, "No plays found")?;
@ -232,53 +242,137 @@ fn list_plays(plays: &[Score], mode: Mode, ctx: Context, m: &Message) -> Command
const ITEMS_PER_PAGE: usize = 5;
let total_pages = (plays.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE;
watcher.paginate_fn(ctx, m.channel_id, |page, e| {
let page = page as usize;
let start = page * ITEMS_PER_PAGE;
let end = plays.len().min(start + ITEMS_PER_PAGE);
if start >= end {
return (e, Err(Error::from("No more pages")));
}
watcher.paginate_fn(
ctx,
m.channel_id,
|page, e| {
let page = page as usize;
let start = page * ITEMS_PER_PAGE;
let end = plays.len().min(start + ITEMS_PER_PAGE);
if start >= end {
return (e, Err(Error::from("No more pages")));
}
let plays = &plays[start..end];
let beatmaps = {
let b = &mut beatmaps[start..end];
b.par_iter_mut().enumerate().map(
|(i, v)| v.get_or_insert_with(
|| osu.beatmaps(BeatmapRequestKind::Beatmap(plays[i].beatmap_id), |f| f)
.ok()
.and_then(|v| v.into_iter().next())
.map(|b| format!(
"[{:.1}*] {} - {} [{}] (#{})",
b.difficulty.stars, b.artist, b.title, b.difficulty_name, b.beatmap_id))
.unwrap_or("FETCH FAILED".to_owned()))).collect::<Vec<_>>()
};
let /*mods width*/ mw = plays.iter().map(|v| v.mods.to_string().len()).max().unwrap().max(4);
let /*beatmap names*/ bw = beatmaps.iter().map(|v| v.len()).max().unwrap().max(7);
let plays = &plays[start..end];
let beatmaps = {
let b = &mut beatmaps[start..end];
b.par_iter_mut()
.enumerate()
.map(|(i, v)| {
v.get_or_insert_with(|| {
if let Some(b) = osu.get_beatmap(plays[i].beatmap_id, mode).ok() {
let stars = beatmap_cache
.get_beatmap(b.beatmap_id)
.ok()
.and_then(|b| {
mode.to_oppai_mode().and_then(|mode| {
b.get_info_with(Some(mode), plays[i].mods).ok()
})
})
.map(|info| info.stars as f64)
.unwrap_or(b.difficulty.stars);
format!(
"[{:.1}*] {} - {} [{}] ({})",
stars,
b.artist,
b.title,
b.difficulty_name,
b.short_link(Some(mode), Some(plays[i].mods)),
)
} else {
"FETCH_FAILED".to_owned()
}
})
})
.collect::<Vec<_>>()
};
let pp = plays
.iter()
.map(|p| {
p.pp.map(|pp| format!("{:.2}pp", pp))
.or_else(|| {
beatmap_cache.get_beatmap(p.beatmap_id).ok().and_then(|b| {
mode.to_oppai_mode().and_then(|op| {
b.get_pp_from(
oppai_rs::Combo::NonFC {
max_combo: p.max_combo as u32,
misses: p.count_miss as u32,
},
p.accuracy(mode) as f32,
Some(op),
p.mods,
)
.ok()
.map(|pp| format!("{:.2}pp [?]", pp))
})
})
})
.unwrap_or("-".to_owned())
})
.collect::<Vec<_>>();
let pw = pp.iter().map(|v| v.len()).max().unwrap_or(2);
/*mods width*/
let mw = plays
.iter()
.map(|v| v.mods.to_string().len())
.max()
.unwrap()
.max(4);
/*beatmap names*/
let bw = beatmaps.iter().map(|v| v.len()).max().unwrap().max(7);
let mut m = MessageBuilder::new();
// Table header
m.push_line(format!(" # | pp | accuracy | rank | {:mw$} | {:bw$}", "mods", "beatmap", mw = mw, bw = bw));
m.push_line(format!("---------------------------------{:-<mw$}---{:-<bw$}", "", "", mw = mw, bw = bw));
// Each row
for (id, (play, beatmap)) in plays.iter().zip(beatmaps.iter()).enumerate() {
m.push_line(
format!(
"{:>3} | {:>6} | {:>8} | {:^4} | {:mw$} | {:bw$}",
let mut m = MessageBuilder::new();
// Table header
m.push_line(format!(
" # | {:pw$} | accuracy | rank | {:mw$} | {:bw$}",
"pp",
"mods",
"beatmap",
pw = pw,
mw = mw,
bw = bw
));
m.push_line(format!(
"------{:-<pw$}---------------------{:-<mw$}---{:-<bw$}",
"",
"",
"",
pw = pw,
mw = mw,
bw = bw
));
// Each row
for (id, (play, beatmap)) in plays.iter().zip(beatmaps.iter()).enumerate() {
m.push_line(format!(
"{:>3} | {:>pw$} | {:>8} | {:^4} | {:mw$} | {:bw$}",
id + start + 1,
play.pp.map(|v| format!("{:.2}", v)).unwrap_or("-".to_owned()),
pp[id],
format!("{:.2}%", play.accuracy(mode)),
play.rank.to_string(), play.mods.to_string(), beatmap, mw = mw, bw = bw));
}
// End
let table = m.build().replace("```", "\\`\\`\\`");
let mut m = MessageBuilder::new();
m
.push_codeblock(table, None)
.push_line(format!("Page **{}/{}**", page + 1, total_pages))
.push_line("Note: star difficulty don't reflect mods applied.");
(e.content(m.build()), Ok(()))
}, std::time::Duration::from_secs(60))
play.rank.to_string(),
play.mods.to_string(),
beatmap,
pw = pw,
mw = mw,
bw = bw
));
}
// End
let table = m.build().replace("```", "\\`\\`\\`");
let mut m = MessageBuilder::new();
m.push_codeblock(table, None).push_line(format!(
"Page **{}/{}**",
page + 1,
total_pages
));
if let None = mode.to_oppai_mode() {
m.push_line("Note: star difficulty doesn't reflect mods applied.");
} else {
m.push_line("[?] means pp was predicted by oppai-rs.");
}
(e.content(m.build()), Ok(()))
},
std::time::Duration::from_secs(60),
)
}
#[command]
@ -292,6 +386,8 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let osu = ctx.data.get_cloned::<OsuClient>();
let meta_cache = ctx.data.get_cloned::<BeatmapMetaCache>();
let oppai = ctx.data.get_cloned::<BeatmapCache>();
let user = osu
.user(user, |f| f.mode(mode))?
.ok_or(Error::from("User not found"))?;
@ -302,25 +398,22 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
.into_iter()
.last()
.ok_or(Error::from("No such play"))?;
let beatmap = osu
.beatmaps(BeatmapRequestKind::Beatmap(recent_play.beatmap_id), |f| {
f.mode(mode, true)
})?
.into_iter()
.next()
.map(|v| BeatmapWithMode(v, mode))
let beatmap = meta_cache
.get_beatmap(recent_play.beatmap_id, mode)
.unwrap();
let content = oppai.get_beatmap(beatmap.beatmap_id)?;
let beatmap_mode = BeatmapWithMode(beatmap, mode);
msg.channel_id.send_message(&ctx, |m| {
m.content(format!(
"{}: here is the play that you requested",
msg.author
))
.embed(|m| score_embed(&recent_play, &beatmap, &user, None, m))
.embed(|m| score_embed(&recent_play, &beatmap_mode, &content, &user, None, m))
})?;
// Save the beatmap...
cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap)?;
cache::save_beatmap(&*ctx.data.read(), msg.channel_id, &beatmap_mode)?;
}
Nth::All => {
let plays = osu.user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50))?;
@ -332,18 +425,26 @@ pub fn recent(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
#[command]
#[description = "Show information from the last queried beatmap."]
#[num_args(0)]
pub fn last(ctx: &mut Context, msg: &Message, _: Args) -> CommandResult {
#[usage = "[mods = no mod]"]
#[max_args(1)]
pub fn last(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let b = cache::get_beatmap(&*ctx.data.read(), msg.channel_id)?;
match b {
Some(BeatmapWithMode(b, m)) => {
let mods = args.find::<Mods>().unwrap_or(Mods::NOMOD);
let info = ctx
.data
.get_cloned::<BeatmapCache>()
.get_beatmap(b.beatmap_id)?
.get_info_with(m.to_oppai_mode(), mods)
.ok();
msg.channel_id.send_message(&ctx, |f| {
f.content(format!(
"{}: here is the beatmap you requested!",
msg.author
))
.embed(|c| beatmap_embed(&b, m, c))
.embed(|c| beatmap_embed(&b, m, mods, info, c))
})?;
}
None => {
@ -371,6 +472,9 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let osu = ctx.data.get_cloned::<OsuClient>();
let oppai = ctx.data.get_cloned::<BeatmapCache>();
let content = oppai.get_beatmap(b.beatmap_id)?;
let user = osu
.user(user, |f| f)?
@ -383,7 +487,7 @@ pub fn check(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult
for score in scores.into_iter() {
msg.channel_id.send_message(&ctx, |c| {
c.embed(|m| score_embed(&score, &bm, &user, None, m))
c.embed(|m| score_embed(&score, &bm, &content, &user, None, m))
})?;
}
}
@ -407,6 +511,7 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let osu = ctx.data.get_cloned::<OsuClient>();
let oppai = ctx.data.get_cloned::<BeatmapCache>();
let user = osu
.user(user, |f| f.mode(mode))?
.ok_or(Error::from("User not found"))?;
@ -427,15 +532,16 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
})?
.into_iter()
.next()
.map(|v| BeatmapWithMode(v, mode))
.unwrap();
let content = oppai.get_beatmap(beatmap.beatmap_id)?;
let beatmap = BeatmapWithMode(beatmap, mode);
msg.channel_id.send_message(&ctx, |m| {
m.content(format!(
"{}: here is the play that you requested",
msg.author
))
.embed(|m| score_embed(&top_play, &beatmap, &user, Some(rank), m))
.embed(|m| score_embed(&top_play, &beatmap, &content, &user, Some(rank), m))
})?;
// Save the beatmap...
@ -452,18 +558,26 @@ pub fn top(ctx: &mut Context, msg: &Message, mut args: Args) -> CommandResult {
fn get_user(ctx: &mut Context, msg: &Message, mut args: Args, mode: Mode) -> CommandResult {
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*ctx.data.read(), msg)?;
let osu = ctx.data.get_cloned::<OsuClient>();
let cache = ctx.data.get_cloned::<BeatmapMetaCache>();
let user = osu.user(user, |f| f.mode(mode))?;
let oppai = ctx.data.get_cloned::<BeatmapCache>();
match user {
Some(u) => {
let best = osu
.user_best(UserID::ID(u.id), |f| f.limit(1).mode(mode))?
.into_iter()
.next()
.map(|m| {
osu.beatmaps(BeatmapRequestKind::Beatmap(m.beatmap_id), |f| {
f.mode(mode, true)
})
.map(|map| (m, BeatmapWithMode(map.into_iter().next().unwrap(), mode)))
.map(|m| -> Result<_, Error> {
let beatmap = cache.get_beatmap(m.beatmap_id, mode)?;
let info = mode
.to_oppai_mode()
.map(|mode| -> Result<_, Error> {
Ok(oppai
.get_beatmap(m.beatmap_id)?
.get_info_with(Some(mode), m.mods)?)
})
.transpose()?;
Ok((m, BeatmapWithMode(beatmap, mode), info))
})
.transpose()?;
msg.channel_id.send_message(&ctx, |m| {

View file

@ -0,0 +1,97 @@
use serenity::framework::standard::CommandError;
use std::{ffi::CString, sync::Arc};
use youmubot_prelude::TypeMapKey;
/// the information collected from a download/Oppai request.
#[derive(Clone, Debug)]
pub struct BeatmapContent {
id: u64,
content: Arc<CString>,
}
/// the output of "one" oppai run.
#[derive(Clone, Copy, Debug)]
pub struct BeatmapInfo {
pub stars: f32,
pub pp: [f32; 4], // 95, 98, 99, 100
}
impl BeatmapContent {
/// Get pp given the combo and accuracy.
pub fn get_pp_from(
&self,
combo: oppai_rs::Combo,
accuracy: f32,
mode: Option<oppai_rs::Mode>,
mods: impl Into<oppai_rs::Mods>,
) -> Result<f32, CommandError> {
let mut oppai = oppai_rs::Oppai::new_from_content(&self.content[..])?;
oppai.combo(combo)?.accuracy(accuracy)?.mods(mods.into());
if let Some(mode) = mode {
oppai.mode(mode)?;
}
Ok(oppai.pp())
}
/// Get info given mods.
pub fn get_info_with(
&self,
mode: Option<oppai_rs::Mode>,
mods: impl Into<oppai_rs::Mods>,
) -> Result<BeatmapInfo, CommandError> {
let mut oppai = oppai_rs::Oppai::new_from_content(&self.content[..])?;
if let Some(mode) = mode {
oppai.mode(mode)?;
}
oppai.mods(mods.into()).combo(oppai_rs::Combo::PERFECT)?;
let pp = [
oppai.accuracy(95.0)?.pp(),
oppai.accuracy(98.0)?.pp(),
oppai.accuracy(99.0)?.pp(),
oppai.accuracy(100.0)?.pp(),
];
let stars = oppai.stars();
Ok(BeatmapInfo { stars, pp })
}
}
/// A central cache for the beatmaps.
#[derive(Clone, Debug)]
pub struct BeatmapCache {
client: reqwest::blocking::Client,
cache: Arc<dashmap::DashMap<u64, BeatmapContent>>,
}
impl BeatmapCache {
/// Create a new cache.
pub fn new(client: reqwest::blocking::Client) -> Self {
BeatmapCache {
client,
cache: Arc::new(dashmap::DashMap::new()),
}
}
fn download_beatmap(&self, id: u64) -> Result<BeatmapContent, CommandError> {
let content = self
.client
.get(&format!("https://osu.ppy.sh/osu/{}", id))
.send()?
.bytes()?;
Ok(BeatmapContent {
id,
content: Arc::new(CString::new(content.into_iter().collect::<Vec<_>>())?),
})
}
/// Get a beatmap from the cache.
pub fn get_beatmap(&self, id: u64) -> Result<BeatmapContent, CommandError> {
self.cache
.entry(id)
.or_try_insert_with(|| self.download_beatmap(id))
.map(|v| v.clone())
}
}
impl TypeMapKey for BeatmapCache {
type Value = BeatmapCache;
}

View file

@ -50,17 +50,19 @@ pub fn server_rank(ctx: &mut Context, m: &Message, mut args: Args) -> CommandRes
m.channel_id,
move |page: u8, e: &mut EditMessage| {
let start = (page as usize) * ITEMS_PER_PAGE;
if start >= users.len() {
let end = (start + ITEMS_PER_PAGE).min(users.len());
if start >= end {
return (e, Err(Error("No more items".to_owned())));
}
let total_len = users.len();
let users = users.iter().skip(start).take(ITEMS_PER_PAGE);
let users = &users[start..end];
let username_len = users.iter().map(|(_, u)| u.len()).max().unwrap_or(8).max(8);
let mut content = MessageBuilder::new();
content
.push_line("```")
.push_line("Rank | pp | Username")
.push_line("-------------------------");
for (id, (pp, member)) in users.enumerate() {
.push_line(format!("-----------------{:-<uw$}", "", uw = username_len));
for (id, (pp, member)) in users.iter().enumerate() {
content
.push(format!(
"{:>4} | {:>7.2} | ",

View file

@ -14,7 +14,7 @@ use std::{convert::TryInto, sync::Arc};
/// Client is the client that will perform calls to the osu! api server.
/// It's cheap to clone, so do it.
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct Client {
key: Arc<String>,
client: HTTPClient,

View file

@ -45,6 +45,77 @@ pub struct Difficulty {
pub count_slider: u64,
pub count_spinner: u64,
pub max_combo: Option<u64>,
pub drain_length: Duration,
pub total_length: Duration,
}
impl Difficulty {
// Difficulty calculation is based on
// https://www.reddit.com/r/osugame/comments/6phntt/difficulty_settings_table_with_all_values/
fn apply_everything_by_ratio(&mut self, rat: f64) {
self.cs = (self.cs * rat).min(10.0);
self.od = (self.od * rat).min(10.0);
self.ar = (self.ar * rat).min(10.0);
self.hp = (self.hp * rat).min(10.0);
}
fn apply_ar_by_time_ratio(&mut self, rat: f64) {
// Convert AR to approach time...
let approach_time = if self.ar < 5.0 {
1800.0 - self.ar * 120.0
} else {
1200.0 - (self.ar - 5.0) * 150.0
};
// Update it...
let approach_time = approach_time * rat;
// Convert it back to AR...
self.ar = if approach_time > 1200.0 {
(1800.0 - approach_time) / 120.0
} else {
(1200.0 - approach_time) / 150.0 + 5.0
};
}
fn apply_od_by_time_ratio(&mut self, rat: f64) {
// Convert OD to hit timing
let hit_timing = 79.0 - self.od * 6.0 + 0.5;
// Update it...
let hit_timing = hit_timing * rat + 0.5 / rat;
// then convert back
self.od = (79.0 - (hit_timing - 0.5)) / 6.0;
}
fn apply_length_by_ratio(&mut self, mul: u32, div: u32) {
self.drain_length = self.drain_length * mul / div;
self.total_length = self.total_length * mul / div;
}
/// Apply mods to the given difficulty.
/// Note that `stars`, `aim` and `speed` cannot be calculated from this alone.
pub fn apply_mods(&self, mods: Mods) -> Difficulty {
let mut diff = self.clone();
// Apply mods one by one
if mods.contains(Mods::EZ) {
diff.apply_everything_by_ratio(0.5);
}
if mods.contains(Mods::HT) {
diff.apply_ar_by_time_ratio(4.0 / 3.0);
diff.apply_od_by_time_ratio(4.0 / 3.0);
diff.apply_length_by_ratio(4, 3);
}
if mods.contains(Mods::HR) {
let old_cs = diff.cs;
diff.apply_everything_by_ratio(1.4);
// CS is changed by 1.3 tho
diff.cs = old_cs * 1.3;
}
if mods.contains(Mods::DT) {
diff.apply_ar_by_time_ratio(2.0 / 3.0);
diff.apply_od_by_time_ratio(2.0 / 3.0);
diff.apply_length_by_ratio(2, 3);
}
diff
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Deserialize, Serialize)]
@ -94,7 +165,7 @@ impl fmt::Display for Language {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, std::hash::Hash)]
pub enum Mode {
Std,
Taiko,
@ -118,6 +189,38 @@ impl fmt::Display for Mode {
}
}
impl Mode {
/// Convert to oppai mode.
pub fn to_oppai_mode(self) -> Option<oppai_rs::Mode> {
Some(match self {
Mode::Std => oppai_rs::Mode::Std,
Mode::Taiko => oppai_rs::Mode::Taiko,
_ => return None,
})
}
/// Parse from the new site's convention.
pub fn parse_from_new_site(s: &str) -> Option<Self> {
Some(match s {
"osu" => Mode::Std,
"taiko" => Mode::Taiko,
"fruits" => Mode::Catch,
"mania" => Mode::Mania,
_ => return None,
})
}
/// Returns the mode string in the new convention.
pub fn to_str_new_site(&self) -> &'static str {
match self {
Mode::Std => "osu",
Mode::Taiko => "taiko",
Mode::Catch => "fruits",
Mode::Mania => "mania",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Beatmap {
// Beatmapset info
@ -141,8 +244,6 @@ pub struct Beatmap {
pub beatmap_id: u64,
pub difficulty_name: String,
pub difficulty: Difficulty,
pub drain_length: Duration,
pub total_length: Duration,
pub file_hash: String,
pub mode: Mode,
pub favourite_count: u64,
@ -169,6 +270,19 @@ impl Beatmap {
)
}
/// Return a parsable short link.
pub fn short_link(&self, override_mode: Option<Mode>, mods: Option<Mods>) -> String {
format!(
"/b/{}{}{}",
self.beatmap_id,
match override_mode {
Some(mode) if mode != self.mode => format!("/{}", mode.to_str_new_site()),
_ => "".to_owned(),
},
mods.map(|m| format!("{}", m)).unwrap_or("".to_owned()),
)
}
/// Link to the cover image of the beatmap.
pub fn cover_url(&self) -> String {
format!(

View file

@ -128,3 +128,9 @@ impl fmt::Display for Mods {
Ok(())
}
}
impl From<Mods> for oppai_rs::Mods {
fn from(m: Mods) -> Self {
oppai_rs::Mods::from_bits_truncate(m.bits() as i32)
}
}

View file

@ -139,9 +139,9 @@ impl TryFrom<raw::Beatmap> for Beatmap {
count_slider: parse_from_str(&raw.count_slider)?,
count_spinner: parse_from_str(&raw.count_spinner)?,
max_combo: raw.max_combo.map(parse_from_str).transpose()?,
drain_length: parse_duration(&raw.hit_length)?,
total_length: parse_duration(&raw.total_length)?,
},
drain_length: parse_duration(&raw.hit_length)?,
total_length: parse_duration(&raw.total_length)?,
file_hash: raw.file_md5,
mode: parse_mode(&raw.mode)?,
favourite_count: parse_from_str(&raw.favourite_count)?,

View file

@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Clone, Debug)]
pub(crate) struct Beatmap {
pub approved: String,
pub submit_date: String,

View file

@ -168,6 +168,51 @@ impl AnnouncerHandler {
}
}
/// Gets the announcer of the given guild.
pub fn announcer_of(
ctx: &Context,
key: &'static str,
guild: GuildId,
) -> Result<Option<ChannelId>, Error> {
Ok(AnnouncerChannels::open(&*ctx.data.read())
.borrow()?
.get(key)
.and_then(|channels| channels.get(&guild).cloned()))
}
#[command("list")]
#[description = "List the registered announcers of this server"]
#[num_args(0)]
#[only_in(guilds)]
pub fn list_announcers(ctx: &mut Context, m: &Message, _: Args) -> CommandResult {
let guild_id = m.guild_id.unwrap();
let announcers = AnnouncerChannels::open(&*ctx.data.read());
let announcers = announcers.borrow()?;
let channels = ctx
.data
.get_cloned::<AnnouncerHandler>()
.into_iter()
.filter_map(|key| {
announcers
.get(key)
.and_then(|channels| channels.get(&guild_id))
.map(|&ch| (key, ch))
})
.map(|(key, ch)| format!(" - `{}`: activated on channel {}", key, ch.mention()))
.collect::<Vec<_>>();
m.reply(
&ctx,
format!(
"Activated announcers on this server:\n{}",
channels.join("\n")
),
)?;
Ok(())
}
#[command("register")]
#[description = "Register the current channel with an announcer"]
#[usage = "[announcer key]"]
@ -253,5 +298,5 @@ pub fn remove_announcer(ctx: &mut Context, m: &Message, mut args: Args) -> Comma
#[only_in(guilds)]
#[required_permissions(MANAGE_CHANNELS)]
#[description = "Manage the announcers in the server."]
#[commands(remove_announcer, register_announcer)]
#[commands(remove_announcer, register_announcer, list_announcers)]
pub struct AnnouncerCommands;