// {{{ Imports use crate::arcaea::play::{CreatePlay, Play}; use crate::arcaea::score::Score; use crate::context::{Error, ErrorKind, PoiseContext, TagError, TaggedError}; use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; use crate::user::User; use crate::{get_user_error, timed}; use anyhow::anyhow; use image::DynamicImage; use poise::serenity_prelude::{CreateAttachment, CreateEmbed}; use poise::{serenity_prelude as serenity, CreateReply}; use super::discord::{CreateReplyExtra, MessageContext}; // }}} // {{{ Score /// Score management #[poise::command( prefix_command, slash_command, subcommands("magic", "delete", "show"), subcommand_required )] pub async fn score(_ctx: PoiseContext<'_>) -> Result<(), Error> { Ok(()) } // }}} // {{{ Score magic // {{{ Implementation #[allow(clippy::too_many_arguments)] async fn magic_detect_one<C: MessageContext>( ctx: &mut C, user: &User, embeds: &mut Vec<CreateEmbed>, attachments: &mut Vec<CreateAttachment>, plays: &mut Vec<Play>, analyzer: &mut ImageAnalyzer, attachment: &C::Attachment, index: usize, image: &mut DynamicImage, grayscale_image: &mut DynamicImage, ) -> Result<(), TaggedError> { // {{{ Detection let kind = timed!("read_score_kind", { analyzer.read_score_kind(ctx.data(), grayscale_image)? }); let difficulty = timed!("read_difficulty", { analyzer.read_difficulty(ctx.data(), image, grayscale_image, kind)? }); let (song, chart) = timed!("read_jacket", { analyzer.read_jacket(ctx.data(), image, kind, difficulty)? }); let max_recall = match kind { ScoreKind::ScoreScreen => { // NOTE: are we ok with discarding errors like that? analyzer.read_max_recall(ctx.data(), grayscale_image).ok() } ScoreKind::SongSelect => None, }; grayscale_image.invert(); let note_distribution = match kind { ScoreKind::ScoreScreen => Some(analyzer.read_distribution(ctx.data(), grayscale_image)?), ScoreKind::SongSelect => None, }; let score = timed!("read_score", { analyzer .read_score(ctx.data(), Some(chart.note_count), grayscale_image, kind) .map_err(|err| { anyhow!( "Could not read score for chart {} [{:?}]: {err}", song.title, chart.difficulty ) })? }); // {{{ Build play let maybe_fars = Score::resolve_distibution_ambiguities(score, note_distribution, chart.note_count); let play = CreatePlay::new(score) .with_attachment(C::attachment_id(attachment)) .with_fars(maybe_fars) .with_max_recall(max_recall) .save(ctx.data(), user, chart) .await?; // }}} // }}} // {{{ Deliver embed let (embed, attachment) = timed!("to embed", { play.to_embed(ctx.data(), user, song, chart, index, None)? }); plays.push(play); embeds.push(embed); attachments.extend(attachment); // }}} Ok(()) } pub async fn magic_impl<C: MessageContext>( ctx: &mut C, files: &[C::Attachment], ) -> Result<Vec<Play>, TaggedError> { let user = User::from_context(ctx)?; let files = ctx.download_images(files).await?; if files.is_empty() { return Err(anyhow!("No images found attached to message").tag(ErrorKind::User)); } let mut embeds = Vec::with_capacity(files.len()); let mut attachments = Vec::with_capacity(files.len()); let mut plays = Vec::with_capacity(files.len()); let mut analyzer = ImageAnalyzer::default(); for (i, (attachment, bytes)) in files.into_iter().enumerate() { // {{{ Process attachment let mut image = image::load_from_memory(&bytes)?; let mut grayscale_image = DynamicImage::ImageLuma8(image.to_luma8()); let result = magic_detect_one( ctx, &user, &mut embeds, &mut attachments, &mut plays, &mut analyzer, attachment, i, &mut image, &mut grayscale_image, ) .await; if let Err(err) = result { let user_err = get_user_error!(err); analyzer .send_discord_error(ctx, &image, C::filename(attachment), user_err) .await?; } // }}} } if !embeds.is_empty() { ctx.send( CreateReply::default() .reply(true) .embeds(embeds) .attachments(attachments), ) .await?; } Ok(plays) } // }}} // {{{ Tests #[cfg(test)] mod magic_tests { use std::{path::PathBuf, str::FromStr}; use crate::{ arcaea::score::ScoringSystem, commands::discord::{mock::MockContext, play_song_title}, golden_test, with_test_ctx, }; use super::*; #[tokio::test] async fn no_pics() -> Result<(), Error> { with_test_ctx!("commands/score/magic/no_pics", |ctx| async move { magic_impl(ctx, &[]).await?; Ok(()) }) } golden_test!(simple_pic, "score/magic/single_pic"); async fn simple_pic(ctx: &mut MockContext) -> Result<(), TaggedError> { let plays = magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?]).await?; assert_eq!(plays.len(), 1); assert_eq!(plays[0].score(ScoringSystem::Standard).0, 9926250); assert_eq!(play_song_title(ctx, &plays[0])?, "ALTER EGO"); Ok(()) } golden_test!(weird_kerning, "score/magic/weird_kerning"); async fn weird_kerning(ctx: &mut MockContext) -> Result<(), TaggedError> { let plays = magic_impl( ctx, &[ PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?, ], ) .await?; assert_eq!(plays.len(), 2); assert_eq!(plays[0].score(ScoringSystem::Standard).0, 9983744); assert_eq!(play_song_title(ctx, &plays[0])?, "Antithese"); assert_eq!(plays[1].score(ScoringSystem::Standard).0, 9724775); assert_eq!(play_song_title(ctx, &plays[1])?, "GENOCIDER"); Ok(()) } } // }}} // {{{ Discord wrapper /// Identify scores from attached images. #[poise::command(prefix_command, slash_command)] pub async fn magic( mut ctx: PoiseContext<'_>, #[description = "Images containing scores"] files: Vec<serenity::Attachment>, ) -> Result<(), Error> { let res = magic_impl(&mut ctx, &files).await; ctx.handle_error(res).await?; Ok(()) } // }}} // }}} // {{{ Score show // {{{ Implementation pub async fn show_impl<C: MessageContext>( ctx: &mut C, ids: &[u32], ) -> Result<Vec<Play>, TaggedError> { if ids.is_empty() { return Err(anyhow!("Empty ID list provided").tag(ErrorKind::User)); } let mut embeds = Vec::with_capacity(ids.len()); let mut attachments = Vec::with_capacity(ids.len()); let mut plays = Vec::with_capacity(ids.len()); let conn = ctx.data().db.get()?; for (i, id) in ids.iter().enumerate() { let result = conn .prepare_cached( " SELECT p.id, p.chart_id, p.user_id, p.created_at, p.max_recall, p.far_notes, s.score, u.discord_id FROM plays p JOIN scores s ON s.play_id = p.id JOIN users u ON p.user_id = u.id WHERE s.scoring_system='standard' AND p.id=? ORDER BY s.score DESC LIMIT 1 ", )? .query_and_then([id], |row| -> Result<_, Error> { let (song, chart) = ctx.data().song_cache.lookup_chart(row.get("chart_id")?)?; let play = Play::from_sql(chart, row)?; let discord_id = row.get::<_, String>("discord_id")?; Ok((song, chart, play, discord_id)) })? .next(); let (song, chart, play, discord_id) = match result { None => { ctx.send( CreateReply::default().content(format!("Could not find play with id {}", id)), ) .await?; continue; } Some(result) => result?, }; let author = ctx.fetch_user(&discord_id).await?; let user = User::by_id(ctx.data(), play.user_id)?; let (embed, attachment) = play.to_embed(ctx.data(), &user, song, chart, i, Some(&author))?; embeds.push(embed); attachments.extend(attachment); plays.push(play); } if !embeds.is_empty() { ctx.send( CreateReply::default() .reply(true) .embeds(embeds) .attachments(attachments), ) .await?; } Ok(plays) } /// }}} // {{{ Tests #[cfg(test)] mod show_tests { use super::*; use crate::{commands::discord::mock::MockContext, golden_test, with_test_ctx}; use std::{path::PathBuf, str::FromStr}; #[tokio::test] async fn no_ids() -> Result<(), Error> { with_test_ctx!("commands/score/show/no_ids", |ctx| async move { show_impl(ctx, &[]).await?; Ok(()) }) } #[tokio::test] async fn nonexistent_id() -> Result<(), Error> { with_test_ctx!("commands/score/show/nonexistent_id", |ctx| async move { show_impl(ctx, &[666]).await?; Ok(()) }) } golden_test!(agrees_with_magic, "commands/score/show/agrees_with_magic"); async fn agrees_with_magic(ctx: &mut MockContext) -> Result<(), TaggedError> { let created_plays = magic_impl( ctx, &[ PathBuf::from_str("test/screenshots/alter_ego.jpg")?, PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?, ], ) .await?; let ids = created_plays.iter().map(|p| p.id).collect::<Vec<_>>(); let plays = show_impl(ctx, &ids).await?; assert_eq!(plays.len(), 3); assert_eq!(created_plays, plays); Ok(()) } } // }}} // {{{ Discord wrapper /// Show scores given their IDs. #[poise::command(prefix_command, slash_command)] pub async fn show( mut ctx: PoiseContext<'_>, #[description = "Ids of score to show"] ids: Vec<u32>, ) -> Result<(), Error> { let res = show_impl(&mut ctx, &ids).await; ctx.handle_error(res).await?; Ok(()) } // }}} // }}} // {{{ Score delete // {{{ Implementation pub async fn delete_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<(), TaggedError> { let user = User::from_context(ctx)?; if ids.is_empty() { return Err(anyhow!("Empty ID list provided").tag(ErrorKind::User)); } let mut count = 0; for id in ids { let res = ctx .data() .db .get()? .prepare_cached("DELETE FROM plays WHERE id=? AND user_id=?")? .execute((id, user.id))?; if res == 0 { ctx.reply(&format!("No play with id {} found", id)).await?; } else { count += 1; } } if count > 0 { ctx.reply(&format!("Deleted {} play(s) successfully!", count)) .await?; } Ok(()) } /// }}} // {{{ Tests #[cfg(test)] mod delete_tests { use super::*; use crate::{ commands::discord::{mock::MockContext, play_song_title}, golden_test, with_test_ctx, }; use std::{path::PathBuf, str::FromStr}; #[tokio::test] async fn no_ids() -> Result<(), Error> { with_test_ctx!("commands/score/delete/no_ids", |ctx| async move { delete_impl(ctx, &[]).await?; Ok(()) }) } #[tokio::test] async fn nonexistent_id() -> Result<(), Error> { with_test_ctx!("commands/score/delete/nonexistent_id", |ctx| async move { delete_impl(ctx, &[666]).await?; Ok(()) }) } golden_test!(delete_twice, "commands/score/delete/delete_twice"); async fn delete_twice(ctx: &mut MockContext) -> Result<(), TaggedError> { let plays = magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?]).await?; let id = plays[0].id; delete_impl(ctx, &[id, id]).await?; Ok(()) } golden_test!( no_show_after_delete, "commands/score/delete/no_show_after_delete" ); async fn no_show_after_delete(ctx: &mut MockContext) -> Result<(), TaggedError> { let plays = magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?]).await?; // Showcase proper usage let ids = [plays[0].id]; delete_impl(ctx, &ids).await?; // This will tell the user the play doesn't exist let shown_plays = show_impl(ctx, &ids).await?; assert_eq!(shown_plays.len(), 0); Ok(()) } golden_test!(delete_multiple, "commands/score/delete/delete_multiple"); async fn delete_multiple(ctx: &mut MockContext) -> Result<(), TaggedError> { let plays = magic_impl( ctx, &[ PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?, PathBuf::from_str("test/screenshots/alter_ego.jpg")?, PathBuf::from_str("test/screenshots/genocider_24_kerning.jpg")?, ], ) .await?; delete_impl(ctx, &[plays[0].id, plays[2].id]).await?; // Ensure the second play still exists let shown_plays = show_impl(ctx, &[plays[1].id]).await?; assert_eq!(play_song_title(ctx, &shown_plays[0])?, "ALTER EGO"); Ok(()) } } // }}} // {{{ Discord wrapper /// Delete scores, given their IDs. #[poise::command(prefix_command, slash_command)] pub async fn delete( mut ctx: PoiseContext<'_>, #[description = "Id of score to delete"] ids: Vec<u32>, ) -> Result<(), Error> { let res = delete_impl(&mut ctx, &ids).await; ctx.handle_error(res).await?; Ok(()) } // }}} // }}}