1
Fork 0

Implement basic rich presence

This commit is contained in:
prescientmoon 2024-09-24 22:49:09 +02:00
parent 5186c7e8b8
commit 68c46fb7cd
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
26 changed files with 1061 additions and 267 deletions

View file

@ -5,13 +5,14 @@ use std::{fmt::Display, num::NonZeroU16, path::PathBuf};
use anyhow::anyhow;
use image::{ImageBuffer, Rgb};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use serde::{Deserialize, Serialize};
use crate::bitmap::Color;
use crate::context::{DbConnection, Error};
// }}}
// {{{ Difficuly
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Difficulty {
PST,
PRS,
@ -69,7 +70,7 @@ pub const DIFFICULTY_MENU_PIXEL_COLORS: [Color; Difficulty::DIFFICULTIES.len()]
];
// }}}
// {{{ Level
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Level {
Unknown,
One,
@ -144,7 +145,7 @@ impl FromSql for Level {
}
// }}}
// {{{ Side
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum Side {
Light,
Conflict,
@ -178,7 +179,7 @@ impl FromSql for Side {
}
// }}}
// {{{ Song
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Song {
pub id: u32,
pub title: String,
@ -199,7 +200,7 @@ pub struct Jacket {
pub bitmap: &'static ImageBuffer<Rgb<u8>, Vec<u8>>,
}
#[derive(Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Chart {
pub id: u32,
pub song_id: u32,
@ -212,17 +213,9 @@ pub struct Chart {
pub note_count: u32,
pub chart_constant: u32,
#[serde(skip)]
pub cached_jacket: Option<Jacket>,
}
impl Chart {
#[inline]
pub fn jacket_path(&self, data_dir: &Path) -> PathBuf {
data_dir
.join("jackets")
.join(format!("{}-{}.jpg", self.song_id, self.id))
}
}
// }}}
// {{{ Cached song
#[derive(Debug, Clone)]

View file

@ -26,6 +26,7 @@ pub struct ImageVec {
impl ImageVec {
// {{{ (Image => vector) encoding
#[allow(clippy::identity_op)]
pub fn from_image(image: &impl GenericImageView<Pixel = Rgba<u8>>) -> Self {
let mut colors = [0.0; IMAGE_VEC_DIM];
let chunk_width = image.width() / SPLIT_FACTOR;
@ -55,7 +56,6 @@ impl ImageVec {
let r = (r as f64 / count).sqrt();
let g = (g as f64 / count).sqrt();
let b = (b as f64 / count).sqrt();
#[allow(clippy::identity_op)]
colors[i as usize * 3 + 0] = r as f32;
colors[i as usize * 3 + 1] = g as f32;
colors[i as usize * 3 + 2] = b as f32;

View file

@ -12,6 +12,8 @@ use num::Rational32;
use num::Zero;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp};
use rusqlite::Row;
use serde::Deserialize;
use serde::Serialize;
use crate::arcaea::chart::{Chart, Song};
use crate::context::ErrorKind;
@ -140,7 +142,7 @@ impl CreatePlay {
}
// }}}
// {{{ Score data
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]);
impl ScoreCollection {
@ -152,7 +154,7 @@ impl ScoreCollection {
}
// }}}
// {{{ Play
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Play {
pub id: u32,
#[allow(unused)]
@ -267,9 +269,7 @@ impl Play {
} else {
Some('P')
}
} else if let Some(distribution) = self.distribution(chart.note_count)
&& distribution.3 == 0
{
} else if let Some((_, _, _, 0)) = self.distribution(chart.note_count) {
Some('F')
} else {
Some('C')
@ -555,3 +555,11 @@ pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), Error> {
Ok(())
}
// }}}
// {{{ Play + chart + song triplet
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayWithDetails {
pub play: Play,
pub song: Song,
pub chart: Chart,
}
// }}}

View file

@ -2,6 +2,7 @@
use std::fmt::{Display, Write};
use num::{Rational32, Rational64};
use serde::{Deserialize, Serialize};
use crate::context::Error;
@ -71,7 +72,7 @@ impl Display for Grade {
}
// }}}
// {{{ Score
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Score(pub u32);
impl Score {

View file

@ -6,12 +6,8 @@ use std::{env::var, sync::Arc, time::Duration};
// {{{ Error handler
async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
match error {
error => {
if let Err(e) = poise::builtins::on_error(error).await {
println!("Error while handling error: {}", e)
}
}
if let Err(e) = poise::builtins::on_error(error).await {
println!("Error while handling error: {}", e)
}
}
// }}}
@ -34,7 +30,7 @@ async fn main() {
} else if message.content.starts_with("!") {
Ok(Some(message.content.split_at(1)))
} else if message.guild_id.is_none() {
if message.content.trim().len() == 0 {
if message.content.trim().is_empty() {
Ok(Some(("", "score magic")))
} else {
Ok(Some(("", &message.content[..])))

View file

@ -0,0 +1,73 @@
use std::time::Duration;
use anyhow::anyhow;
// {{{ Imports
use discord_rich_presence::activity::{Activity, Assets};
use discord_rich_presence::{DiscordIpc, DiscordIpcClient};
use shimmeringmoon::arcaea::chart::Difficulty;
use shimmeringmoon::arcaea::play::PlayWithDetails;
use shimmeringmoon::arcaea::score::ScoringSystem;
use shimmeringmoon::assets::get_var;
use shimmeringmoon::context::Error;
// }}}
#[tokio::main]
async fn main() -> Result<(), Error> {
let server_url = get_var("SHIMMERING_SERVER_URL");
let client_id = get_var("SHIMMERING_DISCORD_ID");
println!("Connecting to discord...");
let mut ipc = DiscordIpcClient::new(&client_id).map_err(|e| anyhow!("{}", e))?;
ipc.connect().map_err(|e| anyhow!("{}", e))?;
println!("Starting presence loop...");
for i in 0.. {
println!("Getting most recent score...");
let res = reqwest::get(format!("{}/plays/latest", server_url)).await;
let res = match res.and_then(|r| r.error_for_status()) {
Ok(v) => v,
Err(e) => {
ipc.clear_activity().map_err(|e| anyhow!("{}", e))?;
println!("{e}");
tokio::time::sleep(Duration::from_secs(10)).await;
continue;
}
};
let triplet = res.json::<PlayWithDetails>().await?;
let jacket_url = format!(
"{}/jackets/by_chart_id/{}.png",
server_url, &triplet.chart.id
);
let jacket_url = "https://static.wikia.nocookie.net/iowiro/images/c/c2/Fracture_Ray.jpg/revision/latest?cb=20230928061927";
println!("Jacket url: {}", jacket_url);
let jacket_text = format!("{}{}", &triplet.song.title, &triplet.song.artist);
let assets = Assets::new()
.large_image(&jacket_url)
.large_text(&jacket_text);
let details = format!(
"{} [{} {}]",
&triplet.song.title,
Difficulty::DIFFICULTY_SHORTHANDS[triplet.chart.difficulty.to_index()],
&triplet.chart.level,
);
let state = format!("{}", &triplet.play.score(ScoringSystem::Standard));
let activity = Activity::new()
.assets(assets)
.details(&details)
.state(&state);
println!("Sending activity");
ipc.set_activity(activity).map_err(|e| anyhow!("{}", e))?;
tokio::time::sleep(Duration::from_secs(30)).await;
}
Ok(())
}

12
src/bin/server/context.rs Normal file
View file

@ -0,0 +1,12 @@
use shimmeringmoon::context::UserContext;
#[derive(Clone, Copy)]
pub struct AppContext {
pub ctx: &'static UserContext,
}
impl AppContext {
pub fn new(ctx: &'static UserContext) -> Self {
Self { ctx }
}
}

34
src/bin/server/error.rs Normal file
View file

@ -0,0 +1,34 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
pub struct AppError {
pub error: anyhow::Error,
pub status_code: StatusCode,
}
impl AppError {
pub fn new(error: anyhow::Error, status_code: StatusCode) -> Self {
Self { error, status_code }
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
self.status_code,
format!("Something went wrong: {}", self.error),
)
.into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self::new(err.into(), StatusCode::INTERNAL_SERVER_ERROR)
}
}

33
src/bin/server/main.rs Normal file
View file

@ -0,0 +1,33 @@
use context::AppContext;
use routes::jacket::get_jacket_image;
use routes::recent_plays::get_recent_play;
use shimmeringmoon::assets::get_var;
use shimmeringmoon::context::{Error, UserContext};
mod context;
mod error;
mod routes;
#[tokio::main]
async fn main() -> Result<(), Error> {
let ctx = Box::leak(Box::new(UserContext::new().await?));
let app = axum::Router::new()
.route("/plays/latest", axum::routing::get(get_recent_play))
.route(
"/jackets/by_chart_id/:chart_id",
axum::routing::get(get_jacket_image),
)
.with_state(AppContext::new(ctx));
let port: u32 = get_var("SHIMMERING_SERVER_PORT").parse()?;
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{}", port))
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -0,0 +1,44 @@
use std::io::Cursor;
use axum::extract::{Path, State};
use axum::http::{header, HeaderName, StatusCode};
use crate::{context::AppContext, error::AppError};
pub async fn get_jacket_image(
State(state): State<AppContext>,
Path(filename): Path<String>,
) -> Result<([(HeaderName, String); 2], Vec<u8>), AppError> {
let chart_id = filename
.strip_suffix(".png")
.unwrap_or(&filename)
.parse::<u32>()
.map_err(|e| AppError::new(e.into(), StatusCode::NOT_FOUND))?;
let (_song, chart) = state
.ctx
.song_cache
.lookup_chart(chart_id)
.map_err(|e| AppError::new(e, StatusCode::NOT_FOUND))?;
let headers = [
(header::CONTENT_TYPE, "image/png".to_owned()),
(
header::HeaderName::from_static("pngrok-skip-browser-warning"),
"-".to_owned(),
),
// (
// header::CONTENT_DISPOSITION,
// format!("attachment; filename=\"chart_{}.jpg\"", chart_id),
// ),
];
let mut buffer = Vec::new();
let mut cursor = Cursor::new(&mut buffer);
chart
.cached_jacket
.unwrap()
.bitmap
.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok((headers, buffer))
}

View file

@ -0,0 +1,2 @@
pub mod jacket;
pub mod recent_plays;

View file

@ -0,0 +1,50 @@
// {{{ Imports
use crate::context::AppContext;
use crate::error::AppError;
use anyhow::anyhow;
use axum::{extract::State, http::StatusCode, Json};
use chrono::{TimeDelta, Utc};
use shimmeringmoon::arcaea::play::{Play, PlayWithDetails};
// }}}
pub async fn get_recent_play(
State(state): State<AppContext>,
) -> Result<Json<PlayWithDetails>, AppError> {
let after = Utc::now()
.checked_sub_signed(TimeDelta::minutes(30))
.unwrap()
.naive_utc();
let (play, song, chart) = state
.ctx
.db
.get()?
.prepare_cached(
"
SELECT
p.id, p.chart_id, p.user_id, p.created_at,
p.max_recall, p.far_notes, s.score
FROM plays p
JOIN scores s ON s.play_id = p.id
WHERE s.scoring_system='standard'
AND p.user_id=?
AND p.created_at>=?
ORDER BY p.created_at DESC
LIMIT 1
",
)?
.query_and_then((2, after), |row| -> Result<_, AppError> {
let (song, chart) = state.ctx.song_cache.lookup_chart(row.get("chart_id")?)?;
let play = Play::from_sql(chart, row)?;
Ok((play, song, chart))
})?
.next()
.ok_or_else(|| AppError::new(anyhow!("No recent plays found"), StatusCode::NOT_FOUND))??;
// Perhaps I need to make a Serialize-only version of this type which takes refs?
Ok(axum::response::Json(PlayWithDetails {
play,
song: song.clone(),
chart: chart.clone(),
}))
}

View file

@ -363,13 +363,15 @@ impl BitmapCanvas {
})?;
let face = &mut faces[face_index];
if let Some((prev_face_index, prev_glyth_index)) = previous
&& prev_face_index == face_index
&& kerning[face_index]
{
let delta =
face.get_kerning(prev_glyth_index, glyph_index, KerningMode::KerningDefault)?;
pen_x += delta.x >> 6; // we shift to get rid of sub-pixel accuracy
if let Some((prev_face_index, prev_glyth_index)) = previous {
if prev_face_index == face_index && kerning[face_index] {
let delta = face.get_kerning(
prev_glyth_index,
glyph_index,
KerningMode::KerningDefault,
)?;
pen_x += delta.x >> 6; // we shift to get rid of sub-pixel accuracy
}
}
face.load_glyph(glyph_index, LoadFlag::DEFAULT)?;
@ -579,12 +581,13 @@ impl LayoutManager {
) {
let current = self.boxes[id.0];
if let Some((current_points_to, dx, dy)) = current.relative_to
&& current_points_to != id_relative_to
{
self.edit_to_relative(current_points_to, id_relative_to, x - dx, y - dy);
} else {
self.boxes[id.0].relative_to = Some((id_relative_to, x, y));
match current.relative_to {
Some((current_points_to, dx, dy)) if current_points_to != id_relative_to => {
self.edit_to_relative(current_points_to, id_relative_to, x - dx, y - dy);
}
_ => {
self.boxes[id.0].relative_to = Some((id_relative_to, x, y));
}
}
{

View file

@ -101,13 +101,13 @@ async fn info_impl(ctx: &mut impl MessageContext, name: &str) -> Result<(), Tagg
// {{{ Tests
#[cfg(test)]
mod info_tests {
use crate::{commands::discord::mock::MockContext, with_test_ctx};
use crate::{commands::discord::mock::MockContext, golden_test, with_test_ctx};
use super::*;
#[tokio::test]
async fn no_suffix() -> Result<(), Error> {
with_test_ctx!("test/commands/chart/info/no_suffix", async |ctx| {
with_test_ctx!("commands/commands/chart/info/no_suffix", |ctx| async move {
info_impl(ctx, "Pentiment").await?;
Ok(())
})
@ -115,23 +115,21 @@ mod info_tests {
#[tokio::test]
async fn specify_difficulty() -> Result<(), Error> {
with_test_ctx!("test/commands/chart/info/specify_difficulty", async |ctx| {
info_impl(ctx, "Hellohell [ETR]").await?;
Ok(())
})
}
#[tokio::test]
async fn last_byd() -> Result<(), Error> {
with_test_ctx!(
"test/commands/chart/info/last_byd",
async |ctx: &mut MockContext| {
info_impl(ctx, "Last | Moment [BYD]").await?;
info_impl(ctx, "Last | Eternity [BYD]").await?;
"commands/commands/chart/info/specify_difficulty",
|ctx| async move {
info_impl(ctx, "Hellohell [ETR]").await?;
Ok(())
}
)
}
golden_test!(last_byd, "commands/chart/info/last_byd");
async fn last_byd(ctx: &mut MockContext) -> Result<(), TaggedError> {
info_impl(ctx, "Last | Moment [BYD]").await?;
info_impl(ctx, "Last | Eternity [BYD]").await?;
Ok(())
}
}
// }}}
// {{{ Discord wrapper
@ -208,46 +206,41 @@ async fn best_impl<C: MessageContext>(ctx: &mut C, name: &str) -> Result<Play, T
// {{{ Tests
#[cfg(test)]
mod best_tests {
use std::path::PathBuf;
use std::{path::PathBuf, str::FromStr};
use crate::{
commands::{discord::mock::MockContext, score::magic_impl},
with_test_ctx,
golden_test, with_test_ctx,
};
use super::*;
#[tokio::test]
async fn no_scores() -> Result<(), Error> {
with_test_ctx!("test/commands/chart/best/no_scores", async |ctx| {
with_test_ctx!("commands/chart/best/no_scores", |ctx| async move {
best_impl(ctx, "Pentiment").await?;
Ok(())
})
}
#[tokio::test]
async fn pick_correct_score() -> Result<(), Error> {
with_test_ctx!(
"test/commands/chart/best/pick_correct_score",
async |ctx: &mut MockContext| {
let plays = magic_impl(
ctx,
&[
PathBuf::from_str("test/screenshots/fracture_ray_ex.jpg")?,
// Make sure we aren't considering higher scores from other stuff
PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
PathBuf::from_str("test/screenshots/fracture_ray_missed_ex.jpg")?,
],
)
.await?;
let play = best_impl(ctx, "Fracture ray").await?;
assert_eq!(play.score(ScoringSystem::Standard).0, 9_805_651);
assert_eq!(plays[0], play);
Ok(())
}
golden_test!(pick_correct_score, "commands/chart/best/pick_correct_score");
async fn pick_correct_score(ctx: &mut MockContext) -> Result<(), TaggedError> {
let plays = magic_impl(
ctx,
&[
PathBuf::from_str("test/screenshots/fracture_ray_ex.jpg")?,
// Make sure we aren't considering higher scores from other stuff
PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
PathBuf::from_str("test/screenshots/fracture_ray_missed_ex.jpg")?,
],
)
.await?;
let play = best_impl(ctx, "Fracture ray").await?;
assert_eq!(play.score(ScoringSystem::Standard).0, 9_805_651);
assert_eq!(plays[0], play);
Ok(())
}
}
// }}}

View file

@ -4,7 +4,7 @@ use crate::arcaea::score::Score;
use crate::context::{Context, Error, ErrorKind, TagError, TaggedError};
use crate::recognition::recognize::{ImageAnalyzer, ScoreKind};
use crate::user::User;
use crate::{get_user_error, timed};
use crate::{get_user_error, timed, try_block};
use anyhow::anyhow;
use image::DynamicImage;
use poise::{serenity_prelude as serenity, CreateReply};
@ -48,7 +48,7 @@ pub async fn magic_impl<C: MessageContext>(
let mut grayscale_image = DynamicImage::ImageLuma8(image.to_luma8());
// }}}
let result: Result<(), TaggedError> = try {
let result: Result<(), TaggedError> = try_block!({
// {{{ Detection
let kind = timed!("read_score_kind", {
@ -113,7 +113,7 @@ pub async fn magic_impl<C: MessageContext>(
embeds.push(embed);
attachments.extend(attachment);
// }}}
};
});
if let Err(err) = result {
let user_err = get_user_error!(err);
@ -140,63 +140,52 @@ pub async fn magic_impl<C: MessageContext>(
#[cfg(test)]
mod magic_tests {
use std::path::PathBuf;
use std::{path::PathBuf, str::FromStr};
use crate::{
arcaea::score::ScoringSystem,
commands::discord::{mock::MockContext, play_song_title},
with_test_ctx,
golden_test, with_test_ctx,
};
use super::*;
#[tokio::test]
async fn no_pics() -> Result<(), Error> {
with_test_ctx!("test/commands/score/magic/no_pics", async |ctx| {
with_test_ctx!("commands/score/magic/no_pics", |ctx| async move {
magic_impl(ctx, &[]).await?;
Ok(())
})
}
#[tokio::test]
async fn simple_pic() -> Result<(), Error> {
with_test_ctx!(
"test/commands/score/magic/single_pic",
async |ctx: &mut MockContext| {
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!(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(())
}
#[tokio::test]
async fn weird_kerning() -> Result<(), Error> {
with_test_ctx!(
"test/commands/score/magic/weird_kerning",
async |ctx: &mut MockContext| {
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(())
}
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(())
}
}
// }}}
@ -293,12 +282,12 @@ pub async fn show_impl<C: MessageContext>(
#[cfg(test)]
mod show_tests {
use super::*;
use crate::{commands::discord::mock::MockContext, with_test_ctx};
use std::path::PathBuf;
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!("test/commands/score/show/no_ids", async |ctx| {
with_test_ctx!("commands/score/show/no_ids", |ctx| async move {
show_impl(ctx, &[]).await?;
Ok(())
})
@ -306,35 +295,30 @@ mod show_tests {
#[tokio::test]
async fn nonexistent_id() -> Result<(), Error> {
with_test_ctx!("test/commands/score/show/nonexistent_id", async |ctx| {
with_test_ctx!("commands/score/show/nonexistent_id", |ctx| async move {
show_impl(ctx, &[666]).await?;
Ok(())
})
}
#[tokio::test]
async fn agrees_with_magic() -> Result<(), Error> {
with_test_ctx!(
"test/commands/score/show/agrees_with_magic",
async |ctx: &mut MockContext| {
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(())
}
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(())
}
}
// }}}
@ -392,13 +376,13 @@ mod delete_tests {
use super::*;
use crate::{
commands::discord::{mock::MockContext, play_song_title},
with_test_ctx,
golden_test, with_test_ctx,
};
use std::path::PathBuf;
use std::{path::PathBuf, str::FromStr};
#[tokio::test]
async fn no_ids() -> Result<(), Error> {
with_test_ctx!("test/commands/score/delete/no_ids", async |ctx| {
with_test_ctx!("commands/score/delete/no_ids", |ctx| async move {
delete_impl(ctx, &[]).await?;
Ok(())
})
@ -406,74 +390,60 @@ mod delete_tests {
#[tokio::test]
async fn nonexistent_id() -> Result<(), Error> {
with_test_ctx!("test/commands/score/delete/nonexistent_id", async |ctx| {
with_test_ctx!("commands/score/delete/nonexistent_id", |ctx| async move {
delete_impl(ctx, &[666]).await?;
Ok(())
})
}
#[tokio::test]
async fn delete_twice() -> Result<(), Error> {
with_test_ctx!(
"test/commands/score/delete/delete_twice",
async |ctx: &mut MockContext| {
let plays =
magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?])
.await?;
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(())
}
)
let id = plays[0].id;
delete_impl(ctx, &[id, id]).await?;
Ok(())
}
#[tokio::test]
async fn no_show_after_delete() -> Result<(), Error> {
with_test_ctx!(
"test/commands/score/delete/no_show_after_delete",
async |ctx: &mut MockContext| {
let plays =
magic_impl(ctx, &[PathBuf::from_str("test/screenshots/alter_ego.jpg")?])
.await?;
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?;
// 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);
// 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(())
}
)
Ok(())
}
#[tokio::test]
async fn delete_multiple() -> Result<(), Error> {
with_test_ctx!(
"test/commands/score/delete/delete_multiple",
async |ctx: &mut MockContext| {
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(())
}
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(())
}
}
// }}}

View file

@ -145,7 +145,7 @@ pub mod testing {
pub async fn get_shared_context() -> &'static UserContext {
static CELL: tokio::sync::OnceCell<UserContext> = tokio::sync::OnceCell::const_new();
CELL.get_or_init(async || {
CELL.get_or_init(|| async move {
// env::set_var("SHIMMERING_DATA_DIR", "")
UserContext::new().await.unwrap()
})
@ -165,6 +165,20 @@ pub mod testing {
);
}
// rustfmt fucks up the formatting here,
// but the skip attribute doesn't seem to work well on macros 🤔
#[macro_export]
macro_rules! golden_test {
($name:ident, $test_path:expr) => {
paste::paste! {
#[tokio::test]
async fn [<$name _test>]() -> Result<(), $crate::context::Error> {
$crate::with_test_ctx!($test_path, $name)
}
}
};
}
#[macro_export]
macro_rules! with_test_ctx {
($test_path:expr, $f:expr) => {{
@ -179,10 +193,11 @@ pub mod testing {
let res = $crate::user::User::create_from_context(&ctx);
ctx.handle_error(res).await?;
let res: Result<(), $crate::context::TaggedError> = $f(&mut ctx).await;
let ctx: &mut $crate::commands::discord::mock::MockContext = &mut ctx;
let res: Result<(), $crate::context::TaggedError> = $f(ctx).await;
ctx.handle_error(res).await?;
ctx.golden(&std::path::PathBuf::from_str($test_path)?)?;
ctx.golden(&std::path::PathBuf::from_str("test")?.join($test_path))?;
Ok(())
}};
}

View file

@ -1,16 +1,6 @@
#![allow(async_fn_in_trait)]
#![allow(clippy::needless_range_loop)]
#![allow(clippy::redundant_closure)]
#![feature(iter_map_windows)]
#![feature(anonymous_lifetime_in_impl_trait)]
#![feature(let_chains)]
#![feature(array_try_map)]
#![feature(async_closure)]
#![feature(try_blocks)]
#![feature(thread_local)]
#![feature(generic_arg_infer)]
#![feature(iter_collect_into)]
#![feature(stmt_expr_attributes)]
pub mod arcaea;
pub mod assets;
@ -23,3 +13,4 @@ pub mod recognition;
pub mod time;
pub mod transform;
pub mod user;
pub mod utils;

View file

@ -74,9 +74,7 @@ pub fn guess_chart_name<'a>(
let mut close_enough: Vec<_> = cache
.charts()
.filter_map(|chart| {
if let Some(difficulty) = difficulty
&& chart.difficulty != difficulty
{
if difficulty.map_or(false, |d| d != chart.difficulty) {
return None;
}
@ -92,22 +90,24 @@ pub fn guess_chart_name<'a>(
// Cut title to the length of the text, and then check
let shortest_len = Ord::min(song_title.len(), text.len());
if let Some(sliced) = &song_title.get(..shortest_len)
&& (text.len() >= 6 || unsafe_heuristics)
{
let slice_distance = edit_distance_with(text, sliced, &mut levenshtein_vec);
if slice_distance == 0 {
distance_vec.push(3);
if let Some(sliced) = &song_title.get(..shortest_len) {
if text.len() >= 6 || unsafe_heuristics {
let slice_distance = edit_distance_with(text, sliced, &mut levenshtein_vec);
if slice_distance == 0 {
distance_vec.push(3);
}
}
}
// Shorthand-based matching
if let Some(shorthand) = &chart.shorthand
&& unsafe_heuristics
{
let short_distance = edit_distance_with(text, shorthand, &mut levenshtein_vec);
if short_distance <= shorthand.len() / 3 {
distance_vec.push(short_distance * 10 + 1);
if let Some(shorthand) = &chart.shorthand {
if unsafe_heuristics {
let short_distance =
edit_distance_with(text, shorthand, &mut levenshtein_vec);
if short_distance <= shorthand.len() / 3 {
distance_vec.push(short_distance * 10 + 1);
}
}
}

View file

@ -70,10 +70,10 @@ impl ComponentVec {
for x in x_start..x_end {
for y in y_start..y_end {
if let Some(p) = components.components.get_pixel_checked(x, y)
&& p.0[0] == component
{
count += 255 - components.image[(x, y)].0[0] as u32;
if let Some(p) = components.components.get_pixel_checked(x, y) {
if p.0[0] == component {
count += 255 - components.image[(x, y)].0[0] as u32;
}
}
}
}

View file

@ -162,14 +162,14 @@ impl ImageAnalyzer {
);
// Discard scores if it's impossible
if result.0 <= 10_010_000
&& note_count.map_or(true, |note_count| {
let (zeta, shinies, score_units) = result.analyse(note_count);
8_000_000 <= zeta.0
&& zeta.0 <= 10_000_000
&& shinies <= note_count
&& score_units <= 2 * note_count
}) {
let valid_analysis = note_count.map_or(true, |note_count| {
let (zeta, shinies, score_units) = result.analyse(note_count);
8_000_000 <= zeta.0
&& zeta.0 <= 10_000_000
&& shinies <= note_count
&& score_units <= 2 * note_count
});
if result.0 <= 10_010_000 && valid_analysis {
Ok(result)
} else {
Err(anyhow!("Score {result} is not vaild"))

20
src/utils.rs Normal file
View file

@ -0,0 +1,20 @@
/// Performs "Ok-wrapping" on the result of an expression.
/// This is compatible with [`Result`], [`Option`], [`ControlFlow`], and any type that
/// implements the unstable [`std::ops::Try`] trait.
///
/// The destination type must be specified with a type ascription somewhere.
#[macro_export]
macro_rules! wrap_ok {
($e:expr) => {
::core::iter::empty().try_fold($e, |_, __x: ::core::convert::Infallible| match __x {})
};
}
#[macro_export]
macro_rules! try_block {
{ $($token:tt)* } => {
(|| $crate::wrap_ok!({
$($token)*
}))()
}
}