From cd0e3be0efb6ddf939ccf54f023fa68f8b52673e Mon Sep 17 00:00:00 2001 From: Natsu Kagami Date: Tue, 31 Dec 2024 05:14:54 +0100 Subject: [PATCH] Add beatmap command --- Cargo.lock | 77 +++++++----- youmubot-osu/Cargo.toml | 1 + youmubot-osu/src/discord/announcer.rs | 4 +- youmubot-osu/src/discord/commands.rs | 149 ++++++++++++++++++++++-- youmubot-osu/src/discord/display.rs | 38 ++++-- youmubot-osu/src/discord/embeds.rs | 8 +- youmubot-osu/src/discord/hook.rs | 2 +- youmubot-osu/src/discord/interaction.rs | 2 +- youmubot-osu/src/discord/mod.rs | 9 +- youmubot-osu/src/models/mod.rs | 8 ++ youmubot-osu/src/models/mods.rs | 14 ++- 11 files changed, 252 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b9e8b8..ce88e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,7 +104,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -461,7 +461,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -472,7 +472,7 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" dependencies = [ "darling_core", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -780,7 +780,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -1454,7 +1454,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -1559,7 +1559,7 @@ checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -1627,7 +1627,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -1644,9 +1644,9 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -1849,7 +1849,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallstr", - "thiserror", + "thiserror 1.0.57", "time", "tokio", "tracing", @@ -1885,7 +1885,7 @@ dependencies = [ "serde", "serde_yaml", "tempfile", - "thiserror", + "thiserror 1.0.57", ] [[package]] @@ -2049,7 +2049,7 @@ checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -2288,7 +2288,7 @@ dependencies = [ "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.57", "tokio", "tokio-stream", "tracing", @@ -2373,7 +2373,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.57", "tracing", "whoami", ] @@ -2413,7 +2413,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.57", "tracing", "whoami", ] @@ -2484,9 +2484,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "9c786062daee0d6db1132800e623df74274a0a87322d8e183338e01b3d98d058" dependencies = [ "proc-macro2", "quote", @@ -2553,7 +2553,16 @@ version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.57", +] + +[[package]] +name = "thiserror" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f072643fd0190df67a8bab670c20ef5d8737177d6ac6b2e9a236cb096206b2cc" +dependencies = [ + "thiserror-impl 2.0.9", ] [[package]] @@ -2564,7 +2573,18 @@ checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b50fa271071aae2e6ee85f842e2e28ba8cd2c5fb67f11fcb1fd70b276f9e7d4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.93", ] [[package]] @@ -2638,7 +2658,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -2727,7 +2747,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -2766,7 +2786,7 @@ dependencies = [ "rand", "rustls", "sha1", - "thiserror", + "thiserror 1.0.57", "url", "utf-8", ] @@ -2809,7 +2829,7 @@ checksum = "0b122284365ba8497be951b9a21491f70c9688eb6fddc582931a0703f6a00ece" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] @@ -2954,7 +2974,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", "wasm-bindgen-shared", ] @@ -2988,7 +3008,7 @@ checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3300,7 +3320,7 @@ dependencies = [ "either", "futures-util", "sqlx", - "thiserror", + "thiserror 1.0.57", ] [[package]] @@ -3323,6 +3343,7 @@ dependencies = [ "serde", "serde_json", "serenity", + "thiserror 2.0.9", "time", "youmubot-db", "youmubot-db-sql", @@ -3343,7 +3364,7 @@ dependencies = [ "poise", "reqwest", "serenity", - "thiserror", + "thiserror 1.0.57", "tokio", "youmubot-db", "youmubot-db-sql", @@ -3366,7 +3387,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.93", ] [[package]] diff --git a/youmubot-osu/Cargo.toml b/youmubot-osu/Cargo.toml index c738006..6a254c2 100644 --- a/youmubot-osu/Cargo.toml +++ b/youmubot-osu/Cargo.toml @@ -24,6 +24,7 @@ poise = "0.6" zip = "0.6.2" rand = "0.8" futures-util = "0.3.30" +thiserror = "2" youmubot-db = { path = "../youmubot-db" } youmubot-db-sql = { path = "../youmubot-db-sql" } diff --git a/youmubot-osu/src/discord/announcer.rs b/youmubot-osu/src/discord/announcer.rs index 8fb5561..099f12c 100644 --- a/youmubot-osu/src/discord/announcer.rs +++ b/youmubot-osu/src/discord/announcer.rs @@ -337,11 +337,11 @@ impl<'a> CollectedScore<'a> { CreateMessage::new() .content(self.kind.announcement_msg(self.mode, &member)) .embed({ - let mut b = score_embed(&self.score, bm, content, self.user); + let b = score_embed(&self.score, bm, content, self.user); let b = if let Some(rank) = self.kind.top_record { b.top_record(rank) } else { - &mut b + b }; let b = if let Some(rank) = self.kind.world_record { b.world_record(rank) diff --git a/youmubot-osu/src/discord/commands.rs b/youmubot-osu/src/discord/commands.rs index db8cbdb..dde35a0 100644 --- a/youmubot-osu/src/discord/commands.rs +++ b/youmubot-osu/src/discord/commands.rs @@ -1,11 +1,14 @@ use super::*; +use display::display_beatmapset; +use embeds::ScoreEmbedBuilder; +use link_parser::EmbedType; use poise::CreateReply; use serenity::all::User; /// osu!-related command group. #[poise::command( slash_command, - subcommands("profile", "top", "recent", "pinned", "save", "forcesave") + subcommands("profile", "top", "recent", "pinned", "save", "forcesave", "beatmap") )] pub async fn osu(_ctx: CmdContext<'_, U>) -> Result<()> { Ok(()) @@ -46,7 +49,7 @@ async fn top( plays.sort_unstable_by(|a, b| b.pp.partial_cmp(&a.pp).unwrap()); - handle_listing(ctx, plays, args, "top").await + handle_listing(ctx, plays, args, |nth, b| b.top_record(nth), "top").await } /// Get an user's profile. @@ -116,7 +119,7 @@ async fn recent( .user_recent(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(50)) .await?; - handle_listing(ctx, plays, args, "recent").await + handle_listing(ctx, plays, args, |_, b| b, "recent").await } /// Returns pinned plays from a given player. @@ -147,7 +150,7 @@ async fn pinned( .user_pins(UserID::ID(args.user.id), |f| f.mode(args.mode).limit(50)) .await?; - handle_listing(ctx, plays, args, "pinned").await + handle_listing(ctx, plays, args, |_, b| b, "pinned").await } /// Save your osu! profile into Youmu's database for tracking and quick commands. @@ -229,6 +232,7 @@ async fn handle_listing( ctx: CmdContext<'_, U>, plays: Vec, listing_args: ListingArgs, + transform: impl for<'a> Fn(u8, ScoreEmbedBuilder<'a>) -> ScoreEmbedBuilder<'a>, listing_kind: &'static str, ) -> Result<()> { let env = ctx.data().osu_env(); @@ -257,11 +261,14 @@ async fn handle_listing( listing_kind, user.mention() )) - .embed( - score_embed(&play, &beatmap, &content, user) - .top_record(nth + 1) - .build(), - ) + .embed({ + let mut b = + transform(nth + 1, score_embed(&play, &beatmap, &content, user)); + if let Some(rank) = play.global_rank { + b = b.world_record(rank as u16); + } + b.build() + }) .components(vec![score_components(ctx.guild_id())]) }) .await?; @@ -288,6 +295,130 @@ async fn handle_listing( Ok(()) } +/// Get information about a beatmap, or the last beatmap mentioned in the channel. +#[poise::command(slash_command)] +async fn beatmap( + ctx: CmdContext<'_, U>, + #[description = "A link or shortlink to the beatmap or beatmapset"] map: Option, + #[description = "Override the mods on the map"] mods: Option, + #[description = "Override the mode of the map"] mode: Option, + #[description = "Load the beatmapset instead"] beatmapset: Option, +) -> Result<()> { + let env = ctx.data().osu_env(); + + ctx.defer().await?; + + let beatmap = match map { + None => { + let Some((BeatmapWithMode(b, mode), bmmods)) = + load_beatmap(env, ctx.channel_id(), None as Option<&'_ Message>).await + else { + return Err(Error::msg("no beatmap mentioned in this channel")); + }; + let mods = bmmods.unwrap_or_else(|| Mods::NOMOD.clone()); + let info = env + .oppai + .get_beatmap(b.beatmap_id) + .await? + .get_possible_pp_with(mode, &mods); + EmbedType::Beatmap(Box::new(b), info, mods) + } + Some(map) => { + let Some(results) = stream::select( + link_parser::parse_new_links(env, &map), + stream::select( + link_parser::parse_old_links(env, &map), + link_parser::parse_short_links(env, &map), + ), + ) + .next() + .await + else { + return Err(Error::msg("no beatmap detected in the argument")); + }; + results.embed + } + }; + + // override into beatmapset if needed + let beatmap = if beatmapset == Some(true) { + match beatmap { + EmbedType::Beatmap(beatmap, _, _) => { + let beatmaps = env.beatmaps.get_beatmapset(beatmap.beatmapset_id).await?; + EmbedType::Beatmapset(beatmaps) + } + bm @ EmbedType::Beatmapset(_) => bm, + } + } else { + beatmap + }; + + // override mods and mode if needed + match beatmap { + EmbedType::Beatmap(beatmap, info, bmmods) => { + let (beatmap, info, mods) = if mods.is_none() && mode.is_none_or(|v| v == beatmap.mode) + { + (*beatmap, info, bmmods) + } else { + let mode = mode.unwrap_or(beatmap.mode); + let mods = match mods { + None => bmmods, + Some(mods) => mods.to_mods(mode)?, + }; + let beatmap = env.beatmaps.get_beatmap(beatmap.beatmap_id, mode).await?; + let info = env + .oppai + .get_beatmap(beatmap.beatmap_id) + .await? + .get_possible_pp_with(mode, &mods); + (beatmap, info, mods) + }; + ctx.send( + CreateReply::default() + .content(format!( + "Information for beatmap `{}`", + beatmap.short_link(mode, &mods) + )) + .embed(beatmap_embed( + &beatmap, + mode.unwrap_or(beatmap.mode), + &mods, + &info, + )) + .components(vec![beatmap_components( + mode.unwrap_or(beatmap.mode), + ctx.guild_id(), + )]), + ) + .await?; + } + EmbedType::Beatmapset(vec) => { + let b0 = &vec[0]; + let msg = ctx + .clone() + .reply(format!( + "Information for beatmapset [`/s/{}`](<{}>)", + b0.beatmapset_id, + b0.beatmapset_link() + )) + .await? + .into_message() + .await?; + display_beatmapset( + ctx.serenity_context().clone(), + vec, + mode, + mods, + ctx.guild_id(), + msg, + ) + .await?; + } + }; + + Ok(()) +} + fn arg_from_username_or_discord( username: Option, discord_name: Option, diff --git a/youmubot-osu/src/discord/display.rs b/youmubot-osu/src/discord/display.rs index af1fbb2..35fbc2e 100644 --- a/youmubot-osu/src/discord/display.rs +++ b/youmubot-osu/src/discord/display.rs @@ -346,24 +346,35 @@ mod beatmapset { use youmubot_prelude::*; - use crate::discord::{interaction::beatmap_components, OsuEnv}; use crate::{ discord::{cache::save_beatmap, oppai_cache::BeatmapInfoWithPP, BeatmapWithMode}, models::{Beatmap, Mode, Mods}, }; + use crate::{ + discord::{interaction::beatmap_components, OsuEnv}, + mods::UnparsedMods, + }; const SHOW_ALL_EMOTE: &str = "🗒️"; pub async fn display_beatmapset( ctx: Context, - beatmapset: Vec, + mut beatmapset: Vec, mode: Option, - mods: Mods, + mods: Option, guild_id: Option, target: Message, ) -> Result { assert!(!beatmapset.is_empty(), "Beatmapset should not be empty"); + beatmapset.sort_unstable_by(|a, b| { + if a.mode != b.mode { + (a.mode as u8).cmp(&(b.mode as u8)) + } else { + a.difficulty.stars.partial_cmp(&b.difficulty.stars).unwrap() + } + }); + let p = Paginate { infos: vec![None; beatmapset.len()], maps: beatmapset, @@ -392,20 +403,25 @@ mod beatmapset { maps: Vec, infos: Vec>, mode: Option, - mods: Mods, + mods: Option, guild_id: Option, all_reaction: Option, } impl Paginate { - async fn get_beatmap_info(&self, ctx: &Context, b: &Beatmap) -> Result { + async fn get_beatmap_info( + &self, + ctx: &Context, + b: &Beatmap, + mods: &Mods, + ) -> Result { let env = ctx.data.read().await.get::().unwrap().clone(); env.oppai .get_beatmap(b.beatmap_id) .await - .map(move |v| v.get_possible_pp_with(self.mode.unwrap_or(b.mode), &self.mods)) + .map(move |v| v.get_possible_pp_with(b.mode.with_override(self.mode), &mods)) } } @@ -433,10 +449,16 @@ mod beatmapset { } let map = &self.maps[page]; + let mods = self + .mods + .clone() + .and_then(|v| v.to_mods(map.mode.with_override(self.mode)).ok()) + .unwrap_or_default(); + let info = match &self.infos[page] { Some(info) => info, None => { - let info = self.get_beatmap_info(ctx, map).await?; + let info = self.get_beatmap_info(ctx, map, &mods).await?; self.infos[page].insert(info) } }; @@ -445,7 +467,7 @@ mod beatmapset { crate::discord::embeds::beatmap_embed( map, self.mode.unwrap_or(map.mode), - &self.mods, + &mods, info, ) .footer({ diff --git a/youmubot-osu/src/discord/embeds.rs b/youmubot-osu/src/discord/embeds.rs index e19e8c7..b31e345 100644 --- a/youmubot-osu/src/discord/embeds.rs +++ b/youmubot-osu/src/discord/embeds.rs @@ -270,15 +270,15 @@ pub(crate) struct ScoreEmbedBuilder<'a> { } impl<'a> ScoreEmbedBuilder<'a> { - pub fn top_record(&mut self, rank: u8) -> &mut Self { + pub fn top_record(mut self, rank: u8) -> Self { self.top_record = Some(rank); self } - pub fn world_record(&mut self, rank: u16) -> &mut Self { + pub fn world_record(mut self, rank: u16) -> Self { self.world_record = Some(rank); self } - pub fn footer(&mut self, footer: impl Into) -> &mut Self { + pub fn footer(mut self, footer: impl Into) -> Self { self.footer = Some(match self.footer.take() { None => footer.into(), Some(pre) => format!("{} | {}", pre, footer.into()), @@ -306,7 +306,7 @@ pub(crate) fn score_embed<'a>( impl<'a> ScoreEmbedBuilder<'a> { #[allow(clippy::many_single_char_names)] - pub fn build(&mut self) -> CreateEmbed { + pub fn build(mut self) -> CreateEmbed { let mode = self.bm.mode(); let b = &self.bm.0; let s = self.s; diff --git a/youmubot-osu/src/discord/hook.rs b/youmubot-osu/src/discord/hook.rs index baf735b..4ec9aab 100644 --- a/youmubot-osu/src/discord/hook.rs +++ b/youmubot-osu/src/discord/hook.rs @@ -323,7 +323,7 @@ async fn handle_beatmapset<'a, 'b>( ctx.clone(), beatmaps, mode, - Mods::default(), + None, reply_to.guild_id, reply, ) diff --git a/youmubot-osu/src/discord/interaction.rs b/youmubot-osu/src/discord/interaction.rs index fc2dffc..c92b818 100644 --- a/youmubot-osu/src/discord/interaction.rs +++ b/youmubot-osu/src/discord/interaction.rs @@ -325,7 +325,7 @@ async fn handle_last_req( ctx.clone(), beatmapset, None, - mods, + None, comp.guild_id, reply, ) diff --git a/youmubot-osu/src/discord/mod.rs b/youmubot-osu/src/discord/mod.rs index 7dbe116..cf6705c 100644 --- a/youmubot-osu/src/discord/mod.rs +++ b/youmubot-osu/src/discord/mod.rs @@ -859,10 +859,7 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult match b { Some((bm, mods_def)) => { - let mods = match args.find::().ok() { - Some(m) => m.to_mods(bm.mode())?, - None => mods_def.unwrap_or_default(), - }; + let mods = args.find::().ok(); if beatmapset { let beatmapset = env.beatmaps.get_beatmapset(bm.0.beatmapset_id).await?; let reply = msg @@ -879,6 +876,10 @@ pub async fn last(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .await?; return Ok(()); } + let mods = match mods { + Some(m) => m.to_mods(bm.mode())?, + None => mods_def.unwrap_or_default(), + }; let info = env .oppai .get_beatmap(bm.0.beatmap_id) diff --git a/youmubot-osu/src/models/mod.rs b/youmubot-osu/src/models/mod.rs index f9ec508..37c75e5 100644 --- a/youmubot-osu/src/models/mod.rs +++ b/youmubot-osu/src/models/mod.rs @@ -341,6 +341,14 @@ impl Mode { Mode::Mania => "mania", } } + + pub fn with_override(self, opt_mode: impl Into>) -> Self { + if self == Mode::Std { + opt_mode.into().unwrap_or(self) + } else { + self + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/youmubot-osu/src/models/mods.rs b/youmubot-osu/src/models/mods.rs index 8643914..f891ab2 100644 --- a/youmubot-osu/src/models/mods.rs +++ b/youmubot-osu/src/models/mods.rs @@ -27,8 +27,16 @@ pub struct UnparsedMods { clock: Option, } +#[derive(thiserror::Error, Debug)] +pub enum ModParseError { + #[error("invalid mods `{0}`")] + Invalid(String), + #[error("not a mod: `{0}`")] + NotAMod(String), +} + impl FromStr for UnparsedMods { - type Err = String; + type Err = ModParseError; fn from_str(s: &str) -> std::result::Result { if s.is_empty() { @@ -36,12 +44,12 @@ impl FromStr for UnparsedMods { } let ms = match MODS.captures(s) { Some(m) => m, - None => return Err(format!("invalid mods: {}", s)), + None => return Err(ModParseError::Invalid(s.to_owned())), }; let mods = ms.name("mods").map(|v| v.as_str().to_owned()); if let Some(mods) = &mods { if GameModsIntermode::try_from_acronyms(mods).is_none() { - return Err(format!("invalid mod sequence: {}", mods)); + return Err(ModParseError::NotAMod(mods.to_owned())); } } let is_lazer = ms.name("lazer").is_some();