mirror of
https://github.com/natsukagami/youmubot.git
synced 2025-04-18 00:08:54 +00:00
Misc improvements to osu! (#15)
* Use len() as hint for pagination * Implement len for beatmapset paging * More tweaks for pagination * Move table rendering to display mod * Use grid throughout the commands * No more double user update * Sort by PP by default * Filter check by mod * Filter lb by mod * Improve 1-page cases
This commit is contained in:
parent
1799b70bc1
commit
2feb91ac00
5 changed files with 482 additions and 297 deletions
|
@ -1,4 +1,346 @@
|
|||
pub use beatmapset::display_beatmapset;
|
||||
pub use scores::ScoreListStyle;
|
||||
|
||||
mod scores {
|
||||
use crate::models::{Mode, Score};
|
||||
use serenity::{framework::standard::CommandResult, model::channel::Message};
|
||||
use youmubot_prelude::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
/// The style for the scores list to be displayed.
|
||||
pub enum ScoreListStyle {
|
||||
Table,
|
||||
Grid,
|
||||
}
|
||||
|
||||
impl Default for ScoreListStyle {
|
||||
fn default() -> Self {
|
||||
Self::Table
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ScoreListStyle {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"--table" => Ok(Self::Table),
|
||||
"--grid" => Ok(Self::Grid),
|
||||
_ => Err(Error::msg("unknown value")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScoreListStyle {
|
||||
pub async fn display_scores<'a>(
|
||||
self,
|
||||
scores: Vec<Score>,
|
||||
mode: Mode,
|
||||
ctx: &'a Context,
|
||||
m: &'a Message,
|
||||
) -> CommandResult {
|
||||
match self {
|
||||
ScoreListStyle::Table => table::display_scores_table(scores, mode, ctx, m).await,
|
||||
ScoreListStyle::Grid => grid::display_scores_grid(scores, mode, ctx, m).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod grid {
|
||||
use crate::discord::{
|
||||
cache::save_beatmap, BeatmapCache, BeatmapMetaCache, BeatmapWithMode,
|
||||
};
|
||||
use crate::models::{Mode, Score};
|
||||
use serenity::{framework::standard::CommandResult, model::channel::Message};
|
||||
use youmubot_prelude::*;
|
||||
|
||||
pub async fn display_scores_grid<'a>(
|
||||
scores: Vec<Score>,
|
||||
mode: Mode,
|
||||
ctx: &'a Context,
|
||||
m: &'a Message,
|
||||
) -> CommandResult {
|
||||
if scores.is_empty() {
|
||||
m.reply(&ctx, "No plays found").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
paginate_reply(
|
||||
Paginate { scores, mode },
|
||||
ctx,
|
||||
m,
|
||||
std::time::Duration::from_secs(60),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Paginate {
|
||||
scores: Vec<Score>,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl pagination::Paginate for Paginate {
|
||||
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> {
|
||||
let data = ctx.data.read().await;
|
||||
let client = data.get::<crate::discord::OsuClient>().unwrap();
|
||||
let osu = data.get::<BeatmapMetaCache>().unwrap();
|
||||
let beatmap_cache = data.get::<BeatmapCache>().unwrap();
|
||||
let page = page as usize;
|
||||
let score = &self.scores[page];
|
||||
|
||||
let hourglass = msg.react(ctx, '⌛').await?;
|
||||
let mode = self.mode;
|
||||
let beatmap = osu.get_beatmap(score.beatmap_id, mode).await?;
|
||||
let content = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?;
|
||||
let bm = BeatmapWithMode(beatmap, mode);
|
||||
let user = client
|
||||
.user(crate::request::UserID::ID(score.user_id), |f| f)
|
||||
.await?
|
||||
.ok_or_else(|| Error::msg("user not found"))?;
|
||||
|
||||
msg.edit(ctx, |e| {
|
||||
e.embed(|e| {
|
||||
crate::discord::embeds::score_embed(score, &bm, &content, &user)
|
||||
.footer(format!("Page {}/{}", page + 1, self.scores.len()))
|
||||
.build(e)
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
save_beatmap(&*ctx.data.read().await, msg.channel_id, &bm).await?;
|
||||
|
||||
// End
|
||||
hourglass.delete(ctx).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn len(&self) -> Option<usize> {
|
||||
Some(self.scores.len())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod table {
|
||||
use crate::discord::{Beatmap, BeatmapCache, BeatmapInfo, BeatmapMetaCache};
|
||||
use crate::models::{Mode, Score};
|
||||
use serenity::{framework::standard::CommandResult, model::channel::Message};
|
||||
use youmubot_prelude::*;
|
||||
|
||||
pub async fn display_scores_table<'a>(
|
||||
scores: Vec<Score>,
|
||||
mode: Mode,
|
||||
ctx: &'a Context,
|
||||
m: &'a Message,
|
||||
) -> CommandResult {
|
||||
if scores.is_empty() {
|
||||
m.reply(&ctx, "No plays found").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
paginate_reply(
|
||||
Paginate { scores, mode },
|
||||
ctx,
|
||||
m,
|
||||
std::time::Duration::from_secs(60),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Paginate {
|
||||
scores: Vec<Score>,
|
||||
mode: Mode,
|
||||
}
|
||||
|
||||
impl Paginate {
|
||||
fn total_pages(&self) -> usize {
|
||||
(self.scores.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE
|
||||
}
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE: usize = 5;
|
||||
|
||||
#[async_trait]
|
||||
impl pagination::Paginate for Paginate {
|
||||
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> {
|
||||
let data = ctx.data.read().await;
|
||||
let osu = data.get::<BeatmapMetaCache>().unwrap();
|
||||
let beatmap_cache = data.get::<BeatmapCache>().unwrap();
|
||||
let page = page as usize;
|
||||
let start = page * ITEMS_PER_PAGE;
|
||||
let end = self.scores.len().min(start + ITEMS_PER_PAGE);
|
||||
if start >= end {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let hourglass = msg.react(ctx, '⌛').await?;
|
||||
let plays = &self.scores[start..end];
|
||||
let mode = self.mode;
|
||||
let beatmaps = plays
|
||||
.iter()
|
||||
.map(|play| async move {
|
||||
let beatmap = osu.get_beatmap(play.beatmap_id, mode).await?;
|
||||
let info = {
|
||||
let b = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?;
|
||||
mode.to_oppai_mode()
|
||||
.and_then(|mode| b.get_info_with(Some(mode), play.mods).ok())
|
||||
};
|
||||
Ok((beatmap, info)) as Result<(Beatmap, Option<BeatmapInfo>)>
|
||||
})
|
||||
.collect::<stream::FuturesOrdered<_>>()
|
||||
.map(|v| v.ok())
|
||||
.collect::<Vec<_>>();
|
||||
let pp = plays
|
||||
.iter()
|
||||
.map(|p| async move {
|
||||
match p.pp.map(|pp| format!("{:.2}pp", pp)) {
|
||||
Some(v) => Ok(v),
|
||||
None => {
|
||||
let b = beatmap_cache.get_beatmap(p.beatmap_id).await?;
|
||||
let r: Result<_> = Ok(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,
|
||||
},
|
||||
oppai_rs::Accuracy::from_hits(
|
||||
p.count_100 as u32,
|
||||
p.count_50 as u32,
|
||||
),
|
||||
Some(op),
|
||||
p.mods,
|
||||
)
|
||||
.ok()
|
||||
.map(|pp| format!("{:.2}pp [?]", pp))
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_owned()));
|
||||
r
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<stream::FuturesOrdered<_>>()
|
||||
.map(|v| v.unwrap_or_else(|_| "-".to_owned()))
|
||||
.collect::<Vec<String>>();
|
||||
let (beatmaps, pp) = future::join(beatmaps, pp).await;
|
||||
|
||||
let ranks = plays
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| match p.rank {
|
||||
crate::models::Rank::F => beatmaps[i]
|
||||
.as_ref()
|
||||
.and_then(|(_, i)| i.map(|i| i.objects))
|
||||
.map(|total| {
|
||||
(p.count_300 + p.count_100 + p.count_50 + p.count_miss) as f64
|
||||
/ (total as f64)
|
||||
* 100.0
|
||||
})
|
||||
.map(|p| format!("F [{:.0}%]", p))
|
||||
.unwrap_or_else(|| "F".to_owned()),
|
||||
v => v.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let beatmaps = beatmaps
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| {
|
||||
let play = &plays[i];
|
||||
b.map(|(beatmap, info)| {
|
||||
format!(
|
||||
"[{:.1}*] {} - {} [{}] ({})",
|
||||
info.map(|i| i.stars as f64)
|
||||
.unwrap_or(beatmap.difficulty.stars),
|
||||
beatmap.artist,
|
||||
beatmap.title,
|
||||
beatmap.difficulty_name,
|
||||
beatmap.short_link(Some(self.mode), Some(play.mods)),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "FETCH_FAILED".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);
|
||||
/* ranks width */
|
||||
let rw = ranks.iter().map(|v| v.len()).max().unwrap().max(5);
|
||||
|
||||
let mut m = serenity::utils::MessageBuilder::new();
|
||||
// Table header
|
||||
m.push_line(format!(
|
||||
" # | {:pw$} | accuracy | {:rw$} | {:mw$} | {:bw$}",
|
||||
"pp",
|
||||
"ranks",
|
||||
"mods",
|
||||
"beatmap",
|
||||
rw = rw,
|
||||
pw = pw,
|
||||
mw = mw,
|
||||
bw = bw
|
||||
));
|
||||
m.push_line(format!(
|
||||
"------{:-<pw$}--------------{:-<rw$}---{:-<mw$}---{:-<bw$}",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
rw = rw,
|
||||
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} | {:^rw$} | {:mw$} | {:bw$}",
|
||||
id + start + 1,
|
||||
pp[id],
|
||||
format!("{:.2}%", play.accuracy(self.mode)),
|
||||
ranks[id],
|
||||
play.mods.to_string(),
|
||||
beatmap,
|
||||
rw = rw,
|
||||
pw = pw,
|
||||
mw = mw,
|
||||
bw = bw
|
||||
));
|
||||
}
|
||||
// End
|
||||
let table = m.build().replace("```", "\\`\\`\\`");
|
||||
let mut m = serenity::utils::MessageBuilder::new();
|
||||
m.push_codeblock(table, None).push_line(format!(
|
||||
"Page **{}/{}**",
|
||||
page + 1,
|
||||
self.total_pages()
|
||||
));
|
||||
if self.mode.to_oppai_mode().is_none() {
|
||||
m.push_line("Note: star difficulty doesn't reflect mods applied.");
|
||||
} else {
|
||||
m.push_line("[?] means pp was predicted by oppai-rs.");
|
||||
}
|
||||
msg.edit(ctx, |f| f.content(m.to_string())).await?;
|
||||
hourglass.delete(ctx).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn len(&self) -> Option<usize> {
|
||||
Some(self.total_pages())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod beatmapset {
|
||||
use crate::{
|
||||
|
@ -71,6 +413,10 @@ mod beatmapset {
|
|||
|
||||
#[async_trait]
|
||||
impl pagination::Paginate for Paginate {
|
||||
fn len(&self) -> Option<usize> {
|
||||
Some(self.maps.len())
|
||||
}
|
||||
|
||||
async fn render(
|
||||
&mut self,
|
||||
page: u8,
|
||||
|
|
|
@ -169,6 +169,7 @@ pub(crate) struct ScoreEmbedBuilder<'a> {
|
|||
u: &'a User,
|
||||
top_record: Option<u8>,
|
||||
world_record: Option<u16>,
|
||||
footer: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> ScoreEmbedBuilder<'a> {
|
||||
|
@ -180,6 +181,10 @@ impl<'a> ScoreEmbedBuilder<'a> {
|
|||
self.world_record = Some(rank);
|
||||
self
|
||||
}
|
||||
pub fn footer(&mut self, footer: impl Into<String>) -> &mut Self {
|
||||
self.footer = Some(footer.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn score_embed<'a>(
|
||||
|
@ -195,12 +200,13 @@ pub(crate) fn score_embed<'a>(
|
|||
u,
|
||||
top_record: None,
|
||||
world_record: None,
|
||||
footer: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ScoreEmbedBuilder<'a> {
|
||||
#[allow(clippy::many_single_char_names)]
|
||||
pub fn build<'b>(&self, m: &'b mut CreateEmbed) -> &'b mut CreateEmbed {
|
||||
pub fn build<'b>(&mut self, m: &'b mut CreateEmbed) -> &'b mut CreateEmbed {
|
||||
let mode = self.bm.mode();
|
||||
let b = &self.bm.0;
|
||||
let s = self.s;
|
||||
|
@ -358,8 +364,12 @@ impl<'a> ScoreEmbedBuilder<'a> {
|
|||
)
|
||||
.field("Map stats", diff.format_info(mode, s.mods, b), false)
|
||||
.timestamp(&s.date);
|
||||
let mut footer = self.footer.take().unwrap_or_else(String::new);
|
||||
if mode.to_oppai_mode().is_none() && s.mods != Mods::NOMOD {
|
||||
m.footer(|f| f.text("Star difficulty does not reflect game mods."));
|
||||
footer += " Star difficulty does not reflect game mods.";
|
||||
}
|
||||
if !footer.is_empty() {
|
||||
m.footer(|f| f.text(footer));
|
||||
}
|
||||
m
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
use crate::{
|
||||
discord::beatmap_cache::BeatmapMetaCache,
|
||||
discord::oppai_cache::{BeatmapCache, BeatmapInfo, OppaiAccuracy},
|
||||
models::{Beatmap, Mode, Mods, Score, User},
|
||||
discord::display::ScoreListStyle,
|
||||
discord::oppai_cache::{BeatmapCache, BeatmapInfo},
|
||||
models::{Beatmap, Mode, Mods, User},
|
||||
request::UserID,
|
||||
Client as OsuHttpClient,
|
||||
};
|
||||
|
@ -31,7 +32,7 @@ use db::OsuUser;
|
|||
use db::{OsuLastBeatmap, OsuSavedUsers, OsuUserBests};
|
||||
use embeds::{beatmap_embed, score_embed, user_embed};
|
||||
pub use hook::hook;
|
||||
use server_rank::{LEADERBOARD_COMMAND, SERVER_RANK_COMMAND, UPDATE_LEADERBOARD_COMMAND};
|
||||
use server_rank::{SERVER_RANK_COMMAND, UPDATE_LEADERBOARD_COMMAND};
|
||||
|
||||
/// The osu! client.
|
||||
pub(crate) struct OsuClient;
|
||||
|
@ -107,7 +108,6 @@ pub fn setup(
|
|||
check,
|
||||
top,
|
||||
server_rank,
|
||||
leaderboard,
|
||||
update_leaderboard
|
||||
)]
|
||||
#[default_command(std)]
|
||||
|
@ -313,210 +313,16 @@ impl FromStr for Nth {
|
|||
}
|
||||
}
|
||||
|
||||
async fn list_plays<'a>(
|
||||
plays: Vec<Score>,
|
||||
mode: Mode,
|
||||
ctx: &'a Context,
|
||||
m: &'a Message,
|
||||
) -> CommandResult {
|
||||
let plays = Arc::new(plays);
|
||||
if plays.is_empty() {
|
||||
m.reply(&ctx, "No plays found").await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE: usize = 5;
|
||||
let total_pages = (plays.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE;
|
||||
paginate_reply_fn(
|
||||
move |page, ctx: &Context, msg| {
|
||||
let plays = plays.clone();
|
||||
Box::pin(async move {
|
||||
let data = ctx.data.read().await;
|
||||
let osu = data.get::<BeatmapMetaCache>().unwrap();
|
||||
let beatmap_cache = data.get::<BeatmapCache>().unwrap();
|
||||
let page = page as usize;
|
||||
let start = page * ITEMS_PER_PAGE;
|
||||
let end = plays.len().min(start + ITEMS_PER_PAGE);
|
||||
if start >= end {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let hourglass = msg.react(ctx, '⌛').await?;
|
||||
let plays = &plays[start..end];
|
||||
let beatmaps = plays
|
||||
.iter()
|
||||
.map(|play| async move {
|
||||
let beatmap = osu.get_beatmap(play.beatmap_id, mode).await?;
|
||||
let info = {
|
||||
let b = beatmap_cache.get_beatmap(beatmap.beatmap_id).await?;
|
||||
mode.to_oppai_mode()
|
||||
.and_then(|mode| b.get_info_with(Some(mode), play.mods).ok())
|
||||
};
|
||||
Ok((beatmap, info)) as Result<(Beatmap, Option<BeatmapInfo>)>
|
||||
})
|
||||
.collect::<stream::FuturesOrdered<_>>()
|
||||
.map(|v| v.ok())
|
||||
.collect::<Vec<_>>();
|
||||
let pp = plays
|
||||
.iter()
|
||||
.map(|p| async move {
|
||||
match p.pp.map(|pp| format!("{:.2}pp", pp)) {
|
||||
Some(v) => Ok(v),
|
||||
None => {
|
||||
let b = beatmap_cache.get_beatmap(p.beatmap_id).await?;
|
||||
let r: Result<_> = Ok(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,
|
||||
},
|
||||
OppaiAccuracy::from_hits(
|
||||
p.count_100 as u32,
|
||||
p.count_50 as u32,
|
||||
),
|
||||
Some(op),
|
||||
p.mods,
|
||||
)
|
||||
.ok()
|
||||
.map(|pp| format!("{:.2}pp [?]", pp))
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_owned()));
|
||||
r
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect::<stream::FuturesOrdered<_>>()
|
||||
.map(|v| v.unwrap_or_else(|_| "-".to_owned()))
|
||||
.collect::<Vec<String>>();
|
||||
let (beatmaps, pp) = future::join(beatmaps, pp).await;
|
||||
|
||||
let ranks = plays
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| match p.rank {
|
||||
crate::models::Rank::F => beatmaps[i]
|
||||
.as_ref()
|
||||
.and_then(|(_, i)| i.map(|i| i.objects))
|
||||
.map(|total| {
|
||||
(p.count_300 + p.count_100 + p.count_50 + p.count_miss) as f64
|
||||
/ (total as f64)
|
||||
* 100.0
|
||||
})
|
||||
.map(|p| format!("F [{:.0}%]", p))
|
||||
.unwrap_or_else(|| "F".to_owned()),
|
||||
v => v.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let beatmaps = beatmaps
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| {
|
||||
let play = &plays[i];
|
||||
b.map(|(beatmap, info)| {
|
||||
format!(
|
||||
"[{:.1}*] {} - {} [{}] ({})",
|
||||
info.map(|i| i.stars as f64)
|
||||
.unwrap_or(beatmap.difficulty.stars),
|
||||
beatmap.artist,
|
||||
beatmap.title,
|
||||
beatmap.difficulty_name,
|
||||
beatmap.short_link(Some(mode), Some(play.mods)),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "FETCH_FAILED".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);
|
||||
/* ranks width */
|
||||
let rw = ranks.iter().map(|v| v.len()).max().unwrap().max(5);
|
||||
|
||||
let mut m = MessageBuilder::new();
|
||||
// Table header
|
||||
m.push_line(format!(
|
||||
" # | {:pw$} | accuracy | {:rw$} | {:mw$} | {:bw$}",
|
||||
"pp",
|
||||
"ranks",
|
||||
"mods",
|
||||
"beatmap",
|
||||
rw = rw,
|
||||
pw = pw,
|
||||
mw = mw,
|
||||
bw = bw
|
||||
));
|
||||
m.push_line(format!(
|
||||
"------{:-<pw$}--------------{:-<rw$}---{:-<mw$}---{:-<bw$}",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
rw = rw,
|
||||
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} | {:^rw$} | {:mw$} | {:bw$}",
|
||||
id + start + 1,
|
||||
pp[id],
|
||||
format!("{:.2}%", play.accuracy(mode)),
|
||||
ranks[id],
|
||||
play.mods.to_string(),
|
||||
beatmap,
|
||||
rw = rw,
|
||||
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 mode.to_oppai_mode().is_none() {
|
||||
m.push_line("Note: star difficulty doesn't reflect mods applied.");
|
||||
} else {
|
||||
m.push_line("[?] means pp was predicted by oppai-rs.");
|
||||
}
|
||||
msg.edit(ctx, |f| f.content(m.to_string())).await?;
|
||||
hourglass.delete(ctx).await?;
|
||||
Ok(true)
|
||||
})
|
||||
},
|
||||
ctx,
|
||||
m,
|
||||
std::time::Duration::from_secs(60),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[aliases("rs", "rc")]
|
||||
#[description = "Gets an user's recent play"]
|
||||
#[usage = "#[the nth recent play = --all] / [mode (std, taiko, mania, catch) = std] / [username / user id = your saved id]"]
|
||||
#[usage = "#[the nth recent play = --all] / [style (table or grid) = --table] / [mode (std, taiko, mania, catch) = std] / [username / user id = your saved id]"]
|
||||
#[example = "#1 / taiko / natsukagami"]
|
||||
#[max_args(3)]
|
||||
#[max_args(4)]
|
||||
pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
let nth = args.single::<Nth>().unwrap_or(Nth::All);
|
||||
let style = args.single::<ScoreListStyle>().unwrap_or_default();
|
||||
let mode = args.single::<ModeArg>().unwrap_or(ModeArg(Mode::Std)).0;
|
||||
let user = to_user_id_query(args.single::<UsernameArg>().ok(), &*data, msg).await?;
|
||||
|
||||
|
@ -556,7 +362,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu
|
|||
let plays = osu
|
||||
.user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50))
|
||||
.await?;
|
||||
list_plays(plays, mode, ctx, msg).await?;
|
||||
style.display_scores(plays, mode, ctx, msg).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
@ -628,11 +434,12 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
|
|||
|
||||
#[command]
|
||||
#[aliases("c", "chk")]
|
||||
#[usage = "[username or tag = yourself]"]
|
||||
#[usage = "[style (table or grid) = --table] / [username or tag = yourself] / [mods to filter]"]
|
||||
#[description = "Check your own or someone else's best record on the last beatmap. Also stores the result if possible."]
|
||||
#[max_args(1)]
|
||||
#[max_args(3)]
|
||||
pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
let mods = args.find::<Mods>().unwrap_or(Mods::NOMOD);
|
||||
let bm = cache::get_beatmap(&*data, msg.channel_id).await?;
|
||||
|
||||
match bm {
|
||||
|
@ -643,6 +450,9 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
|
|||
Some(bm) => {
|
||||
let b = &bm.0;
|
||||
let m = bm.1;
|
||||
let style = args
|
||||
.single::<ScoreListStyle>()
|
||||
.unwrap_or(ScoreListStyle::Grid);
|
||||
let username_arg = args.single::<UsernameArg>().ok();
|
||||
let user_id = match username_arg.as_ref() {
|
||||
Some(UsernameArg::Tagged(v)) => Some(*v),
|
||||
|
@ -652,38 +462,34 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
|
|||
let user = to_user_id_query(username_arg, &*data, msg).await?;
|
||||
|
||||
let osu = data.get::<OsuClient>().unwrap();
|
||||
let oppai = data.get::<BeatmapCache>().unwrap();
|
||||
|
||||
let content = oppai.get_beatmap(b.beatmap_id).await?;
|
||||
|
||||
let user = osu
|
||||
.user(user, |f| f)
|
||||
.await?
|
||||
.ok_or_else(|| Error::msg("User not found"))?;
|
||||
let scores = osu
|
||||
let mut scores = osu
|
||||
.scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m))
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|s| s.mods.contains(mods))
|
||||
.collect::<Vec<_>>();
|
||||
scores.sort_by(|a, b| b.pp.unwrap().partial_cmp(&a.pp.unwrap()).unwrap());
|
||||
|
||||
if scores.is_empty() {
|
||||
msg.reply(&ctx, "No scores found").await?;
|
||||
}
|
||||
|
||||
for score in scores.iter() {
|
||||
msg.channel_id
|
||||
.send_message(&ctx, |c| {
|
||||
c.embed(|m| score_embed(&score, &bm, &content, &user).build(m))
|
||||
})
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(user_id) = user_id {
|
||||
// Save to database
|
||||
data.get::<OsuUserBests>()
|
||||
.unwrap()
|
||||
.save(user_id, m, scores)
|
||||
.save(user_id, m, scores.clone())
|
||||
.await
|
||||
.pls_ok();
|
||||
}
|
||||
|
||||
style.display_scores(scores, m, ctx, msg).await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -692,12 +498,13 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul
|
|||
|
||||
#[command]
|
||||
#[description = "Get the n-th top record of an user."]
|
||||
#[usage = "[mode (std, taiko, catch, mania)] = std / #[n-th = --all] / [username or user_id = your saved user id]"]
|
||||
#[example = "taiko / #2 / natsukagami"]
|
||||
#[max_args(3)]
|
||||
#[usage = "#[n-th = --all] / [style (table or grid) = --table] / [mode (std, taiko, catch, mania)] = std / [username or user_id = your saved user id]"]
|
||||
#[example = "#2 / taiko / natsukagami"]
|
||||
#[max_args(4)]
|
||||
pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
let nth = args.single::<Nth>().unwrap_or(Nth::All);
|
||||
let style = args.single::<ScoreListStyle>().unwrap_or_default();
|
||||
let mode = args
|
||||
.single::<ModeArg>()
|
||||
.map(|ModeArg(t)| t)
|
||||
|
@ -750,7 +557,7 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult
|
|||
let plays = osu
|
||||
.user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100))
|
||||
.await?;
|
||||
list_plays(plays, mode, ctx, msg).await?;
|
||||
style.display_scores(plays, mode, ctx, msg).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
|
|
@ -5,6 +5,7 @@ use super::{
|
|||
};
|
||||
use crate::{
|
||||
discord::{
|
||||
display::ScoreListStyle,
|
||||
oppai_cache::{BeatmapCache, OppaiAccuracy},
|
||||
BeatmapWithMode,
|
||||
},
|
||||
|
@ -151,23 +152,34 @@ enum OrderBy {
|
|||
Score,
|
||||
}
|
||||
|
||||
impl From<&str> for OrderBy {
|
||||
fn from(s: &str) -> Self {
|
||||
if s == "--score" {
|
||||
Self::Score
|
||||
} else {
|
||||
Self::PP
|
||||
impl Default for OrderBy {
|
||||
fn default() -> Self {
|
||||
Self::PP
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for OrderBy {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"--score" => Ok(OrderBy::Score),
|
||||
"--pp" => Ok(OrderBy::PP),
|
||||
_ => Err(Error::msg("unknown value")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[command("updatelb")]
|
||||
#[description = "Update the leaderboard on the last seen beatmap"]
|
||||
#[usage = "[--score to sort by score, default to sort by pp]"]
|
||||
#[max_args(1)]
|
||||
#[command("leaderboard")]
|
||||
#[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]"]
|
||||
#[description = "See the server's ranks on the last seen beatmap"]
|
||||
#[max_args(2)]
|
||||
#[only_in(guilds)]
|
||||
pub async fn update_leaderboard(ctx: &Context, m: &Message, args: Args) -> CommandResult {
|
||||
let sort_order = OrderBy::from(args.rest());
|
||||
pub async fn update_leaderboard(ctx: &Context, m: &Message, mut args: Args) -> CommandResult {
|
||||
let sort_order = args.single::<OrderBy>().unwrap_or_default();
|
||||
let style = args.single::<ScoreListStyle>().unwrap_or_default();
|
||||
let mods = args.find::<Mods>().unwrap_or(Mods::NOMOD);
|
||||
|
||||
let guild = m.guild_id.unwrap();
|
||||
let data = ctx.data.read().await;
|
||||
|
@ -246,34 +258,16 @@ pub async fn update_leaderboard(ctx: &Context, m: &Message, args: Args) -> Comma
|
|||
.await
|
||||
.ok();
|
||||
drop(update_lock);
|
||||
show_leaderboard(ctx, m, bm, sort_order).await
|
||||
}
|
||||
|
||||
#[command("leaderboard")]
|
||||
#[aliases("lb", "bmranks", "br", "cc")]
|
||||
#[usage = "[--score to sort by score, default to sort by pp]"]
|
||||
#[description = "See the server's ranks on the last seen beatmap"]
|
||||
#[max_args(1)]
|
||||
#[only_in(guilds)]
|
||||
pub async fn leaderboard(ctx: &Context, m: &Message, args: Args) -> CommandResult {
|
||||
let sort_order = OrderBy::from(args.rest());
|
||||
|
||||
let data = ctx.data.read().await;
|
||||
let bm = match get_beatmap(&*data, m.channel_id).await? {
|
||||
Some(bm) => bm,
|
||||
None => {
|
||||
m.reply(&ctx, "No beatmap queried on this channel.").await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
show_leaderboard(ctx, m, bm, sort_order).await
|
||||
show_leaderboard(ctx, m, bm, mods, sort_order, style).await
|
||||
}
|
||||
|
||||
async fn show_leaderboard(
|
||||
ctx: &Context,
|
||||
m: &Message,
|
||||
bm: BeatmapWithMode,
|
||||
mods: Mods,
|
||||
order: OrderBy,
|
||||
style: ScoreListStyle,
|
||||
) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
|
||||
|
@ -295,31 +289,6 @@ async fn show_leaderboard(
|
|||
})
|
||||
};
|
||||
|
||||
// Run a check on the user once too!
|
||||
{
|
||||
let user = data
|
||||
.get::<OsuSavedUsers>()
|
||||
.unwrap()
|
||||
.by_user_id(m.author.id)
|
||||
.await?
|
||||
.map(|v| v.id);
|
||||
if let Some(id) = user {
|
||||
let osu = data.get::<OsuClient>().unwrap();
|
||||
if let Ok(scores) = osu
|
||||
.scores(bm.0.beatmap_id, |f| f.user(UserID::ID(id)))
|
||||
.await
|
||||
{
|
||||
if !scores.is_empty() {
|
||||
data.get::<OsuUserBests>()
|
||||
.unwrap()
|
||||
.save(m.author.id, mode, scores)
|
||||
.await
|
||||
.pls_ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let guild = m.guild_id.expect("Guild-only command");
|
||||
let member_cache = data.get::<MemberCache>().unwrap();
|
||||
let scores = {
|
||||
|
@ -337,6 +306,7 @@ async fn show_leaderboard(
|
|||
|
||||
let mut scores: Vec<(f64, String, Score)> = scores
|
||||
.into_iter()
|
||||
.filter(|(_, score)| score.mods.contains(mods))
|
||||
.map(|(user_id, score)| {
|
||||
member_cache
|
||||
.query(&ctx, user_id, guild)
|
||||
|
@ -381,6 +351,19 @@ async fn show_leaderboard(
|
|||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let ScoreListStyle::Grid = style {
|
||||
style
|
||||
.display_scores(
|
||||
scores.into_iter().map(|(_, _, a)| a).collect(),
|
||||
mode,
|
||||
ctx,
|
||||
m,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
paginate_reply_fn(
|
||||
move |page: u8, ctx: &Context, m: &mut Message| {
|
||||
const ITEMS_PER_PAGE: usize = 5;
|
||||
|
|
|
@ -12,6 +12,8 @@ use tokio::time as tokio_time;
|
|||
|
||||
const ARROW_RIGHT: &str = "➡️";
|
||||
const ARROW_LEFT: &str = "⬅️";
|
||||
const REWIND: &str = "⏪";
|
||||
const FAST_FORWARD: &str = "⏩";
|
||||
|
||||
/// A trait that provides the implementation of a paginator.
|
||||
#[async_trait::async_trait]
|
||||
|
@ -39,6 +41,16 @@ pub trait Paginate: Send + Sized {
|
|||
.await
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// Return the number of pages, if it is known in advance.
|
||||
/// If this is given, bounds-check will be done outside of `prerender` / `render`.
|
||||
fn len(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> Option<bool> {
|
||||
self.len().map(|v| v == 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
@ -90,15 +102,30 @@ async fn paginate_with_first_message(
|
|||
mut message: Message,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<()> {
|
||||
pager.prerender(&ctx, &mut message).await?;
|
||||
pager.render(0, ctx, &mut message).await?;
|
||||
// Just quit if there is only one page
|
||||
if pager.len().filter(|&v| v == 1).is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// React to the message
|
||||
let large_count = pager.len().filter(|&p| p > 10).is_some();
|
||||
if large_count {
|
||||
// add >> and << buttons
|
||||
message.react(&ctx, ReactionType::try_from(REWIND)?).await?;
|
||||
}
|
||||
message
|
||||
.react(&ctx, ReactionType::try_from(ARROW_LEFT)?)
|
||||
.await?;
|
||||
message
|
||||
.react(&ctx, ReactionType::try_from(ARROW_RIGHT)?)
|
||||
.await?;
|
||||
pager.prerender(&ctx, &mut message).await?;
|
||||
pager.render(0, ctx, &mut message).await?;
|
||||
if large_count {
|
||||
// add >> and << buttons
|
||||
message
|
||||
.react(&ctx, ReactionType::try_from(FAST_FORWARD)?)
|
||||
.await?;
|
||||
}
|
||||
// Build a reaction collector
|
||||
let mut reaction_collector = message.await_reactions(&ctx).removed(true).await;
|
||||
let mut page = 0;
|
||||
|
@ -167,21 +194,33 @@ pub async fn handle_pagination_reaction(
|
|||
let reaction = match reaction {
|
||||
ReactionAction::Added(v) | ReactionAction::Removed(v) => v,
|
||||
};
|
||||
let pages = pager.len();
|
||||
let fast = pages.map(|v| v / 10).unwrap_or(5).max(5) as u8;
|
||||
match &reaction.emoji {
|
||||
ReactionType::Unicode(ref s) => match s.as_str() {
|
||||
ARROW_LEFT if page == 0 => Ok(page),
|
||||
ARROW_LEFT => Ok(if pager.render(page - 1, ctx, message).await? {
|
||||
page - 1
|
||||
ReactionType::Unicode(ref s) => {
|
||||
let new_page = match s.as_str() {
|
||||
ARROW_LEFT | REWIND if page == 0 => return Ok(page),
|
||||
ARROW_LEFT => page - 1,
|
||||
REWIND => {
|
||||
if page < fast {
|
||||
0
|
||||
} else {
|
||||
page - fast
|
||||
}
|
||||
}
|
||||
ARROW_RIGHT if pages.filter(|&pages| page as usize + 1 >= pages).is_some() => {
|
||||
return Ok(page)
|
||||
}
|
||||
ARROW_RIGHT => page + 1,
|
||||
FAST_FORWARD => (pages.unwrap() as u8 - 1).min(page + fast),
|
||||
_ => return Ok(page),
|
||||
};
|
||||
Ok(if pager.render(new_page, ctx, message).await? {
|
||||
new_page
|
||||
} else {
|
||||
page
|
||||
}),
|
||||
ARROW_RIGHT => Ok(if pager.render(page + 1, ctx, message).await? {
|
||||
page + 1
|
||||
} else {
|
||||
page
|
||||
}),
|
||||
_ => Ok(page),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ => Ok(page),
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue