489 lines
12 KiB
Rust
489 lines
12 KiB
Rust
// {{{ 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(())
|
|
}
|
|
// }}}
|
|
// }}}
|