mirror of
https://github.com/natsukagami/youmubot.git
synced 2025-04-19 16:58:55 +00:00
osu: implement beatmapset and simulate button (#55)
* Prepare a message beforehand for display_beatmapset * Implement a Beatmapset button * Always sort top plays by pp * Show leaderboard for top pp per user/mod only * Add score simulation * Store all reaction to be removed later * Properly handle errors * Parse beatmap to get placeholders for modal * Make buttons same color
This commit is contained in:
parent
7d490774e0
commit
803d718c7a
10 changed files with 476 additions and 64 deletions
|
@ -356,17 +356,14 @@ mod beatmapset {
|
||||||
const SHOW_ALL_EMOTE: &str = "🗒️";
|
const SHOW_ALL_EMOTE: &str = "🗒️";
|
||||||
|
|
||||||
pub async fn display_beatmapset(
|
pub async fn display_beatmapset(
|
||||||
ctx: &Context,
|
ctx: Context,
|
||||||
beatmapset: Vec<Beatmap>,
|
beatmapset: Vec<Beatmap>,
|
||||||
mode: Option<Mode>,
|
mode: Option<Mode>,
|
||||||
mods: Mods,
|
mods: Mods,
|
||||||
reply_to: &Message,
|
|
||||||
guild_id: Option<GuildId>,
|
guild_id: Option<GuildId>,
|
||||||
message: impl AsRef<str>,
|
target: Message,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
if beatmapset.is_empty() {
|
assert!(!beatmapset.is_empty(), "Beatmapset should not be empty");
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
let p = Paginate {
|
let p = Paginate {
|
||||||
infos: vec![None; beatmapset.len()],
|
infos: vec![None; beatmapset.len()],
|
||||||
|
@ -374,13 +371,18 @@ mod beatmapset {
|
||||||
mode,
|
mode,
|
||||||
mods,
|
mods,
|
||||||
guild_id,
|
guild_id,
|
||||||
message: message.as_ref().to_owned(),
|
|
||||||
|
all_reaction: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
let reply_to = reply_to.clone();
|
|
||||||
spawn_future(async move {
|
spawn_future(async move {
|
||||||
pagination::paginate_reply(p, &ctx, &reply_to, std::time::Duration::from_secs(60))
|
pagination::paginate_with_first_message(
|
||||||
|
p,
|
||||||
|
&ctx,
|
||||||
|
target,
|
||||||
|
std::time::Duration::from_secs(60),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.pls_ok();
|
.pls_ok();
|
||||||
});
|
});
|
||||||
|
@ -392,8 +394,9 @@ mod beatmapset {
|
||||||
infos: Vec<Option<BeatmapInfoWithPP>>,
|
infos: Vec<Option<BeatmapInfoWithPP>>,
|
||||||
mode: Option<Mode>,
|
mode: Option<Mode>,
|
||||||
mods: Mods,
|
mods: Mods,
|
||||||
message: String,
|
|
||||||
guild_id: Option<GuildId>,
|
guild_id: Option<GuildId>,
|
||||||
|
|
||||||
|
all_reaction: Option<Reaction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Paginate {
|
impl Paginate {
|
||||||
|
@ -440,7 +443,7 @@ mod beatmapset {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
msg.edit(ctx,
|
msg.edit(ctx,
|
||||||
EditMessage::new().content(self.message.as_str()).embed(
|
EditMessage::new().embed(
|
||||||
crate::discord::embeds::beatmap_embed(
|
crate::discord::embeds::beatmap_embed(
|
||||||
map,
|
map,
|
||||||
self.mode.unwrap_or(map.mode),
|
self.mode.unwrap_or(map.mode),
|
||||||
|
@ -456,7 +459,7 @@ mod beatmapset {
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.components(vec![beatmap_components(self.guild_id)]),
|
.components(vec![beatmap_components(map.mode, self.guild_id)]),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
|
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
|
||||||
|
@ -476,8 +479,10 @@ mod beatmapset {
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
m: &mut serenity::model::channel::Message,
|
m: &mut serenity::model::channel::Message,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
self.all_reaction = Some(
|
||||||
m.react(&ctx, SHOW_ALL_EMOTE.parse::<ReactionType>().unwrap())
|
m.react(&ctx, SHOW_ALL_EMOTE.parse::<ReactionType>().unwrap())
|
||||||
.await?;
|
.await?,
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -499,5 +504,13 @@ mod beatmapset {
|
||||||
.await
|
.await
|
||||||
.map(Some)
|
.map(Some)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup(&mut self, ctx: &Context, _msg: &mut Message) {
|
||||||
|
if let Some(r) = self.all_reaction.take() {
|
||||||
|
if !r.delete_all(&ctx).await.is_ok() {
|
||||||
|
r.delete(&ctx).await.pls_ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -392,11 +392,6 @@ impl<'a> ScoreEmbedBuilder<'a> {
|
||||||
.map(|v| format!(" | #{} on Global Rankings!", v))
|
.map(|v| format!(" | #{} on Global Rankings!", v))
|
||||||
.unwrap_or_else(|| "".to_owned());
|
.unwrap_or_else(|| "".to_owned());
|
||||||
let diff = b.difficulty.apply_mods(&s.mods, stars);
|
let diff = b.difficulty.apply_mods(&s.mods, stars);
|
||||||
let creator = if b.difficulty_name.contains("'s") {
|
|
||||||
"".to_owned()
|
|
||||||
} else {
|
|
||||||
format!("by {} ", b.creator)
|
|
||||||
};
|
|
||||||
let mod_details = mod_details(&s.mods);
|
let mod_details = mod_details(&s.mods);
|
||||||
let description_fields = [
|
let description_fields = [
|
||||||
Some(
|
Some(
|
||||||
|
@ -430,18 +425,8 @@ impl<'a> ScoreEmbedBuilder<'a> {
|
||||||
MessageBuilder::new()
|
MessageBuilder::new()
|
||||||
.push_safe(&u.username)
|
.push_safe(&u.username)
|
||||||
.push(" | ")
|
.push(" | ")
|
||||||
.push_safe(&b.artist)
|
.push(b.full_title(&s.mods, stars))
|
||||||
.push(" - ")
|
.push(" | ")
|
||||||
.push(&b.title)
|
|
||||||
.push(" [")
|
|
||||||
.push_safe(&b.difficulty_name)
|
|
||||||
.push("] ")
|
|
||||||
.push(s.mods.to_string())
|
|
||||||
.push(" ")
|
|
||||||
.push(format!("({:.2}\\*)", stars))
|
|
||||||
.push(" ")
|
|
||||||
.push_safe(creator)
|
|
||||||
.push("| ")
|
|
||||||
.push(score_line)
|
.push(score_line)
|
||||||
.push(top_record)
|
.push(top_record)
|
||||||
.push(world_record)
|
.push(world_record)
|
||||||
|
@ -479,6 +464,132 @@ impl<'a> ScoreEmbedBuilder<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct FakeScore<'a> {
|
||||||
|
pub bm: &'a BeatmapWithMode,
|
||||||
|
pub content: &'a BeatmapContent,
|
||||||
|
pub mods: Mods,
|
||||||
|
|
||||||
|
pub count_300: usize,
|
||||||
|
pub count_100: usize,
|
||||||
|
pub count_50: usize,
|
||||||
|
pub count_miss: usize,
|
||||||
|
|
||||||
|
pub count_slider_ends_missed: Option<usize>, // lazer only
|
||||||
|
|
||||||
|
pub max_combo: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> FakeScore<'a> {
|
||||||
|
fn is_ss(&self, map_max_combo: usize) -> bool {
|
||||||
|
self.is_fc(map_max_combo)
|
||||||
|
&& self.count_100
|
||||||
|
+ self.count_50
|
||||||
|
+ self.count_miss
|
||||||
|
+ self.count_slider_ends_missed.unwrap_or(0)
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
fn is_fc(&self, map_max_combo: usize) -> bool {
|
||||||
|
match self.max_combo {
|
||||||
|
None => self.count_miss == 0,
|
||||||
|
Some(combo) => combo == map_max_combo - self.count_slider_ends_missed.unwrap_or(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn accuracy(&self) -> f64 {
|
||||||
|
100.0
|
||||||
|
* (self.count_300 as f64 * 300.0
|
||||||
|
+ self.count_100 as f64 * 100.0
|
||||||
|
+ self.count_50 as f64 * 50.0)
|
||||||
|
/ ((self.count_300 + self.count_100 + self.count_50 + self.count_miss) as f64 * 300.0)
|
||||||
|
}
|
||||||
|
pub fn embed(self, ctx: &Context) -> Result<CreateEmbed> {
|
||||||
|
let BeatmapWithMode(b, mode) = self.bm;
|
||||||
|
let info = self.content.get_info_with(*mode, &self.mods)?;
|
||||||
|
let max_combo = self.max_combo.unwrap_or(
|
||||||
|
info.max_combo - self.count_miss - self.count_slider_ends_missed.unwrap_or(0),
|
||||||
|
);
|
||||||
|
let acc = format!("{:.2}%", self.accuracy());
|
||||||
|
let score_line: Cow<str> = if self.is_ss(info.max_combo) {
|
||||||
|
"SS".into()
|
||||||
|
} else if self.is_fc(info.max_combo) {
|
||||||
|
format!("{} FC", acc).into()
|
||||||
|
} else {
|
||||||
|
format!("{} {}x {} miss", acc, max_combo, self.count_miss).into()
|
||||||
|
};
|
||||||
|
let pp = self.content.get_pp_from(
|
||||||
|
*mode,
|
||||||
|
self.max_combo,
|
||||||
|
Accuracy::ByCount(
|
||||||
|
self.count_300 as u64,
|
||||||
|
self.count_100 as u64,
|
||||||
|
self.count_50 as u64,
|
||||||
|
self.count_miss as u64,
|
||||||
|
),
|
||||||
|
&self.mods,
|
||||||
|
)?;
|
||||||
|
let pp_if_fc: Cow<str> = if self.is_fc(info.max_combo) {
|
||||||
|
"".into()
|
||||||
|
} else {
|
||||||
|
let pp = self.content.get_pp_from(
|
||||||
|
*mode,
|
||||||
|
None,
|
||||||
|
Accuracy::ByCount(
|
||||||
|
(self.count_300 + self.count_miss) as u64,
|
||||||
|
self.count_100 as u64,
|
||||||
|
self.count_50 as u64,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
&self.mods,
|
||||||
|
)?;
|
||||||
|
format!(" ({:.2}pp if fc)", pp).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let youmu = ctx.cache.current_user();
|
||||||
|
|
||||||
|
Ok(CreateEmbed::new()
|
||||||
|
.author(
|
||||||
|
CreateEmbedAuthor::new(&youmu.name).icon_url(youmu.static_avatar_url().unwrap()),
|
||||||
|
)
|
||||||
|
.color(0xffb6c1)
|
||||||
|
.title(
|
||||||
|
MessageBuilder::new()
|
||||||
|
.push_safe(&youmu.name)
|
||||||
|
.push(" | ")
|
||||||
|
.push(b.full_title(&self.mods, info.stars))
|
||||||
|
.push(" | ")
|
||||||
|
.push(score_line)
|
||||||
|
.push(" | ")
|
||||||
|
.push(format!("{:.2}pp [?]", pp))
|
||||||
|
.push(pp_if_fc)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.thumbnail(b.thumbnail_url())
|
||||||
|
.description(format!("**pp gained**: **{:.2}**pp", pp))
|
||||||
|
.field(
|
||||||
|
"Score stats",
|
||||||
|
format!("**{}** combo | **{}**", max_combo, acc),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"300s | 100s | 50s | misses",
|
||||||
|
format!(
|
||||||
|
"**{}** | **{}** | **{}** | **{}**",
|
||||||
|
self.count_300, self.count_100, self.count_50, self.count_miss
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"Map stats",
|
||||||
|
b.difficulty
|
||||||
|
.apply_mods(&self.mods, info.stars)
|
||||||
|
.format_info(*mode, &self.mods, b),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.footer(CreateEmbedFooter::new(
|
||||||
|
"This is a simulated score, with pp calculated by Youmu.",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn user_embed(u: User, ex: UserExtras) -> CreateEmbed {
|
pub(crate) fn user_embed(u: User, ex: UserExtras) -> CreateEmbed {
|
||||||
let mut stats = Vec::<(&'static str, String, bool)>::new();
|
let mut stats = Vec::<(&'static str, String, bool)>::new();
|
||||||
let UserExtras {
|
let UserExtras {
|
||||||
|
|
|
@ -288,6 +288,7 @@ async fn handle_beatmap<'a, 'b>(
|
||||||
mods: Mods,
|
mods: Mods,
|
||||||
reply_to: &Message,
|
reply_to: &Message,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let mode = mode.unwrap_or(beatmap.mode);
|
||||||
reply_to
|
reply_to
|
||||||
.channel_id
|
.channel_id
|
||||||
.send_message(
|
.send_message(
|
||||||
|
@ -299,13 +300,8 @@ async fn handle_beatmap<'a, 'b>(
|
||||||
.push_mono_safe(link)
|
.push_mono_safe(link)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.embed(beatmap_embed(
|
.embed(beatmap_embed(beatmap, mode, &mods, info))
|
||||||
beatmap,
|
.components(vec![beatmap_components(mode, reply_to.guild_id)])
|
||||||
mode.unwrap_or(beatmap.mode),
|
|
||||||
&mods,
|
|
||||||
info,
|
|
||||||
))
|
|
||||||
.components(vec![beatmap_components(reply_to.guild_id)])
|
|
||||||
.reference_message(reply_to),
|
.reference_message(reply_to),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -319,14 +315,16 @@ async fn handle_beatmapset<'a, 'b>(
|
||||||
mode: Option<Mode>,
|
mode: Option<Mode>,
|
||||||
reply_to: &Message,
|
reply_to: &Message,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
let reply = reply_to
|
||||||
|
.reply(ctx, format!("Beatmapset information for `{}`", link))
|
||||||
|
.await?;
|
||||||
crate::discord::display::display_beatmapset(
|
crate::discord::display::display_beatmapset(
|
||||||
ctx,
|
ctx.clone(),
|
||||||
beatmaps,
|
beatmaps,
|
||||||
mode,
|
mode,
|
||||||
Mods::default(),
|
Mods::default(),
|
||||||
reply_to,
|
|
||||||
reply_to.guild_id,
|
reply_to.guild_id,
|
||||||
format!("Beatmapset information for `{}`", link),
|
reply,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.pls_ok();
|
.pls_ok();
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
use std::pin::Pin;
|
use std::{pin::Pin, str::FromStr, time::Duration};
|
||||||
|
|
||||||
use future::Future;
|
use future::Future;
|
||||||
use serenity::all::{
|
use serenity::all::{
|
||||||
ComponentInteraction, ComponentInteractionDataKind, CreateActionRow, CreateButton,
|
ComponentInteraction, ComponentInteractionDataKind, CreateActionRow, CreateButton,
|
||||||
CreateInteractionResponse, CreateInteractionResponseFollowup, CreateInteractionResponseMessage,
|
CreateInputText, CreateInteractionResponse, CreateInteractionResponseFollowup,
|
||||||
GuildId, Interaction,
|
CreateInteractionResponseMessage, CreateQuickModal, GuildId, InputTextStyle, Interaction,
|
||||||
|
QuickModalResponse,
|
||||||
};
|
};
|
||||||
use youmubot_prelude::*;
|
use youmubot_prelude::*;
|
||||||
|
|
||||||
use crate::{Mods, UserHeader};
|
use crate::{discord::embeds::FakeScore, mods::UnparsedMods, Mode, Mods, UserHeader};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
display::ScoreListStyle,
|
display::ScoreListStyle,
|
||||||
|
@ -20,6 +21,8 @@ use super::{
|
||||||
pub(super) const BTN_CHECK: &str = "youmubot_osu_btn_check";
|
pub(super) const BTN_CHECK: &str = "youmubot_osu_btn_check";
|
||||||
pub(super) const BTN_LB: &str = "youmubot_osu_btn_lb";
|
pub(super) const BTN_LB: &str = "youmubot_osu_btn_lb";
|
||||||
pub(super) const BTN_LAST: &str = "youmubot_osu_btn_last";
|
pub(super) const BTN_LAST: &str = "youmubot_osu_btn_last";
|
||||||
|
pub(super) const BTN_LAST_SET: &str = "youmubot_osu_btn_last_set";
|
||||||
|
pub(super) const BTN_SIMULATE: &str = "youmubot_osu_btn_simulate";
|
||||||
|
|
||||||
/// Create an action row for score pages.
|
/// Create an action row for score pages.
|
||||||
pub fn score_components(guild_id: Option<GuildId>) -> CreateActionRow {
|
pub fn score_components(guild_id: Option<GuildId>) -> CreateActionRow {
|
||||||
|
@ -31,11 +34,15 @@ pub fn score_components(guild_id: Option<GuildId>) -> CreateActionRow {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an action row for score pages.
|
/// Create an action row for score pages.
|
||||||
pub fn beatmap_components(guild_id: Option<GuildId>) -> CreateActionRow {
|
pub fn beatmap_components(mode: Mode, guild_id: Option<GuildId>) -> CreateActionRow {
|
||||||
let mut btns = vec![check_button()];
|
let mut btns = vec![check_button()];
|
||||||
if guild_id.is_some() {
|
if guild_id.is_some() {
|
||||||
btns.push(lb_button());
|
btns.push(lb_button());
|
||||||
}
|
}
|
||||||
|
btns.push(mapset_button());
|
||||||
|
if mode == Mode::Std {
|
||||||
|
btns.push(simulate_button());
|
||||||
|
}
|
||||||
CreateActionRow::Buttons(btns)
|
CreateActionRow::Buttons(btns)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,6 +127,20 @@ pub fn last_button() -> CreateButton {
|
||||||
.style(serenity::all::ButtonStyle::Success)
|
.style(serenity::all::ButtonStyle::Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn mapset_button() -> CreateButton {
|
||||||
|
CreateButton::new(BTN_LAST_SET)
|
||||||
|
.label("Set")
|
||||||
|
.emoji('📚')
|
||||||
|
.style(serenity::all::ButtonStyle::Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn simulate_button() -> CreateButton {
|
||||||
|
CreateButton::new(BTN_SIMULATE)
|
||||||
|
.label("What If?")
|
||||||
|
.emoji('🌈')
|
||||||
|
.style(serenity::all::ButtonStyle::Success)
|
||||||
|
}
|
||||||
|
|
||||||
/// Implements the `last` button on scores and beatmaps.
|
/// Implements the `last` button on scores and beatmaps.
|
||||||
pub fn handle_last_button<'a>(
|
pub fn handle_last_button<'a>(
|
||||||
ctx: &'a Context,
|
ctx: &'a Context,
|
||||||
|
@ -130,6 +151,159 @@ pub fn handle_last_button<'a>(
|
||||||
Some(comp) => comp,
|
Some(comp) => comp,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
handle_last_req(ctx, comp, false).await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implements the `beatmapset` button on scores and beatmaps.
|
||||||
|
pub fn handle_last_set_button<'a>(
|
||||||
|
ctx: &'a Context,
|
||||||
|
interaction: &'a Interaction,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let comp = match expect_and_defer_button(ctx, interaction, BTN_LAST_SET).await? {
|
||||||
|
Some(comp) => comp,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
handle_last_req(ctx, comp, true).await
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Implements the `simulate` button on beatmaps.
|
||||||
|
pub fn handle_simulate_button<'a>(
|
||||||
|
ctx: &'a Context,
|
||||||
|
interaction: &'a Interaction,
|
||||||
|
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let comp = match interaction.as_message_component() {
|
||||||
|
Some(comp)
|
||||||
|
if comp.data.custom_id == BTN_SIMULATE
|
||||||
|
&& matches!(comp.data.kind, ComponentInteractionDataKind::Button) =>
|
||||||
|
{
|
||||||
|
comp
|
||||||
|
}
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = &*comp.message;
|
||||||
|
|
||||||
|
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
|
||||||
|
|
||||||
|
let (bm, _) = super::load_beatmap(&env, comp.channel_id, Some(msg))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let b = &bm.0;
|
||||||
|
let mode = bm.1;
|
||||||
|
let content = env.oppai.get_beatmap(b.beatmap_id).await?;
|
||||||
|
let info = content.get_info_with(mode, Mods::NOMOD)?;
|
||||||
|
|
||||||
|
assert!(mode == Mode::Std);
|
||||||
|
|
||||||
|
fn mk_input(title: &str, placeholder: impl Into<String>) -> CreateInputText {
|
||||||
|
CreateInputText::new(InputTextStyle::Short, title, "")
|
||||||
|
.placeholder(placeholder)
|
||||||
|
.required(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(query) = comp
|
||||||
|
.quick_modal(
|
||||||
|
&ctx,
|
||||||
|
CreateQuickModal::new(format!(
|
||||||
|
"Simulate Score on beatmap `{}`",
|
||||||
|
b.short_link(None, Mods::NOMOD)
|
||||||
|
))
|
||||||
|
.timeout(Duration::from_secs(300))
|
||||||
|
.field(mk_input("Mods", "NM"))
|
||||||
|
.field(mk_input("Max Combo", info.max_combo.to_string()))
|
||||||
|
.field(mk_input("100s", "0"))
|
||||||
|
.field(mk_input("50s", "0"))
|
||||||
|
.field(mk_input("Misses", "0")),
|
||||||
|
// .short_field("Slider Ends Missed (Lazer Only)"), // too long LMAO
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
query.interaction.defer(&ctx).await?;
|
||||||
|
|
||||||
|
if let Err(err) = handle_simluate_query(ctx, &env, &query, bm).await {
|
||||||
|
query
|
||||||
|
.interaction
|
||||||
|
.create_followup(
|
||||||
|
ctx,
|
||||||
|
CreateInteractionResponseFollowup::new()
|
||||||
|
.content(format!("Cannot simulate score: {}", err))
|
||||||
|
.ephemeral(true),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.pls_ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_simluate_query(
|
||||||
|
ctx: &Context,
|
||||||
|
env: &OsuEnv,
|
||||||
|
query: &QuickModalResponse,
|
||||||
|
bm: BeatmapWithMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let b = &bm.0;
|
||||||
|
let mode = bm.1;
|
||||||
|
let content = env.oppai.get_beatmap(b.beatmap_id).await?;
|
||||||
|
|
||||||
|
let score = {
|
||||||
|
let inputs = &query.inputs;
|
||||||
|
let (mods, max_combo, c100, c50, cmiss, csliderends) = (
|
||||||
|
&inputs[0], &inputs[1], &inputs[2], &inputs[3], &inputs[4], "",
|
||||||
|
);
|
||||||
|
let mods = UnparsedMods::from_str(mods)
|
||||||
|
.map_err(|v| Error::msg(v))?
|
||||||
|
.to_mods(mode)?;
|
||||||
|
let info = content.get_info_with(mode, &mods)?;
|
||||||
|
let max_combo = max_combo.parse::<usize>().ok();
|
||||||
|
let c100 = c100.parse::<usize>().unwrap_or(0);
|
||||||
|
let c50 = c50.parse::<usize>().unwrap_or(0);
|
||||||
|
let cmiss = cmiss.parse::<usize>().unwrap_or(0);
|
||||||
|
let c300 = info.objects - c100 - c50 - cmiss;
|
||||||
|
let csliderends = csliderends.parse::<usize>().ok();
|
||||||
|
FakeScore {
|
||||||
|
bm: &bm,
|
||||||
|
content: &content,
|
||||||
|
mods,
|
||||||
|
count_300: c300,
|
||||||
|
count_100: c100,
|
||||||
|
count_50: c50,
|
||||||
|
count_miss: cmiss,
|
||||||
|
count_slider_ends_missed: csliderends,
|
||||||
|
max_combo,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
query
|
||||||
|
.interaction
|
||||||
|
.create_followup(
|
||||||
|
&ctx,
|
||||||
|
CreateInteractionResponseFollowup::new()
|
||||||
|
.content(format!(
|
||||||
|
"Simulated score for `{}`",
|
||||||
|
b.short_link(None, Mods::NOMOD)
|
||||||
|
))
|
||||||
|
.add_embed(score.embed(ctx)?)
|
||||||
|
.components(vec![score_components(query.interaction.guild_id)]),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_last_req(
|
||||||
|
ctx: &Context,
|
||||||
|
comp: &ComponentInteraction,
|
||||||
|
is_beatmapset_req: bool,
|
||||||
|
) -> Result<()> {
|
||||||
let msg = &*comp.message;
|
let msg = &*comp.message;
|
||||||
|
|
||||||
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
|
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
|
||||||
|
@ -140,6 +314,27 @@ pub fn handle_last_button<'a>(
|
||||||
let BeatmapWithMode(b, m) = &bm;
|
let BeatmapWithMode(b, m) = &bm;
|
||||||
|
|
||||||
let mods = mods_def.unwrap_or_default();
|
let mods = mods_def.unwrap_or_default();
|
||||||
|
|
||||||
|
if is_beatmapset_req {
|
||||||
|
let beatmapset = env.beatmaps.get_beatmapset(bm.0.beatmapset_id).await?;
|
||||||
|
let reply = comp
|
||||||
|
.create_followup(
|
||||||
|
&ctx,
|
||||||
|
CreateInteractionResponseFollowup::new()
|
||||||
|
.content(format!("Beatmapset of `{}`", bm.short_link(&mods))),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
super::display::display_beatmapset(
|
||||||
|
ctx.clone(),
|
||||||
|
beatmapset,
|
||||||
|
None,
|
||||||
|
mods,
|
||||||
|
comp.guild_id,
|
||||||
|
reply,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
} else {
|
||||||
let info = env
|
let info = env
|
||||||
.oppai
|
.oppai
|
||||||
.get_beatmap(b.beatmap_id)
|
.get_beatmap(b.beatmap_id)
|
||||||
|
@ -153,14 +348,14 @@ pub fn handle_last_button<'a>(
|
||||||
bm.short_link(&mods)
|
bm.short_link(&mods)
|
||||||
))
|
))
|
||||||
.embed(beatmap_embed(b, *m, &mods, info))
|
.embed(beatmap_embed(b, *m, &mods, info))
|
||||||
.components(vec![beatmap_components(comp.guild_id)]),
|
.components(vec![beatmap_components(bm.1, comp.guild_id)]),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// Save the beatmap...
|
// Save the beatmap...
|
||||||
super::cache::save_beatmap(&env, msg.channel_id, &bm).await?;
|
super::cache::save_beatmap(&env, msg.channel_id, &bm).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new check button.
|
/// Creates a new check button.
|
||||||
|
@ -191,7 +386,7 @@ pub fn handle_lb_button<'a>(
|
||||||
let order = OrderBy::default();
|
let order = OrderBy::default();
|
||||||
let guild = comp.guild_id.expect("Guild-only command");
|
let guild = comp.guild_id.expect("Guild-only command");
|
||||||
|
|
||||||
let scores = get_leaderboard(ctx, &env, &bm, order, guild).await?;
|
let scores = get_leaderboard(ctx, &env, &bm, false, order, guild).await?;
|
||||||
|
|
||||||
if scores.is_empty() {
|
if scores.is_empty() {
|
||||||
comp.create_followup(
|
comp.create_followup(
|
||||||
|
|
|
@ -313,7 +313,7 @@ pub async fn save(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
|
||||||
&ctx,
|
&ctx,
|
||||||
EditMessage::new()
|
EditMessage::new()
|
||||||
.embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, info))
|
.embed(beatmap_embed(&beatmap, mode, Mods::NOMOD, info))
|
||||||
.components(vec![beatmap_components(msg.guild_id)]),
|
.components(vec![beatmap_components(mode, msg.guild_id)]),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let reaction = reply.react(&ctx, '👌').await?;
|
let reaction = reply.react(&ctx, '👌').await?;
|
||||||
|
@ -810,14 +810,16 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
|
||||||
};
|
};
|
||||||
if beatmapset {
|
if beatmapset {
|
||||||
let beatmapset = env.beatmaps.get_beatmapset(bm.0.beatmapset_id).await?;
|
let beatmapset = env.beatmaps.get_beatmapset(bm.0.beatmapset_id).await?;
|
||||||
|
let reply = msg
|
||||||
|
.reply(&ctx, "Here is the beatmapset you requested!")
|
||||||
|
.await?;
|
||||||
display::display_beatmapset(
|
display::display_beatmapset(
|
||||||
ctx,
|
ctx.clone(),
|
||||||
beatmapset,
|
beatmapset,
|
||||||
None,
|
None,
|
||||||
mods,
|
mods,
|
||||||
msg,
|
|
||||||
msg.guild_id,
|
msg.guild_id,
|
||||||
"Here is the beatmapset you requested!",
|
reply,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
@ -833,7 +835,7 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
|
||||||
CreateMessage::new()
|
CreateMessage::new()
|
||||||
.content("Here is the beatmap you requested!")
|
.content("Here is the beatmap you requested!")
|
||||||
.embed(beatmap_embed(&bm.0, bm.1, &mods, info))
|
.embed(beatmap_embed(&bm.0, bm.1, &mods, info))
|
||||||
.components(vec![beatmap_components(msg.guild_id)])
|
.components(vec![beatmap_components(bm.1, msg.guild_id)])
|
||||||
.reference_message(msg),
|
.reference_message(msg),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -941,10 +943,13 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
|
||||||
} = ListingArgs::parse(&env, msg, &mut args, ScoreListStyle::default()).await?;
|
} = ListingArgs::parse(&env, msg, &mut args, ScoreListStyle::default()).await?;
|
||||||
let osu_client = &env.client;
|
let osu_client = &env.client;
|
||||||
|
|
||||||
let plays = osu_client
|
let mut plays = osu_client
|
||||||
.user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100))
|
.user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
plays.sort_unstable_by(|a, b| b.pp.partial_cmp(&a.pp).unwrap());
|
||||||
|
let plays = plays;
|
||||||
|
|
||||||
match nth {
|
match nth {
|
||||||
Nth::Nth(nth) => {
|
Nth::Nth(nth) => {
|
||||||
let Some(play) = plays.get(nth as usize) else {
|
let Some(play) = plays.get(nth as usize) else {
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
use std::{borrow::Cow, cmp::Ordering, collections::HashMap, str::FromStr, sync::Arc};
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
cmp::Ordering,
|
||||||
|
collections::{BTreeMap, HashMap},
|
||||||
|
str::FromStr,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
use chrono::DateTime;
|
use chrono::DateTime;
|
||||||
use pagination::paginate_with_first_message;
|
use pagination::paginate_with_first_message;
|
||||||
|
@ -316,13 +322,26 @@ impl FromStr for OrderBy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AllLb;
|
||||||
|
impl FromStr for AllLb {
|
||||||
|
type Err = Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
match s {
|
||||||
|
"--all" => Ok(AllLb),
|
||||||
|
_ => Err(Error::msg("unknown value")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[command("leaderboard")]
|
#[command("leaderboard")]
|
||||||
#[aliases("lb", "bmranks", "br", "cc", "updatelb")]
|
#[aliases("lb", "bmranks", "br", "cc", "updatelb")]
|
||||||
#[usage = "[--score to sort by score, default to sort by pp] / [--table to show a table, --grid to show score by score] / [mods to filter]"]
|
#[usage = "[--all to show all scores, not just ranked] / [--score to sort by score, default to sort by pp] / [--table to show a table, --grid to show score by score] / [mods to filter]"]
|
||||||
#[description = "See the server's ranks on the last seen beatmap"]
|
#[description = "See the server's ranks on the last seen beatmap"]
|
||||||
#[max_args(2)]
|
#[max_args(2)]
|
||||||
#[only_in(guilds)]
|
#[only_in(guilds)]
|
||||||
pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
|
pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
|
||||||
|
let show_all = args.single::<AllLb>().is_ok();
|
||||||
let order = args.single::<OrderBy>().unwrap_or_default();
|
let order = args.single::<OrderBy>().unwrap_or_default();
|
||||||
let style = args.single::<ScoreListStyle>().unwrap_or_default();
|
let style = args.single::<ScoreListStyle>().unwrap_or_default();
|
||||||
let guild = msg.guild_id.expect("Guild-only command");
|
let guild = msg.guild_id.expect("Guild-only command");
|
||||||
|
@ -339,7 +358,7 @@ pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> C
|
||||||
|
|
||||||
let scores = {
|
let scores = {
|
||||||
let reaction = msg.react(ctx, '⌛').await?;
|
let reaction = msg.react(ctx, '⌛').await?;
|
||||||
let s = get_leaderboard(ctx, &env, &bm, order, guild).await?;
|
let s = get_leaderboard(ctx, &env, &bm, show_all, order, guild).await?;
|
||||||
reaction.delete(&ctx).await?;
|
reaction.delete(&ctx).await?;
|
||||||
s
|
s
|
||||||
};
|
};
|
||||||
|
@ -399,6 +418,7 @@ pub async fn get_leaderboard(
|
||||||
ctx: &Context,
|
ctx: &Context,
|
||||||
env: &OsuEnv,
|
env: &OsuEnv,
|
||||||
bm: &BeatmapWithMode,
|
bm: &BeatmapWithMode,
|
||||||
|
show_unranked: bool,
|
||||||
order: OrderBy,
|
order: OrderBy,
|
||||||
guild: GuildId,
|
guild: GuildId,
|
||||||
) -> Result<Vec<Ranking>> {
|
) -> Result<Vec<Ranking>> {
|
||||||
|
@ -462,6 +482,24 @@ pub async fn get_leaderboard(
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
if !show_unranked {
|
||||||
|
let mut mp = BTreeMap::<u64 /* user id */, Vec<Ranking>>::new();
|
||||||
|
for r in scores.drain(0..scores.len()) {
|
||||||
|
let rs = mp.entry(r.score.user_id).or_default();
|
||||||
|
match rs.iter_mut().find(|t| t.score.mods == r.score.mods) {
|
||||||
|
Some(t) => {
|
||||||
|
if t.pp < r.pp {
|
||||||
|
*t = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
rs.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scores = mp.into_values().flatten().collect();
|
||||||
|
}
|
||||||
|
|
||||||
match order {
|
match order {
|
||||||
OrderBy::PP => scores.sort_by(|a, b| {
|
OrderBy::PP => scores.sort_by(|a, b| {
|
||||||
(b.official, b.pp)
|
(b.official, b.pp)
|
||||||
|
|
|
@ -2,6 +2,7 @@ use chrono::{DateTime, Utc};
|
||||||
use mods::Stats;
|
use mods::Stats;
|
||||||
use rosu_v2::prelude::GameModIntermode;
|
use rosu_v2::prelude::GameModIntermode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
@ -439,6 +440,39 @@ impl Beatmap {
|
||||||
pub fn thumbnail_url(&self) -> String {
|
pub fn thumbnail_url(&self) -> String {
|
||||||
format!("https://b.ppy.sh/thumb/{}l.jpg", self.beatmapset_id)
|
format!("https://b.ppy.sh/thumb/{}l.jpg", self.beatmapset_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Beatmap title and difficulty name
|
||||||
|
pub fn map_title(&self) -> String {
|
||||||
|
MessageBuilder::new()
|
||||||
|
.push_safe(&self.artist)
|
||||||
|
.push(" - ")
|
||||||
|
.push_safe(&self.title)
|
||||||
|
.push(" [")
|
||||||
|
.push_safe(&self.difficulty_name)
|
||||||
|
.push("]")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full title with creator name if needed
|
||||||
|
pub fn full_title(&self, mods: &Mods, stars: f64) -> String {
|
||||||
|
let creator: Cow<str> = if self.difficulty_name.contains("'s") {
|
||||||
|
"".into()
|
||||||
|
} else {
|
||||||
|
format!(" by {}", self.creator).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageBuilder::new()
|
||||||
|
.push_safe(&self.artist)
|
||||||
|
.push(" - ")
|
||||||
|
.push_safe(&self.title)
|
||||||
|
.push(" [")
|
||||||
|
.push_safe(&self.difficulty_name)
|
||||||
|
.push("] ")
|
||||||
|
.push(mods.to_string())
|
||||||
|
.push(format!(" ({:.2}\\*)", stars))
|
||||||
|
.push_safe(creator)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
@ -618,6 +652,11 @@ pub struct Score {
|
||||||
pub max_combo: u64,
|
pub max_combo: u64,
|
||||||
pub perfect: bool,
|
pub perfect: bool,
|
||||||
|
|
||||||
|
/// Whether score would get pp
|
||||||
|
pub ranked: Option<bool>,
|
||||||
|
/// Whether score would be stored
|
||||||
|
pub preserved: Option<bool>,
|
||||||
|
|
||||||
// Some APIv2 stats
|
// Some APIv2 stats
|
||||||
pub server_accuracy: f64,
|
pub server_accuracy: f64,
|
||||||
pub global_rank: Option<u32>,
|
pub global_rank: Option<u32>,
|
||||||
|
|
|
@ -135,6 +135,8 @@ impl From<rosu::score::Score> for Score {
|
||||||
count_geki: legacy_stats.count_geki as u64,
|
count_geki: legacy_stats.count_geki as u64,
|
||||||
max_combo: s.max_combo as u64,
|
max_combo: s.max_combo as u64,
|
||||||
perfect: s.is_perfect_combo,
|
perfect: s.is_perfect_combo,
|
||||||
|
ranked: s.ranked,
|
||||||
|
preserved: s.preserve,
|
||||||
lazer_build_id: s.build_id,
|
lazer_build_id: s.build_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,6 +27,9 @@ pub trait Paginate: Send + Sized {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cleans up after the pagination has timed out.
|
||||||
|
async fn cleanup(&mut self, _ctx: &Context, _m: &mut Message) -> () {}
|
||||||
|
|
||||||
/// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling
|
/// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling
|
||||||
/// before handing the functionality over.
|
/// before handing the functionality over.
|
||||||
///
|
///
|
||||||
|
@ -116,6 +119,10 @@ impl<Inner: Paginate> Paginate for WithPageCount<Inner> {
|
||||||
fn is_empty(&self) -> Option<bool> {
|
fn is_empty(&self) -> Option<bool> {
|
||||||
Some(self.page_count == 0)
|
Some(self.page_count == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn cleanup(&mut self, ctx: &Context, msg: &mut Message) {
|
||||||
|
self.inner.cleanup(ctx, msg).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
|
@ -240,6 +247,8 @@ pub async fn paginate_with_first_message(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pager.cleanup(ctx, &mut message).await;
|
||||||
|
|
||||||
for reaction in reactions {
|
for reaction in reactions {
|
||||||
if reaction.delete_all(&ctx).await.pls_ok().is_none() {
|
if reaction.delete_all(&ctx).await.pls_ok().is_none() {
|
||||||
// probably no permission to delete all reactions, fall back to delete my own.
|
// probably no permission to delete all reactions, fall back to delete my own.
|
||||||
|
|
|
@ -166,6 +166,8 @@ async fn main() {
|
||||||
handler.push_hook(youmubot_osu::discord::score_hook);
|
handler.push_hook(youmubot_osu::discord::score_hook);
|
||||||
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_check_button);
|
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_check_button);
|
||||||
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_last_button);
|
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_last_button);
|
||||||
|
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_last_set_button);
|
||||||
|
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_simulate_button);
|
||||||
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_lb_button);
|
handler.push_interaction_hook(youmubot_osu::discord::interaction::handle_lb_button);
|
||||||
}
|
}
|
||||||
#[cfg(feature = "codeforces")]
|
#[cfg(feature = "codeforces")]
|
||||||
|
|
Loading…
Add table
Reference in a new issue