1
Fork 0
shimmeringmoon/src/commands/score.rs

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(())
}
// }}}
// }}}