From 87e0a02e1f430af3ad9af8dfa2cd29bf92d045b5 Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Tue, 13 May 2025 00:24:20 +0200 Subject: [PATCH] Implement lazy score queries for `top`, `recent` and `check` (#71) * Use streams and such to have 200 results * WIP * display should take a lazy score future * Introduce a Scores stream so we can lazily load top score requests * Fit range to len * Remove debugging * Simplify from_user with `and_then` --- .envrc | 1 + Cargo.lock | 2 + youmubot-osu/Cargo.toml | 3 +- youmubot-osu/src/discord/announcer.rs | 10 +- youmubot-osu/src/discord/commands.rs | 53 ++--- youmubot-osu/src/discord/display.rs | 116 ++++++---- youmubot-osu/src/discord/interaction.rs | 15 +- youmubot-osu/src/discord/mod.rs | 135 ++++++----- youmubot-osu/src/discord/server_rank.rs | 4 +- youmubot-osu/src/lib.rs | 14 +- .../src/{request.rs => request/mod.rs} | 92 +++++--- youmubot-osu/src/request/scores.rs | 211 ++++++++++++++++++ youmubot-prelude/src/pagination.rs | 26 ++- 13 files changed, 482 insertions(+), 200 deletions(-) rename youmubot-osu/src/{request.rs => request/mod.rs} (80%) create mode 100644 youmubot-osu/src/request/scores.rs diff --git a/.envrc b/.envrc index 3550a30..0b1a02d 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,2 @@ +dotenv use flake diff --git a/Cargo.lock b/Cargo.lock index 9fc1460..da8fe82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -746,6 +746,7 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -3905,6 +3906,7 @@ dependencies = [ "bitflags 1.3.2", "chrono", "dashmap 5.5.3", + "futures", "futures-util", "lazy_static", "poise", diff --git a/youmubot-osu/Cargo.toml b/youmubot-osu/Cargo.toml index 63cbcd1..7f16d32 100644 --- a/youmubot-osu/Cargo.toml +++ b/youmubot-osu/Cargo.toml @@ -26,7 +26,8 @@ serenity = "0.12" poise = { git = "https://github.com/serenity-rs/poise", branch = "current" } zip = "0.6.2" rand = "0.8" -futures-util = "0.3.30" +futures = "0.3" +futures-util = "0.3" thiserror = "2" youmubot-db = { path = "../youmubot-db" } diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index b819d1e..1a0277c 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -22,6 +22,7 @@ use youmubot_prelude::*; use crate::discord::calculate_weighted_map_age; use crate::discord::db::OsuUserMode; +use crate::scores::Scores; use crate::{ discord::cache::save_beatmap, discord::oppai_cache::BeatmapContent, @@ -212,7 +213,8 @@ impl Announcer { }; let top_scores = env .client - .user_best(user_id.clone(), |f| f.mode(mode).limit(100)); + .user_best(user_id.clone(), move |f| f.mode(mode)) + .and_then(|v| v.get_all()); let (user, top_scores) = try_join!(user, top_scores)?; let mut user = user.unwrap(); // if top scores exist, user would too @@ -263,14 +265,14 @@ impl<'a> CollectedScore<'a> { user: &'a User, event: UserEventRank, ) -> Result> { - let scores = osu + let mut scores = osu .scores(event.beatmap_id, |f| { f.user(UserID::ID(user.id)).mode(event.mode) }) .await?; let score = match scores - .into_iter() .find(|s| (s.date - event.date).abs() < chrono::TimeDelta::seconds(5)) + .await? { Some(v) => v, None => { @@ -282,7 +284,7 @@ impl<'a> CollectedScore<'a> { }; Ok(Self { user, - score, + score: score.clone(), mode: event.mode, kind: ScoreType::world(event.rank), }) diff --git a/youmubot-osu/src/discord/commands.rs b/youmubot-osu/src/discord/commands.rs index a04cfe2..82a829c 100644 --- a/youmubot-osu/src/discord/commands.rs +++ b/youmubot-osu/src/discord/commands.rs @@ -40,7 +40,7 @@ async fn top( ctx: CmdContext<'_, U>, #[description = "Index of the score"] #[min = 1] - #[max = 100] + #[max = 200] // SCORE_COUNT_LIMIT index: Option, #[description = "Score listing style"] style: Option, #[description = "Game mode"] mode: Option, @@ -61,12 +61,11 @@ async fn top( ctx.defer().await?; let osu_client = &env.client; - let mut plays = osu_client - .user_best(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(100)) + let mode = args.mode; + let plays = osu_client + .user_best(UserID::ID(args.user.id), |f| f.mode(mode)) .await?; - plays.sort_unstable_by(|a, b| b.pp.partial_cmp(&a.pp).unwrap()); - handle_listing(ctx, plays, args, |nth, b| b.top_record(nth), "top").await } @@ -135,9 +134,10 @@ async fn recent( ctx.defer().await?; let osu_client = &env.client; + let mode = args.mode; let plays = osu_client .user_recent(UserID::ID(args.user.id), |f| { - f.mode(args.mode).include_fails(include_fails).limit(50) + f.mode(mode).include_fails(include_fails) }) .await?; @@ -168,8 +168,9 @@ async fn pinned( ctx.defer().await?; let osu_client = &env.client; + let mode = args.mode; let plays = osu_client - .user_pins(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(50)) + .user_pins(UserID::ID(args.user.id), |f| f.mode(mode)) .await?; handle_listing(ctx, plays, args, |_, b| b, "pinned").await @@ -254,7 +255,7 @@ pub async fn forcesave( async fn handle_listing( ctx: CmdContext<'_, U>, - plays: Vec, + mut plays: impl Scores, listing_args: ListingArgs, transform: impl for<'a> Fn(u8, ScoreEmbedBuilder<'a>) -> ScoreEmbedBuilder<'a>, listing_kind: &'static str, @@ -269,8 +270,10 @@ async fn handle_listing( match nth { Nth::Nth(nth) => { - let Some(play) = plays.get(nth as usize) else { - Err(Error::msg("no such play"))? + let play = if let Some(play) = plays.get(nth as usize).await? { + play + } else { + return Err(Error::msg("no such play"))?; }; let beatmap = env.beatmaps.get_beatmap(play.beatmap_id, mode).await?; @@ -301,20 +304,14 @@ async fn handle_listing( cache::save_beatmap(&env, ctx.channel_id(), &beatmap).await?; } Nth::All => { - let reply = ctx - .clone() - .reply(format!( - "Here are the {} plays by {}!", - listing_kind, - user.mention() - )) - .await?; + let header = format!("Here are the {} plays by {}!", listing_kind, user.mention()); + let reply = ctx.clone().reply(&header).await?; style .display_scores( plays, ctx.clone().serenity_context(), ctx.guild_id(), - (reply, ctx), + (reply, ctx).with_header(header), ) .await?; } @@ -478,14 +475,12 @@ async fn check( scores.reverse(); } - let msg = ctx - .clone() - .reply(format!( - "Here are the plays by {} on {}!", - args.user.mention(), - display - )) - .await?; + let header = format!( + "Here are the plays by {} on {}!", + args.user.mention(), + display + ); + let msg = ctx.clone().reply(&header).await?; let style = style.unwrap_or(if scores.len() <= 5 { ScoreListStyle::Grid @@ -498,7 +493,7 @@ async fn check( scores, ctx.clone().serenity_context(), ctx.guild_id(), - (msg, ctx), + (msg, ctx).with_header(header), ) .await?; @@ -618,7 +613,7 @@ async fn leaderboard( let reply = ctx.reply(header).await?; style .display_scores( - scores.into_iter().map(|s| s.score).collect(), + scores.into_iter().map(|s| s.score).collect::>(), ctx.serenity_context(), Some(guild.id), (reply, ctx), diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index 7918021..a9eee4e 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -7,7 +7,7 @@ mod scores { use youmubot_prelude::*; - use crate::models::Score; + use crate::scores::Scores; #[derive(Debug, Clone, Copy, PartialEq, Eq, ChoiceParameter)] /// The style for the scores list to be displayed. @@ -41,7 +41,7 @@ mod scores { impl ScoreListStyle { pub async fn display_scores( self, - scores: Vec, + scores: impl Scores, ctx: &Context, guild_id: Option, m: impl CanEdit, @@ -62,10 +62,10 @@ mod scores { use crate::discord::interaction::score_components; use crate::discord::{cache::save_beatmap, BeatmapWithMode, OsuEnv}; - use crate::models::Score; + use crate::scores::Scores; pub async fn display_scores_grid( - scores: Vec, + scores: impl Scores, ctx: &Context, guild_id: Option, mut on: impl CanEdit, @@ -93,15 +93,22 @@ mod scores { Ok(()) } - pub struct Paginate { + pub struct Paginate { env: OsuEnv, - scores: Vec, + scores: T, guild_id: Option, channel_id: serenity::all::ChannelId, } + impl Paginate { + fn pages_fake(&self) -> usize { + let size = self.scores.length_fetched(); + size.count() + if size.is_total() { 0 } else { 1 } + } + } + #[async_trait] - impl pagination::Paginate for Paginate { + impl pagination::Paginate for Paginate { async fn render( &mut self, page: u8, @@ -109,7 +116,10 @@ mod scores { ) -> Result> { let env = &self.env; let page = page as usize; - let score = &self.scores[page]; + let Some(score) = self.scores.get(page).await? else { + return Ok(None); + }; + let score = score.clone(); let beatmap = env .beatmaps @@ -132,8 +142,12 @@ mod scores { Ok(Some( CreateReply::default() .embed({ - crate::discord::embeds::score_embed(score, &bm, &content, &user) - .footer(format!("Page {}/{}", page + 1, self.scores.len())) + crate::discord::embeds::score_embed(&score, &bm, &content, &user) + .footer(format!( + "Page {} / {}", + page + 1, + self.scores.length_fetched() + )) .build() }) .components( @@ -146,7 +160,7 @@ mod scores { } fn len(&self) -> Option { - Some(self.scores.len()) + Some(self.pages_fake()) } } } @@ -163,33 +177,39 @@ mod scores { use crate::discord::oppai_cache::Stats; use crate::discord::{time_before_now, Beatmap, BeatmapInfo, OsuEnv}; - use crate::models::Score; + use crate::scores::Scores; pub async fn display_scores_as_file( - scores: Vec, + scores: impl Scores, ctx: &Context, mut on: impl CanEdit, ) -> Result<()> { - if scores.is_empty() { + let header = on.headers().unwrap_or("").to_owned(); + let content = format!("{}\n\nPreparing file...", header); + on.apply_edit(CreateReply::default().content(content)) + .await?; + + let mut p = Paginate { + env: ctx.data.read().await.get::().unwrap().clone(), + header: header.clone(), + scores, + }; + let Some(content) = p.to_table(0, usize::max_value()).await? else { on.apply_edit(CreateReply::default().content("No plays found")) .await?; return Ok(()); - } - let p = Paginate { - env: ctx.data.read().await.get::().unwrap().clone(), - header: on.get_message().await?.content.clone(), - scores, }; - let content = p.to_table(0, p.scores.len()).await; on.apply_edit( - CreateReply::default().attachment(CreateAttachment::bytes(content, "table.txt")), + CreateReply::default() + .content(header) + .attachment(CreateAttachment::bytes(content, "table.md")), ) .await?; Ok(()) } pub async fn display_scores_table( - scores: Vec, + scores: impl Scores, ctx: &Context, mut on: impl CanEdit, ) -> Result<()> { @@ -202,7 +222,7 @@ mod scores { paginate_with_first_message( Paginate { env: ctx.data.read().await.get::().unwrap().clone(), - header: on.get_message().await?.content.clone(), + header: on.headers().unwrap_or("").to_owned(), scores, }, ctx, @@ -213,19 +233,18 @@ mod scores { Ok(()) } - pub struct Paginate { + pub struct Paginate { env: OsuEnv, header: String, - scores: Vec, + scores: T, } - impl Paginate { - fn total_pages(&self) -> usize { - (self.scores.len() + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE - } - - async fn to_table(&self, start: usize, end: usize) -> String { - let scores = &self.scores[start..end]; + impl Paginate { + async fn to_table(&mut self, start: usize, end: usize) -> Result> { + let scores = self.scores.get_range(start..end).await?; + if scores.is_empty() { + return Ok(None); + } let meta_cache = &self.env.beatmaps; let oppai = &self.env.oppai; @@ -334,14 +353,18 @@ mod scores { }) .collect::>(); - table_formatting(&SCORE_HEADERS, &SCORE_ALIGNS, score_arr) + Ok(Some(table_formatting( + &SCORE_HEADERS, + &SCORE_ALIGNS, + score_arr, + ))) } } const ITEMS_PER_PAGE: usize = 5; #[async_trait] - impl pagination::Paginate for Paginate { + impl pagination::Paginate for Paginate { async fn render( &mut self, page: u8, @@ -349,23 +372,20 @@ mod scores { ) -> Result> { let page = page as usize; let start = page * ITEMS_PER_PAGE; - let end = self.scores.len().min(start + ITEMS_PER_PAGE); - if start >= end { + let end = start + ITEMS_PER_PAGE; + + let Some(score_table) = self.to_table(start, end).await? else { return Ok(None); - } - let plays = &self.scores[start..end]; - - let has_oppai = plays.iter().any(|p| p.pp.is_none()); - - let score_table = self.to_table(start, end).await; + }; let mut content = serenity::utils::MessageBuilder::new(); content .push_line(&self.header) .push_line(score_table) - .push_line(format!("Page **{}/{}**", page + 1, self.total_pages())); - if has_oppai { - content.push_line("[?] means pp was predicted by oppai-rs."); - }; + .push_line(format!( + "Page **{} / {}**", + page + 1, + self.scores.length_fetched().as_pages(ITEMS_PER_PAGE) + )); let content = content.build(); Ok(Some( @@ -374,7 +394,9 @@ mod scores { } fn len(&self) -> Option { - Some(self.total_pages()) + let size = self.scores.length_fetched(); + let pages = size.count().div_ceil(ITEMS_PER_PAGE); + Some(pages + if size.is_total() { 0 } else { 1 }) } } } diff --git a/youmubot-osu/src/discord/interaction.rs b/youmubot-osu/src/discord/interaction.rs index 253c946..b1900db 100644 --- a/youmubot-osu/src/discord/interaction.rs +++ b/youmubot-osu/src/discord/interaction.rs @@ -95,20 +95,21 @@ pub fn handle_check_button<'a>( return Ok(()); } + let header = format!( + "Here are the scores by [`{}`]() on {}!", + user.username, + user.id, + embed.mention() + ); comp.create_followup( &ctx, - CreateInteractionResponseFollowup::new().content(format!( - "Here are the scores by [`{}`]() on {}!", - user.username, - user.id, - embed.mention() - )), + CreateInteractionResponseFollowup::new().content(&header), ) .await?; let guild_id = comp.guild_id; ScoreListStyle::Grid - .display_scores(scores, &ctx, guild_id, (comp, ctx)) + .display_scores(scores, &ctx, guild_id, (comp, ctx).with_header(header)) .await .pls_ok(); Ok(()) diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 371eb56..70165c7 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -36,7 +36,8 @@ use crate::{ models::{Beatmap, Mode, Mods, Score, User}, mods::UnparsedMods, request::{BeatmapRequestKind, UserID}, - OsuClient as OsuHttpClient, UserHeader, + scores::Scores, + OsuClient as OsuHttpClient, UserHeader, MAX_TOP_SCORES_INDEX, }; mod announcer; @@ -304,6 +305,8 @@ pub(crate) async fn find_save_requirements( ] { let scores = client .user_best(UserID::ID(u.id), |f| f.mode(*mode)) + .await? + .get_all() .await?; if let Some(v) = scores.into_iter().choose(&mut rand::thread_rng()) { return Ok(Some((v, *mode))); @@ -351,11 +354,11 @@ pub(crate) async fn handle_save_respond( let osu_client = &env.client; async fn check(client: &OsuHttpClient, u: &User, mode: Mode, map_id: u64) -> Result { Ok(client - .user_recent(UserID::ID(u.id), |f| f.mode(mode).limit(1)) + .user_recent(UserID::ID(u.id), |f| f.mode(mode)) .await? - .into_iter() - .take(1) - .any(|s| s.beatmap_id == map_id)) + .get(0) + .await? + .is_some_and(|s| s.beatmap_id == map_id)) } let msg_id = reply.get_message().await?.id; let recv = InteractionCollector::create(&ctx, msg_id).await?; @@ -501,10 +504,11 @@ impl UserExtras { pub async fn from_user(env: &OsuEnv, user: &User, mode: Mode) -> Result { let scores = env .client - .user_best(UserID::ID(user.id), |f| f.mode(mode).limit(100)) + .user_best(UserID::ID(user.id), |f| f.mode(mode)) + .and_then(|v| v.get_all()) .await .pls_ok() - .unwrap_or_else(std::vec::Vec::new); + .unwrap_or_else(Vec::new); let (length, age) = join!( calculate_weighted_map_length(&scores, &env.beatmaps, mode), @@ -589,7 +593,7 @@ impl ListingArgs { sender: serenity::all::UserId, ) -> Result { let nth = index - .filter(|&v| 1 <= v && v <= 100) + .filter(|&v| 1 <= v && v <= MAX_TOP_SCORES_INDEX as u8) .map(|v| v - 1) .map(Nth::Nth) .unwrap_or_default(); @@ -632,7 +636,7 @@ async fn user_header_or_default_id( Some(UsernameArg::Raw(r)) => { let user = env .client - .user(&UserID::Username(r), |f| f) + .user(&UserID::Username(Arc::new(r)), |f| f) .await? .ok_or(Error::msg("User not found"))?; (user.preferred_mode, user.into()) @@ -678,30 +682,40 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu } = ListingArgs::parse(&env, msg, &mut args, ScoreListStyle::Table).await?; let osu_client = &env.client; - let plays = osu_client - .user_recent(UserID::ID(user.id), |f| f.mode(mode).limit(50)) + let mut plays = osu_client + .user_recent(UserID::ID(user.id), |f| f.mode(mode)) .await?; match nth { Nth::All => { - let reply = msg - .reply( - ctx, - format!("Here are the recent plays by {}!", user.mention()), - ) - .await?; + let header = format!("Here are the recent plays by {}!", user.mention()); + let reply = msg.reply(ctx, &header).await?; style - .display_scores(plays, ctx, reply.guild_id, (reply, ctx)) + .display_scores(plays, ctx, reply.guild_id, (reply, ctx).with_header(header)) .await?; } Nth::Nth(nth) => { - let Some(play) = plays.get(nth as usize) else { - Err(Error::msg("No such play"))? + let play = plays + .get(nth as usize) + .await? + .ok_or(Error::msg("No such play"))? + .clone(); + let attempts = { + let mut count = 0usize; + while plays + .get(nth as usize + count + 1) + .await + .ok() + .flatten() + .is_some_and(|p| { + p.beatmap_id == play.beatmap_id + && p.mode == play.mode + && p.mods == play.mods + }) + { + count += 1; + } + count }; - let attempts = plays - .iter() - .skip(nth as usize) - .take_while(|p| p.beatmap_id == play.beatmap_id && p.mods == play.mods) - .count(); let beatmap = env.beatmaps.get_beatmap(play.beatmap_id, mode).await?; let content = env.oppai.get_beatmap(beatmap.beatmap_id).await?; let beatmap_mode = BeatmapWithMode(beatmap, Some(mode)); @@ -716,7 +730,7 @@ pub async fn recent(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu user.mention() )) .embed( - score_embed(play, &beatmap_mode, &content, user) + score_embed(&play, &beatmap_mode, &content, user) .footer(format!("Attempt #{}", attempts)) .build(), ) @@ -751,25 +765,22 @@ pub async fn pins(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let osu_client = &env.client; - let plays = osu_client - .user_pins(UserID::ID(user.id), |f| f.mode(mode).limit(50)) + let mut plays = osu_client + .user_pins(UserID::ID(user.id), |f| f.mode(mode)) .await?; match nth { Nth::All => { - let reply = msg - .reply( - ctx, - format!("Here are the pinned plays by `{}`!", user.username), - ) - .await?; + let header = format!("Here are the pinned plays by `{}`!", user.username); + let reply = msg.reply(ctx, &header).await?; style - .display_scores(plays, ctx, reply.guild_id, (reply, ctx)) + .display_scores(plays, ctx, reply.guild_id, (reply, ctx).with_header(header)) .await?; } Nth::Nth(nth) => { - let Some(play) = plays.get(nth as usize) else { - Err(Error::msg("No such play"))? - }; + let play = plays + .get(nth as usize) + .await? + .ok_or(Error::msg("No such play"))?; let beatmap = env.beatmaps.get_beatmap(play.beatmap_id, mode).await?; let content = env.oppai.get_beatmap(beatmap.beatmap_id).await?; let beatmap_mode = BeatmapWithMode(beatmap, Some(mode)); @@ -779,7 +790,7 @@ pub async fn pins(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult &ctx, CreateMessage::new() .content("Here is the play that you requested".to_string()) - .embed(score_embed(play, &beatmap_mode, &content, user).build()) + .embed(score_embed(&play, &beatmap_mode, &content, user).build()) .components(vec![score_components(msg.guild_id)]) .reference_message(msg), ) @@ -1008,18 +1019,14 @@ pub async fn check(ctx: &Context, msg: &Message, mut args: Args) -> CommandResul msg.reply(&ctx, "No scores found").await?; return Ok(()); } - let reply = msg - .reply( - &ctx, - format!( - "Here are the scores by `{}` on {}!", - &user.username, - embed.mention() - ), - ) - .await?; + let header = format!( + "Here are the scores by `{}` on {}!", + &user.username, + embed.mention() + ); + let reply = msg.reply(&ctx, &header).await?; style - .display_scores(scores, ctx, msg.guild_id, (reply, ctx)) + .display_scores(scores, ctx, msg.guild_id, (reply, ctx).with_header(header)) .await?; Ok(()) @@ -1043,6 +1050,7 @@ pub(crate) async fn do_check( let mods = mods.clone().and_then(|t| t.to_mods(m).ok()); osu_client .scores(b.beatmap_id, |f| f.user(UserID::ID(user.id)).mode(m)) + .and_then(|v| v.get_all()) .map_ok(move |mut v| { v.retain(|s| mods.as_ref().is_none_or(|m| s.mods.contains(&m))); v @@ -1087,17 +1095,15 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let osu_client = &env.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)) .await?; - plays.sort_unstable_by(|a, b| b.pp.partial_cmp(&a.pp).unwrap()); - let plays = plays; - match nth { Nth::Nth(nth) => { - let Some(play) = plays.get(nth as usize) else { - Err(Error::msg("no such play"))? - }; + let play = plays + .get(nth as usize) + .await? + .ok_or(Error::msg("No such play"))?; let beatmap = env.beatmaps.get_beatmap(play.beatmap_id, mode).await?; let content = env.oppai.get_beatmap(beatmap.beatmap_id).await?; @@ -1124,14 +1130,10 @@ pub async fn top(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult cache::save_beatmap(&env, msg.channel_id, &beatmap).await?; } Nth::All => { - let reply = msg - .reply( - &ctx, - format!("Here are the top plays by {}!", user.mention()), - ) - .await?; + let header = format!("Here are the top plays by {}!", user.mention()); + let reply = msg.reply(&ctx, &header).await?; style - .display_scores(plays, ctx, msg.guild_id, (reply, ctx)) + .display_scores(plays, ctx, msg.guild_id, (reply, ctx).with_header(header)) .await?; } } @@ -1193,11 +1195,6 @@ fn scales() -> &'static [f64] { SCALES.get_or_init(|| { (0..256) .map(|r| SCALING_FACTOR.powi(r)) - // .scan(1.0, |a, _| { - // let old = *a; - // *a *= SCALING_FACTOR; - // Some(old) - // }) .collect::>() .into_boxed_slice() }) diff --git a/youmubot-osu/src/discord/server_rank.rs b/youmubot-osu/src/discord/server_rank.rs index 7d7d092..476e1e9 100644 --- a/youmubot-osu/src/discord/server_rank.rs +++ b/youmubot-osu/src/discord/server_rank.rs @@ -30,6 +30,7 @@ use crate::{ }, models::Mode, request::UserID, + scores::Scores, Beatmap, Score, }; @@ -438,7 +439,7 @@ pub async fn show_leaderboard(ctx: &Context, msg: &Message, mut args: Args) -> C let reply = msg.reply(&ctx, header).await?; style .display_scores( - scores.into_iter().map(|s| s.score).collect(), + scores.into_iter().map(|s| s.score).collect::>(), ctx, Some(guild), (reply, ctx), @@ -503,6 +504,7 @@ async fn get_leaderboard( .scores(b.beatmap_id, move |f| { f.user(UserID::ID(osu_id)).mode(mode_override) }) + .and_then(|v| v.get_all()) .map(move |r| Some((b, op, mem.clone(), r.ok()?))) }) }) diff --git a/youmubot-osu/src/lib.rs b/youmubot-osu/src/lib.rs index 05ac68b..f5432d3 100644 --- a/youmubot-osu/src/lib.rs +++ b/youmubot-osu/src/lib.rs @@ -12,6 +12,8 @@ pub mod discord; pub mod models; pub mod request; +pub const MAX_TOP_SCORES_INDEX: usize = 200; + /// Client is the client that will perform calls to the osu! api server. #[derive(Clone)] pub struct OsuClient { @@ -86,7 +88,7 @@ impl OsuClient { &self, beatmap_id: u64, f: impl FnOnce(&mut ScoreRequestBuilder) -> &mut ScoreRequestBuilder, - ) -> Result, Error> { + ) -> Result { let mut r = ScoreRequestBuilder::new(beatmap_id); f(&mut r); r.build(self).await @@ -96,7 +98,7 @@ impl OsuClient { &self, user: UserID, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, - ) -> Result, Error> { + ) -> Result { self.user_scores(UserScoreType::Best, user, f).await } @@ -104,7 +106,7 @@ impl OsuClient { &self, user: UserID, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, - ) -> Result, Error> { + ) -> Result { self.user_scores(UserScoreType::Recent, user, f).await } @@ -112,7 +114,7 @@ impl OsuClient { &self, user: UserID, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, - ) -> Result, Error> { + ) -> Result { self.user_scores(UserScoreType::Pin, user, f).await } @@ -121,10 +123,10 @@ impl OsuClient { u: UserScoreType, user: UserID, f: impl FnOnce(&mut UserScoreRequestBuilder) -> &mut UserScoreRequestBuilder, - ) -> Result, Error> { + ) -> Result { let mut r = UserScoreRequestBuilder::new(u, user); f(&mut r); - r.build(self).await + r.build(self.clone()).await } pub async fn score(&self, score_id: u64) -> Result, Error> { diff --git a/youmubot-osu/src/request.rs b/youmubot-osu/src/request/mod.rs similarity index 80% rename from youmubot-osu/src/request.rs rename to youmubot-osu/src/request/mod.rs index 2947276..a13eee0 100644 --- a/youmubot-osu/src/request.rs +++ b/youmubot-osu/src/request/mod.rs @@ -1,13 +1,18 @@ use core::fmt; +use std::sync::Arc; use crate::models::{Mode, Mods}; use crate::OsuClient; use rosu_v2::error::OsuError; use youmubot_prelude::*; +pub(crate) mod scores; + +pub use scores::Scores; + #[derive(Clone, Debug)] pub enum UserID { - Username(String), + Username(Arc), ID(u64), } @@ -23,7 +28,7 @@ impl fmt::Display for UserID { impl From for rosu_v2::prelude::UserId { fn from(value: UserID) -> Self { match value { - UserID::Username(s) => rosu_v2::request::UserId::Name(s.into()), + UserID::Username(s) => rosu_v2::request::UserId::Name(s[..].into()), UserID::ID(id) => rosu_v2::request::UserId::Id(id as u32), } } @@ -34,7 +39,7 @@ impl UserID { let s = s.into(); match s.parse::() { Ok(id) => UserID::ID(id), - Err(_) => UserID::Username(s), + Err(_) => UserID::Username(Arc::new(s)), } } } @@ -56,8 +61,9 @@ fn handle_not_found(v: Result) -> Result, OsuError> { pub mod builders { use rosu_v2::model::mods::GameModsIntermode; - use crate::models; + use crate::models::{self, Score}; + use super::scores::{FetchScores, ScoresFetcher}; use super::OsuClient; use super::*; /// A builder for a Beatmap request. @@ -166,7 +172,6 @@ pub mod builders { user: Option, mode: Option, mods: Option, - limit: Option, } impl ScoreRequestBuilder { @@ -176,7 +181,6 @@ pub mod builders { user: None, mode: None, mods: None, - limit: None, } } @@ -195,21 +199,21 @@ pub mod builders { self } - pub fn limit(&mut self, limit: u8) -> &mut Self { - self.limit = Some(limit).filter(|&v| v <= 100).or(self.limit); - self - } - - pub(crate) async fn build(self, osu: &OsuClient) -> Result> { - let scores = handle_not_found(match self.user { + async fn fetch_scores( + &self, + osu: &crate::OsuClient, + _offset: usize, + ) -> Result> { + let scores = handle_not_found(match &self.user { Some(user) => { - let mut r = osu.rosu.beatmap_user_scores(self.beatmap_id as u32, user); + let mut r = osu + .rosu + .beatmap_user_scores(self.beatmap_id as u32, user.clone()); if let Some(mode) = self.mode { r = r.mode(mode.into()); } - match self.mods { + match &self.mods { Some(mods) => r.await.map(|mut ss| { - // let mods = GameModsIntermode::from(mods.inner); ss.retain(|s| { Mods::from_gamemods(s.mods.clone(), s.set_on_lazer).contains(&mods) }); @@ -220,34 +224,39 @@ pub mod builders { } None => { let mut r = osu.rosu.beatmap_scores(self.beatmap_id as u32).global(); - if let Some(mode) = self.mode { - r = r.mode(mode.into()); + if let Some(mode) = &self.mode { + r = r.mode(mode.clone().into()); } - if let Some(mods) = self.mods { - r = r.mods(GameModsIntermode::from(mods.inner)); - } - if let Some(limit) = self.limit { - r = r.limit(limit as u32); + if let Some(mods) = &self.mods { + r = r.mods(GameModsIntermode::from(mods.inner.clone())); } + // r = r.limit(limit); // can't do this just yet because of offset not working r.await } })? .ok_or_else(|| error!("beatmap or user not found"))?; Ok(scores.into_iter().map(|v| v.into()).collect()) } + + pub(crate) async fn build(self, osu: &OsuClient) -> Result { + // user queries always return all scores, so no need to consider offset. + // otherwise, it's not working anyway... + Ok(self.fetch_scores(osu, 0).await?) + } } + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum UserScoreType { Recent, Best, Pin, } + #[derive(Debug, Clone)] pub struct UserScoreRequestBuilder { score_type: UserScoreType, user: UserID, mode: Option, - limit: Option, include_fails: bool, } @@ -257,7 +266,6 @@ pub mod builders { score_type, user, mode: None, - limit: None, include_fails: true, } } @@ -267,19 +275,20 @@ pub mod builders { self } - pub fn limit(&mut self, limit: u8) -> &mut Self { - self.limit = Some(limit).filter(|&v| v <= 100).or(self.limit); - self - } - pub fn include_fails(&mut self, include_fails: bool) -> &mut Self { self.include_fails = include_fails; self } - pub(crate) async fn build(self, client: &OsuClient) -> Result> { + const SCORES_PER_PAGE: usize = 100; + + async fn with_offset(&self, client: &OsuClient, offset: usize) -> Result> { let scores = handle_not_found({ - let mut r = client.rosu.user_scores(self.user); + let mut r = client + .rosu + .user_scores(self.user.clone()) + .limit(Self::SCORES_PER_PAGE) + .offset(offset); r = match self.score_type { UserScoreType::Recent => r.recent().include_fails(self.include_fails), UserScoreType::Best => r.best(), @@ -288,14 +297,27 @@ pub mod builders { if let Some(mode) = self.mode { r = r.mode(mode.into()); } - if let Some(limit) = self.limit { - r = r.limit(limit as usize); - } r.await })? .ok_or_else(|| error!("user not found"))?; Ok(scores.into_iter().map(|v| v.into()).collect()) } + + pub(crate) async fn build(self, client: OsuClient) -> Result { + ScoresFetcher::new(client, self).await + } + } + + impl FetchScores for UserScoreRequestBuilder { + async fn fetch_scores( + &self, + client: &crate::OsuClient, + offset: usize, + ) -> Result> { + self.with_offset(client, offset).await + } + + const SCORES_PER_PAGE: usize = Self::SCORES_PER_PAGE; } } diff --git a/youmubot-osu/src/request/scores.rs b/youmubot-osu/src/request/scores.rs new file mode 100644 index 0000000..1ef3171 --- /dev/null +++ b/youmubot-osu/src/request/scores.rs @@ -0,0 +1,211 @@ +use std::{fmt::Display, future::Future, ops::Range}; + +use youmubot_prelude::*; + +use crate::{models::Score, OsuClient}; + +pub const MAX_SCORE_PER_PAGE: usize = 1000; + +/// Fetch scores given an offset. +/// Implemented for score requests. +pub trait FetchScores: Send { + /// Scores per page. + const SCORES_PER_PAGE: usize = MAX_SCORE_PER_PAGE; + /// Fetch scores given an offset. + fn fetch_scores( + &self, + client: &crate::OsuClient, + offset: usize, + ) -> impl Future>> + Send; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Size { + /// There might be more + AtLeast(usize), + /// All + Total(usize), +} + +impl Display for Size { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.count())?; + if !self.is_total() { + write!(f, "+")?; + } + Ok(()) + } +} + +impl Size { + pub fn count(&self) -> usize { + match self { + Size::AtLeast(cnt) => *cnt, + Size::Total(cnt) => *cnt, + } + } + + pub fn is_total(&self) -> bool { + match self { + Size::AtLeast(_) => false, + Size::Total(_) => true, + } + } + + pub fn as_pages(self, per_page: usize) -> Size { + match self { + Size::AtLeast(a) => Size::AtLeast(a.div_ceil(per_page)), + Size::Total(a) => Size::Total(a.div_ceil(per_page)), + } + } +} + +/// A scores stream. +pub trait Scores: Send { + /// Total length of the pages. + fn length_fetched(&self) -> Size; + + /// Whether the scores set is empty. + fn is_empty(&self) -> bool; + + /// Get the index-th score. + fn get(&mut self, index: usize) -> impl Future>> + Send; + + /// Get all scores. + fn get_all(self) -> impl Future>> + Send; + + /// Get the scores between the given range. + fn get_range(&mut self, range: Range) -> impl Future> + Send; + + /// Find a score that matches the predicate `f`. + fn find bool + Send>( + &mut self, + f: F, + ) -> impl Future>> + Send; +} + +impl Scores for Vec { + fn length_fetched(&self) -> Size { + Size::Total(self.len()) + } + + fn is_empty(&self) -> bool { + self.is_empty() + } + + fn get(&mut self, index: usize) -> impl Future>> + Send { + future::ok(self[..].get(index)) + } + + fn get_all(self) -> impl Future>> + Send { + future::ok(self) + } + + fn get_range(&mut self, range: Range) -> impl Future> + Send { + future::ok(&self[fit_range_to_len(self.len(), range)]) + } + + async fn find bool + Send>(&mut self, mut f: F) -> Result> { + Ok(self.iter().find(|v| f(*v))) + } +} + +#[inline] +fn fit_range_to_len(len: usize, range: Range) -> Range { + range.start.min(len)..range.end.min(len) +} + +/// A scores stream with a fetcher. +pub(super) struct ScoresFetcher { + fetcher: T, + client: OsuClient, + scores: Vec, + more_exists: bool, +} + +impl ScoresFetcher { + /// Create a new Scores stream. + pub async fn new(client: OsuClient, fetcher: T) -> Result { + let mut s = Self { + fetcher, + client, + scores: Vec::new(), + more_exists: true, + }; + // fetch the first page immediately. + s.fetch_next_page().await?; + Ok(s) + } +} + +impl Scores for ScoresFetcher { + /// Total length of the pages. + fn length_fetched(&self) -> Size { + let count = self.len(); + if self.more_exists { + Size::AtLeast(count) + } else { + Size::Total(count) + } + } + + fn is_empty(&self) -> bool { + self.scores.is_empty() + } + + /// Get the index-th score. + async fn get(&mut self, index: usize) -> Result> { + Ok(self.get_range(index..(index + 1)).await?.get(0)) + } + + /// Get all scores. + async fn get_all(mut self) -> Result> { + let _ = self.get_range(0..usize::max_value()).await?; + Ok(self.scores) + } + + /// Get the scores between the given range. + async fn get_range(&mut self, range: Range) -> Result<&[Score]> { + while self.len() < range.end { + if !self.fetch_next_page().await? { + break; + } + } + Ok(&self.scores[fit_range_to_len(self.len(), range)]) + } + + async fn find bool + Send>(&mut self, mut f: F) -> Result> { + let mut from = 0usize; + let index = loop { + if from == self.len() && !self.fetch_next_page().await? { + break None; + } + if f(&self.scores[from]) { + break Some(from); + } + from += 1; + }; + Ok(index.map(|v| &self.scores[v])) + } +} + +impl ScoresFetcher { + async fn fetch_next_page(&mut self) -> Result { + if !self.more_exists { + return Ok(false); + } + let offset = self.len(); + let scores = self.fetcher.fetch_scores(&self.client, offset).await?; + if scores.len() < T::SCORES_PER_PAGE { + self.more_exists = false; + } + if scores.is_empty() { + return Ok(false); + } + self.scores.extend(scores); + Ok(true) + } + fn len(&self) -> usize { + self.scores.len() + } +} diff --git a/youmubot-prelude/src/pagination.rs b/youmubot-prelude/src/pagination.rs index f302bad..eea9b6f 100644 --- a/youmubot-prelude/src/pagination.rs +++ b/youmubot-prelude/src/pagination.rs @@ -14,9 +14,33 @@ const PREV: &str = "youmubot_pagination_prev"; const FAST_NEXT: &str = "youmubot_pagination_fast_next"; const FAST_PREV: &str = "youmubot_pagination_fast_prev"; -pub trait CanEdit: Send { +pub trait CanEdit: Send + Sized { fn get_message(&self) -> impl Future> + Send; fn apply_edit(&mut self, edit: CreateReply) -> impl Future> + Send; + + fn headers(&self) -> Option<&str> { + None + } + + fn with_header(self, header: String) -> impl CanEdit { + WithHeaders(self, header) + } +} + +struct WithHeaders(T, String); + +impl CanEdit for WithHeaders { + fn get_message(&self) -> impl Future> + Send { + self.0.get_message() + } + + fn apply_edit(&mut self, edit: CreateReply) -> impl Future> + Send { + self.0.apply_edit(edit) + } + + fn headers(&self) -> Option<&str> { + Some(&self.1) + } } impl<'a> CanEdit for (Message, &'a Context) {