Add beatmap command

This commit is contained in:
Natsu Kagami 2024-12-31 05:14:54 +01:00
parent 959bffcf74
commit cd0e3be0ef
Signed by: nki
GPG key ID: 55A032EB38B49ADB
11 changed files with 252 additions and 60 deletions

View file

@ -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" }

View file

@ -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)

View file

@ -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<U: HasOsuEnv>(_ctx: CmdContext<'_, U>) -> Result<()> {
Ok(())
@ -46,7 +49,7 @@ async fn top<U: HasOsuEnv>(
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<U: HasOsuEnv>(
.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<U: HasOsuEnv>(
.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<U: HasOsuEnv>(
ctx: CmdContext<'_, U>,
plays: Vec<Score>,
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<U: HasOsuEnv>(
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<U: HasOsuEnv>(
Ok(())
}
/// Get information about a beatmap, or the last beatmap mentioned in the channel.
#[poise::command(slash_command)]
async fn beatmap<U: HasOsuEnv>(
ctx: CmdContext<'_, U>,
#[description = "A link or shortlink to the beatmap or beatmapset"] map: Option<String>,
#[description = "Override the mods on the map"] mods: Option<UnparsedMods>,
#[description = "Override the mode of the map"] mode: Option<Mode>,
#[description = "Load the beatmapset instead"] beatmapset: Option<bool>,
) -> 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<String>,
discord_name: Option<User>,

View file

@ -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<Beatmap>,
mut beatmapset: Vec<Beatmap>,
mode: Option<Mode>,
mods: Mods,
mods: Option<UnparsedMods>,
guild_id: Option<GuildId>,
target: Message,
) -> Result<bool> {
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<Beatmap>,
infos: Vec<Option<BeatmapInfoWithPP>>,
mode: Option<Mode>,
mods: Mods,
mods: Option<UnparsedMods>,
guild_id: Option<GuildId>,
all_reaction: Option<Reaction>,
}
impl Paginate {
async fn get_beatmap_info(&self, ctx: &Context, b: &Beatmap) -> Result<BeatmapInfoWithPP> {
async fn get_beatmap_info(
&self,
ctx: &Context,
b: &Beatmap,
mods: &Mods,
) -> Result<BeatmapInfoWithPP> {
let env = ctx.data.read().await.get::<OsuEnv>().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({

View file

@ -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<String>) -> &mut Self {
pub fn footer(mut self, footer: impl Into<String>) -> 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;

View file

@ -323,7 +323,7 @@ async fn handle_beatmapset<'a, 'b>(
ctx.clone(),
beatmaps,
mode,
Mods::default(),
None,
reply_to.guild_id,
reply,
)

View file

@ -325,7 +325,7 @@ async fn handle_last_req(
ctx.clone(),
beatmapset,
None,
mods,
None,
comp.guild_id,
reply,
)

View file

@ -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::<UnparsedMods>().ok() {
Some(m) => m.to_mods(bm.mode())?,
None => mods_def.unwrap_or_default(),
};
let mods = args.find::<UnparsedMods>().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)

View file

@ -341,6 +341,14 @@ impl Mode {
Mode::Mania => "mania",
}
}
pub fn with_override(self, opt_mode: impl Into<Option<Mode>>) -> Self {
if self == Mode::Std {
opt_mode.into().unwrap_or(self)
} else {
self
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]

View file

@ -27,8 +27,16 @@ pub struct UnparsedMods {
clock: Option<f64>,
}
#[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<Self, Self::Err> {
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();