From 49d50bf88b4ae03fa1d1f7b8bc300a3ee8039a83 Mon Sep 17 00:00:00 2001 From: prescientmoon Date: Thu, 27 Jun 2024 21:22:44 +0200 Subject: [PATCH] Figured out plotting! Signed-off-by: prescientmoon --- Cargo.lock | 378 +++++--- Cargo.toml | 4 +- flake.nix | 2 + schema.sql | 2 +- src/chart.rs | 42 +- src/commands/mod.rs | 2 + src/{commands.rs => commands/score.rs} | 148 +-- src/commands/stats.rs | 245 +++++ src/context.rs | 6 +- src/main.rs | 8 +- src/score.rs | 1222 +++++++++++++----------- src/user.rs | 15 + 12 files changed, 1297 insertions(+), 777 deletions(-) create mode 100644 src/commands/mod.rs rename src/{commands.rs => commands/score.rs} (75%) create mode 100644 src/commands/stats.rs diff --git a/Cargo.lock b/Cargo.lock index 74707d4..9f593bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -401,6 +401,42 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.12" @@ -494,24 +530,13 @@ dependencies = [ ] [[package]] -name = "csv" -version = "1.3.0" +name = "cstr" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +checksum = "68523903c8ae5aacfa32a0d9ae60cadeb764e1da14ee0d26b1f3089f13a54636" dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "csv-core" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" -dependencies = [ - "memchr", + "proc-macro2", + "quote", ] [[package]] @@ -634,12 +659,33 @@ dependencies = [ "winapi", ] +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dwrote" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439a1c2ba5611ad3ed731280541d36d2e9c4ac5e7fb818a27b604bdc5a6aa65b" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "edit-distance" version = "2.1.0" @@ -655,12 +701,6 @@ dependencies = [ "serde", ] -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "encoding_rs" version = "0.8.34" @@ -728,28 +768,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "failure" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" -dependencies = [ - "backtrace", - "failure_derive", -] - -[[package]] -name = "failure_derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "synstructure", -] - [[package]] name = "fastrand" version = "2.1.0" @@ -775,6 +793,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "flume" version = "0.11.0" @@ -792,6 +816,58 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "font-kit" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2845a73bbd781e691ab7c2a028c579727cd254942e8ced57ff73e0eafd60de87" +dependencies = [ + "bitflags 2.5.0", + "byteorder", + "core-foundation", + "core-graphics", + "core-text", + "dirs-next", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -801,6 +877,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "futures" version = "0.3.30" @@ -930,6 +1017,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3edd93c6756b4dfaf2709eafcc345ba2636565295c198a9cfbf75fa5e3e00b06" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.13.1" @@ -1176,6 +1273,20 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-traits", + "png", +] + [[package]] name = "image" version = "0.25.1" @@ -1186,7 +1297,7 @@ dependencies = [ "byteorder", "color_quant", "exr", - "gif", + "gif 0.13.1", "image-webp", "num-traits", "png", @@ -1242,17 +1353,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" -[[package]] -name = "is-terminal" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" -dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "itertools" version = "0.12.1" @@ -1709,6 +1809,25 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf45976c56919841273f2a0fc684c28437e2f304e264557d9c72be5d5a718be" +dependencies = [ + "rustc_version", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -1770,13 +1889,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" [[package]] -name = "plotlib" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9462104f987d8d0f6625f0c7764f1c8b890bd1dc8584d8293e031f25c5a0d242" +name = "plotters" +version = "0.4.0" +source = "git+https://github.com/starlitcanopy/plotters.git?rev=986cd959362a2dbec8d1b25670fd083b904d7b8c#986cd959362a2dbec8d1b25670fd083b904d7b8c" dependencies = [ - "failure", - "svg", + "chrono", + "font-kit", + "image 0.24.9", + "lazy_static", + "num-traits", + "pathfinder_geometry", + "plotters-backend", + "plotters-bitmap", + "plotters-svg", + "ttf-parser", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.4.0" +source = "git+https://github.com/starlitcanopy/plotters.git?rev=986cd959362a2dbec8d1b25670fd083b904d7b8c#986cd959362a2dbec8d1b25670fd083b904d7b8c" + +[[package]] +name = "plotters-bitmap" +version = "0.4.0" +source = "git+https://github.com/starlitcanopy/plotters.git?rev=986cd959362a2dbec8d1b25670fd083b904d7b8c#986cd959362a2dbec8d1b25670fd083b904d7b8c" +dependencies = [ + "gif 0.11.4", + "image 0.24.9", + "plotters-backend", +] + +[[package]] +name = "plotters-svg" +version = "0.4.0" +source = "git+https://github.com/starlitcanopy/plotters.git?rev=986cd959362a2dbec8d1b25670fd083b904d7b8c#986cd959362a2dbec8d1b25670fd083b904d7b8c" +dependencies = [ + "plotters-backend", ] [[package]] @@ -1833,20 +1984,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" -[[package]] -name = "prettytable-rs" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" -dependencies = [ - "csv", - "encode_unicode", - "is-terminal", - "lazy_static", - "term", - "unicode-width", -] - [[package]] name = "proc-macro2" version = "1.0.85" @@ -2168,6 +2305,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.34" @@ -2243,12 +2389,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "rustversion" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" - [[package]] name = "ryu" version = "1.0.18" @@ -2420,14 +2560,12 @@ name = "shimmeringmoon" version = "0.1.0" dependencies = [ "chrono", - "csv", "edit-distance", - "image", + "image 0.25.1", "kd-tree", "num", - "plotlib", + "plotters", "poise", - "prettytable-rs", "sqlx", "tesseract", "tokio", @@ -2761,12 +2899,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" -[[package]] -name = "svg" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3685c82a045a6af0c488f0550b0f52b4c77d2a52b0ca8aba719f9d268fa96965" - [[package]] name = "syn" version = "1.0.109" @@ -2795,18 +2927,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" -[[package]] -name = "synstructure" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", - "unicode-xid", -] - [[package]] name = "system-configuration" version = "0.5.1" @@ -2865,17 +2985,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "term" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" -dependencies = [ - "dirs-next", - "rustversion", - "winapi", -] - [[package]] name = "tesseract" version = "0.15.1" @@ -3160,6 +3269,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3e06c9b9d80ed6b745c7159c40b311ad2916abb34a49e9be2653b90db0d8dd" + [[package]] name = "tungstenite" version = "0.21.0" @@ -3264,18 +3379,6 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" -[[package]] -name = "unicode-width" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" - -[[package]] -name = "unicode-xid" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" - [[package]] name = "unicode_categories" version = "0.1.1" @@ -3702,6 +3805,27 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb6b23999a8b1a997bf47c7bb4d19ad4029c3327bb3386ebe0a5ff584b33c7a" +dependencies = [ + "cstr", + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "zerocopy" version = "0.7.34" diff --git a/Cargo.toml b/Cargo.toml index 7888eb7..7817ea0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,12 @@ edition = "2021" [dependencies] chrono = "0.4.38" -csv = "1.3.0" edit-distance = "2.1.0" image = "0.25.1" kd-tree = "0.6.0" num = "0.4.3" -plotlib = "0.5.1" +plotters = { git="https://github.com/starlitcanopy/plotters.git", rev="986cd959362a2dbec8d1b25670fd083b904d7b8c" } poise = "0.6.1" -prettytable-rs = "0.10.0" sqlx = { version = "0.7.4", features = ["sqlite", "runtime-tokio", "chrono"] } tesseract = "0.15.1" tokio = {version="1.38.0", features=["rt-multi-thread"]} diff --git a/flake.nix b/flake.nix index e7858e5..9dc28cf 100644 --- a/flake.nix +++ b/flake.nix @@ -28,6 +28,8 @@ rust-analyzer-nightly ruff imagemagick + fontconfig + freetype clang llvmPackages.clang diff --git a/schema.sql b/schema.sql index 548f351..b874dbe 100644 --- a/schema.sql +++ b/schema.sql @@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS plays ( creation_zeta_ptt INTEGER, score INTEGER NOT NULL, - zeta_score INTEGER, + zeta_score INTEGER NOT NULL, max_recall INTEGER, far_notes INTEGER, diff --git a/src/chart.rs b/src/chart.rs index 1fb662b..bab17c2 100644 --- a/src/chart.rs +++ b/src/chart.rs @@ -5,7 +5,7 @@ use sqlx::{prelude::FromRow, SqlitePool}; use crate::context::Error; // {{{ Difficuly -#[derive(Debug, Clone, Copy, sqlx::Type)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type)] pub enum Difficulty { PST, PRS, @@ -85,17 +85,31 @@ impl CachedSong { } #[inline] - pub fn lookup(&self, difficulty: Difficulty) -> Option<&Chart> { + pub fn lookup(&self, difficulty: Difficulty) -> Result<&Chart, Error> { self.charts .get(difficulty.to_index()) .and_then(|c| c.as_ref()) + .ok_or_else(|| { + format!( + "Could not find difficulty {:?} for song {}", + difficulty, self.song.title + ) + .into() + }) } #[inline] - pub fn lookup_mut(&mut self, difficulty: Difficulty) -> Option<&mut Chart> { + pub fn lookup_mut(&mut self, difficulty: Difficulty) -> Result<&mut Chart, Error> { self.charts .get_mut(difficulty.to_index()) .and_then(|c| c.as_mut()) + .ok_or_else(|| { + format!( + "Could not find difficulty {:?} for song {}", + difficulty, self.song.title + ) + .into() + }) } #[inline] @@ -117,8 +131,26 @@ impl SongCache { } #[inline] - pub fn lookup_mut(&mut self, id: u32) -> Option<&mut CachedSong> { - self.songs.get_mut(id as usize).and_then(|i| i.as_mut()) + pub fn lookup_chart(&self, chart_id: u32) -> Result<(&Song, &Chart), Error> { + self.songs() + .find_map(|item| { + item.charts().find_map(|chart| { + if chart.id == chart_id { + Some((&item.song, chart)) + } else { + None + } + }) + }) + .ok_or_else(|| format!("Could not find chart with id {}", chart_id).into()) + } + + #[inline] + pub fn lookup_mut(&mut self, id: u32) -> Result<&mut CachedSong, Error> { + self.songs + .get_mut(id as usize) + .and_then(|i| i.as_mut()) + .ok_or_else(|| format!("Could not find song with id {}", id).into()) } #[inline] diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..73fd49c --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,2 @@ +pub mod score; +pub mod stats; diff --git a/src/commands.rs b/src/commands/score.rs similarity index 75% rename from src/commands.rs rename to src/commands/score.rs index aeb9ade..86393af 100644 --- a/src/commands.rs +++ b/src/commands/score.rs @@ -1,8 +1,10 @@ use std::fmt::Display; use crate::context::{Context, Error}; -use crate::score::{jacket_rects, CreatePlay, ImageCropper, ImageDimensions, RelativeRect, Score}; -use crate::user::User; +use crate::score::{ + jacket_rects, CreatePlay, ImageCropper, ImageDimensions, Play, RelativeRect, Score, +}; +use crate::user::{discord_it_to_discord_user, User}; use image::imageops::FilterType; use image::ImageFormat; use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage}; @@ -36,7 +38,7 @@ pub async fn help( #[poise::command( prefix_command, slash_command, - subcommands("magic", "delete"), + subcommands("magic", "delete", "show"), subcommand_required )] pub async fn score(_ctx: Context<'_>) -> Result<(), Error> { @@ -88,8 +90,8 @@ pub async fn magic( if files.len() == 0 { ctx.reply("No images found attached to message").await?; } else { - let mut embeds: Vec = vec![]; - let mut attachments: Vec = vec![]; + let mut embeds = Vec::with_capacity(files.len()); + let mut attachments = Vec::with_capacity(files.len()); let handle = ctx .reply(format!("Processed 0/{} scores", files.len())) .await?; @@ -116,7 +118,7 @@ pub async fn magic( .content(format!("Image {}: reading jacket", i + 1)); handle.edit(ctx, edited).await?; - let song_by_jacket = cropper.read_jacket(ctx.data(), &image); + let song_by_jacket = cropper.read_jacket(ctx.data(), &image).await; // This makes OCR more likely to work let mut ocr_image = image.grayscale().blur(1.); @@ -151,8 +153,10 @@ pub async fn magic( .content(format!("Image {}: reading title", i + 1)); handle.edit(ctx, edited).await?; - let song_by_name = cropper.read_song(&ocr_image, &ctx.data().song_cache); - let cached_song = match (song_by_jacket, song_by_name) { + let song_by_name = cropper + .read_song(&ocr_image, &ctx.data().song_cache, difficulty) + .await; + let (song, chart) = match (song_by_jacket, song_by_name) { // {{{ Both errors (Err(err_jacket), Err(err_name)) => { error_with_image( @@ -196,16 +200,8 @@ Title error: {} .ok_or_else(|| "Could not find jacket area in picture")? .to_absolute(); // }}} - // {{{ Find chart - let chart = by_name.lookup(difficulty).ok_or_else(|| { - format!( - "Cannot find difficulty {:?} for chart {:?}", - difficulty, by_name.song.title - ) - })?; - // }}} // {{{ Build path - let filename = format!("{}-{}", by_name.song.id, chart.id); + let filename = format!("{}-{}", by_name.0.id, by_name.1.id); let jacket = format!("user/{}", filename); let jacket_dir = ctx.data().data_dir.join("jackets/user"); @@ -221,42 +217,27 @@ Title error: {} sqlx::query!( "UPDATE charts SET jacket=? WHERE song_id=? AND difficulty=?", jacket, - chart.song_id, - chart.difficulty, + by_name.1.song_id, + by_name.1.difficulty, ) .execute(&ctx.data().db) .await?; // }}} // {{{ Aquire and use song cache lock { - let mut song_cache = ctx - .data() - .song_cache - .lock() - .map_err(|_| "Poisoned song cache")?; + let mut song_cache = ctx.data().song_cache.lock().await; let chart = song_cache - .lookup_mut(by_name.song.id) - .ok_or_else(|| { - format!("Could not find song for id {}", by_name.song.id) - })? - .lookup_mut(difficulty) - .ok_or_else(|| { - format!( - "Could not find difficulty {:?} for song {}", - difficulty, by_name.song.title - ) - })?; + .lookup_mut(by_name.0.id)? + .lookup_mut(difficulty)?; if chart.jacket.is_none() { - if let Some(chart) = by_name.lookup_mut(difficulty) { - chart.jacket = Some(jacket_path.clone()); - }; + by_name.1.jacket = Some(jacket_path.clone()); chart.jacket = Some(jacket_path); } else { println!( "Jacket not detected for chart {} [{:?}]", - by_name.song.id, difficulty + by_name.0.id, difficulty ) }; } @@ -267,10 +248,10 @@ Title error: {} // }}} // {{{ Both succeeded (Ok(by_jacket), Ok(by_name)) => { - if by_name.song.id != by_jacket.song.id { + if by_name.0.id != by_jacket.0.id { println!( "Got diverging choices between '{:?}' and '{:?}'", - by_jacket.song.id, by_name.song.id + by_jacket.0.id, by_name.0.id ); }; @@ -278,16 +259,6 @@ Title error: {} } // }}} }; - // {{{ Build chart - let song = &cached_song.song; - let chart = cached_song.lookup(difficulty).ok_or_else(|| { - format!( - "Could not find difficulty {:?} for song {}", - difficulty, song.title - ) - })?; - // }}} - let edited = CreateReply::default() .reply(true) .content(format!("Image {}: reading score", i + 1)); @@ -315,7 +286,7 @@ Title error: {} // {{{ Build play let (score, maybe_fars, score_warning) = Score::resolve_ambiguities(score_possibilities, None, chart.note_count)?; - let play = CreatePlay::new(score, chart, &user) + let play = CreatePlay::new(score, &chart, &user) .with_attachment(file) .with_fars(maybe_fars) .save(&ctx.data()) @@ -323,15 +294,13 @@ Title error: {} // }}} // }}} // {{{ Deliver embed - let (mut embed, attachment) = play.to_embed(&song, &chart, i).await?; + let (mut embed, attachment) = play.to_embed(&song, &chart, i, None).await?; if let Some(warning) = score_warning { embed = embed.description(warning); } embeds.push(embed); - if let Some(attachment) = attachment { - attachments.push(attachment); - } + attachments.extend(attachment); // }}} } else { ctx.reply("One of the attached files is not an image!") @@ -350,10 +319,8 @@ Title error: {} handle.delete(ctx).await?; - let msg = CreateMessage::new().embeds(embeds); - ctx.channel_id() - .send_files(ctx.http(), attachments, msg) + .send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds)) .await?; } @@ -403,3 +370,66 @@ pub async fn delete( Ok(()) } // }}} +// {{{ Score show +/// Show scores given their ides +#[poise::command(prefix_command, slash_command)] +pub async fn show( + ctx: Context<'_>, + #[description = "Ids of score to show"] ids: Vec, +) -> Result<(), Error> { + if ids.len() == 0 { + ctx.reply("Empty ID list provided").await?; + return Ok(()); + } + + let lock = ctx.data().song_cache.lock().await; + + let mut embeds = Vec::with_capacity(ids.len()); + let mut attachments = Vec::with_capacity(ids.len()); + for (i, id) in ids.iter().enumerate() { + let res = query!( + " + SELECT + p.id,p.chart_id,p.user_id,p.score,p.zeta_score, + p.max_recall,p.created_at,p.far_notes, + u.discord_id + FROM plays p + JOIN users u ON p.user_id = u.id + WHERE p.id=? + ", + id + ) + .fetch_one(&ctx.data().db) + .await + .map_err(|_| format!("Could not find play with id {}", id))?; + + let play = Play { + id: res.id as u32, + chart_id: res.chart_id as u32, + user_id: res.user_id as u32, + score: Score(res.score as u32), + zeta_score: Score(res.zeta_score as u32), + max_recall: res.max_recall.map(|r| r as u32), + far_notes: res.far_notes.map(|r| r as u32), + created_at: res.created_at, + discord_attachment_id: None, + creation_ptt: None, + creation_zeta_ptt: None, + }; + + let user = discord_it_to_discord_user(&ctx, &res.discord_id).await?; + + let (song, chart) = lock.lookup_chart(play.chart_id)?; + let (embed, attachment) = play.to_embed(song, chart, i, Some(&user)).await?; + + embeds.push(embed); + attachments.extend(attachment); + } + + ctx.channel_id() + .send_files(ctx.http(), attachments, CreateMessage::new().embeds(embeds)) + .await?; + + Ok(()) +} +// }}} diff --git a/src/commands/stats.rs b/src/commands/stats.rs new file mode 100644 index 0000000..ee13a02 --- /dev/null +++ b/src/commands/stats.rs @@ -0,0 +1,245 @@ +use std::io::Cursor; + +use chrono::{DateTime, NaiveDateTime}; +use image::{ImageBuffer, Rgb}; +use plotters::{ + backend::{BitMapBackend, PixelFormat, RGBPixel}, + chart::{ChartBuilder, LabelAreaPosition}, + drawing::IntoDrawingArea, + element::Circle, + series::LineSeries, + style::{ + text_anchor::{HPos, Pos, VPos}, + Color, FontTransform, IntoFont, TextStyle, BLUE, WHITE, + }, +}; +use poise::{ + serenity_prelude::{CreateAttachment, CreateMessage}, + CreateReply, +}; +use sqlx::query_as; + +use crate::{ + chart::Difficulty, + context::{Context, Error}, + score::{guess_chart_name, DbPlay, Score}, + user::{discord_it_to_discord_user, User}, +}; + +// {{{ Stats +/// Stats display +#[poise::command( + prefix_command, + slash_command, + subcommands("chart"), + subcommand_required +)] +pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} +// }}} +// {{{ Chart +/// Chart-related stats +#[poise::command( + prefix_command, + slash_command, + subcommands("best", "plot"), + subcommand_required +)] +pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} +// }}} +// {{{ Best score +/// Show the best score on a given chart +#[poise::command(prefix_command, slash_command)] +pub async fn best( + ctx: Context<'_>, + #[rest] + #[description = "Name of chart to show (difficulty at the end)"] + name: String, +) -> Result<(), Error> { + let user = match User::from_context(&ctx).await { + Ok(user) => user, + Err(_) => { + ctx.say("You are not an user in my database, sorry!") + .await?; + return Ok(()); + } + }; + + let name = name.trim(); + let (name, difficulty) = name + .strip_suffix("PST") + .zip(Some(Difficulty::PST)) + .or_else(|| name.strip_suffix("PRS").zip(Some(Difficulty::PRS))) + .or_else(|| name.strip_suffix("FTR").zip(Some(Difficulty::FTR))) + .or_else(|| name.strip_suffix("ETR").zip(Some(Difficulty::ETR))) + .or_else(|| name.strip_suffix("BYD").zip(Some(Difficulty::BYD))) + .unwrap_or((&name, Difficulty::FTR)); + + let (song, chart) = guess_chart_name(name, &ctx.data().song_cache, difficulty).await?; + + let play = query_as!( + DbPlay, + " + SELECT * FROM plays + WHERE user_id=? + AND chart_id=? + ORDER BY score DESC + ", + user.id, + chart.id + ) + .fetch_one(&ctx.data().db) + .await + .map_err(|_| format!("Could not find any scores for chart"))? + .to_play(); + + let (embed, attachment) = play + .to_embed( + &song, + &chart, + 0, + Some(&discord_it_to_discord_user(&ctx, &user.discord_id).await?), + ) + .await?; + + ctx.channel_id() + .send_files(ctx.http(), attachment, CreateMessage::new().embed(embed)) + .await?; + + Ok(()) +} +// }}} +// Score plot +/// Show the best score on a given chart +#[poise::command(prefix_command, slash_command)] +pub async fn plot( + ctx: Context<'_>, + #[rest] + #[description = "Name of chart to show (difficulty at the end)"] + name: String, +) -> Result<(), Error> { + let user = match User::from_context(&ctx).await { + Ok(user) => user, + Err(_) => { + ctx.say("You are not an user in my database, sorry!") + .await?; + return Ok(()); + } + }; + + let name = name.trim(); + let (name, difficulty) = name + .strip_suffix("PST") + .zip(Some(Difficulty::PST)) + .or_else(|| name.strip_suffix("PRS").zip(Some(Difficulty::PRS))) + .or_else(|| name.strip_suffix("FTR").zip(Some(Difficulty::FTR))) + .or_else(|| name.strip_suffix("ETR").zip(Some(Difficulty::ETR))) + .or_else(|| name.strip_suffix("BYD").zip(Some(Difficulty::BYD))) + .unwrap_or((&name, Difficulty::FTR)); + + let (song, chart) = guess_chart_name(name, &ctx.data().song_cache, difficulty).await?; + + let plays = query_as!( + DbPlay, + " + SELECT * FROM plays + WHERE user_id=? + AND chart_id=? + ORDER BY created_at ASC + ", + user.id, + chart.id + ) + .fetch_all(&ctx.data().db) + .await?; + + if plays.len() == 0 { + ctx.reply("No plays found").await?; + return Ok(()); + } + + let min_time = plays.iter().map(|p| p.created_at).min().unwrap(); + let max_time = plays.iter().map(|p| p.created_at).max().unwrap(); + let mut min_score = plays.iter().map(|p| p.score).min().unwrap(); + + if min_score > 9_900_000 { + min_score = 9_800_000; + } else if min_score > 9_800_000 { + min_score = 9_800_000; + } else if min_score > 9_500_000 { + min_score = 9_500_000; + } else { + min_score = 9_000_000 + }; + + let max_score = 10_010_000; + let width = 1024; + let height = 768; + + let mut buffer = vec![u8::MAX; RGBPixel::PIXEL_SIZE * (width * height) as usize]; + + { + let mut root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area(); + + let mut chart = ChartBuilder::on(&root) + .margin(25) + .caption( + format!("{} [{:?}]", song.title, chart.difficulty), + ("sans-serif", 40), + ) + .set_label_area_size(LabelAreaPosition::Left, 100) + .set_label_area_size(LabelAreaPosition::Bottom, 40) + .build_cartesian_2d( + min_time.and_utc().timestamp_millis()..max_time.and_utc().timestamp_millis(), + min_score..max_score, + )?; + + chart + .configure_mesh() + .light_line_style(WHITE) + .y_label_formatter(&|s| format!("{}", Score(*s as u32))) + .y_desc("Score") + .x_label_formatter(&|d| { + format!( + "{}", + DateTime::from_timestamp_millis(*d).unwrap().date_naive() + ) + }) + .y_label_style(TextStyle::from(("sans-serif", 20).into_font())) + .x_label_style(TextStyle::from(("sans-serif", 20).into_font())) + .draw()?; + + let mut points: Vec<_> = plays + .iter() + .map(|play| (play.created_at.and_utc().timestamp_millis(), play.score)) + .collect(); + + points.sort(); + points.dedup(); + + chart.draw_series(LineSeries::new(points.iter().map(|(t, s)| (*t, *s)), &BLUE))?; + + chart.draw_series( + points + .iter() + .map(|(t, s)| Circle::new((*t, *s), 3, BLUE.filled())), + )?; + + root.present()?; + } + + let image: ImageBuffer, _> = ImageBuffer::from_raw(width, height, buffer).unwrap(); + + let mut buffer = Vec::new(); + let mut cursor = Cursor::new(&mut buffer); + image.write_to(&mut cursor, image::ImageFormat::Png)?; + + let reply = CreateReply::default().attachment(CreateAttachment::bytes(buffer, "plot.png")); + ctx.send(reply).await?; + + Ok(()) +} +// diff --git a/src/context.rs b/src/context.rs index 3538d7d..9d50d8a 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,9 +1,7 @@ -use std::{ - path::PathBuf, - sync::{Arc, Mutex}, -}; +use std::{path::PathBuf, sync::Arc}; use sqlx::SqlitePool; +use tokio::sync::Mutex; use crate::{chart::SongCache, jacket::JacketCache}; diff --git a/src/main.rs b/src/main.rs index 9c46cf1..21dcb03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ #![warn(clippy::str_to_string)] #![feature(iter_map_windows)] #![feature(let_chains)] +#![feature(async_closure)] mod chart; mod commands; @@ -9,7 +10,6 @@ mod jacket; mod score; mod user; -use chart::Difficulty; use context::{Error, UserContext}; use poise::serenity_prelude::{self as serenity}; use sqlx::sqlite::SqlitePoolOptions; @@ -38,7 +38,11 @@ async fn main() { // {{{ Poise options let options = poise::FrameworkOptions { - commands: vec![commands::help(), commands::score()], + commands: vec![ + commands::score::help(), + commands::score::score(), + commands::stats::stats(), + ], prefix_options: poise::PrefixFrameworkOptions { stripped_dynamic_prefix: Some(|_ctx, message, _user_ctx| { Box::pin(async { diff --git a/src/score.rs b/src/score.rs index 2ccf0d4..1810f42 100644 --- a/src/score.rs +++ b/src/score.rs @@ -1,22 +1,600 @@ #![allow(dead_code)] -use std::{ - fmt::Display, - io::Cursor, - sync::{Mutex, OnceLock}, -}; +use std::fmt::Display; +use std::io::Cursor; +use std::str::FromStr; +use std::sync::OnceLock; use edit_distance::edit_distance; use image::{imageops::FilterType, DynamicImage, GenericImageView}; use num::{traits::Euclid, Rational64}; -use poise::serenity_prelude::{Attachment, AttachmentId, CreateAttachment, CreateEmbed}; -use tesseract::{PageSegMode, Tesseract}; - -use crate::{ - chart::{CachedSong, Chart, Difficulty, Song, SongCache}, - context::{Error, UserContext}, - user::User, +use poise::serenity_prelude::{ + Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp, }; +use tesseract::{PageSegMode, Tesseract}; +use tokio::sync::Mutex; +use crate::chart::{Chart, Difficulty, Song, SongCache}; +use crate::context::{Error, UserContext}; +use crate::user::User; + +// {{{ Score +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Score(pub u32); + +impl Score { + // {{{ Score analysis + // {{{ Mini getters + #[inline] + pub fn to_zeta(self, note_count: u32) -> Score { + self.analyse(note_count).0 + } + + #[inline] + pub fn shinies(self, note_count: u32) -> u32 { + self.analyse(note_count).1 + } + + #[inline] + pub fn units(self, note_count: u32) -> u32 { + self.analyse(note_count).2 + } + // }}} + + #[inline] + pub fn increment(note_count: u32) -> Rational64 { + Rational64::new_raw(5_000_000, note_count as i64).reduced() + } + + /// Remove the contribution made by shinies to a score. + #[inline] + pub fn forget_shinies(self, note_count: u32) -> Self { + Self( + (Self::increment(note_count) * Rational64::from_integer(self.units(note_count) as i64)) + .floor() + .to_integer() as u32, + ) + } + + /// Compute a score without making a distinction between shinies and pures. That is, the given + /// value for `pures` must refer to the sum of `pure` and `shiny` notes. + /// + /// This is the simplest way to compute a score, and is useful for error analysis. + #[inline] + pub fn compute_naive(note_count: u32, pures: u32, fars: u32) -> Self { + Self( + (Self::increment(note_count) * Rational64::from_integer((2 * pures + fars) as i64)) + .floor() + .to_integer() as u32, + ) + } + + /// Returns the zeta score, the number of shinies, and the number of score units. + /// + /// Pure (and higher) notes reward two score units, far notes reward one, and lost notes reward + /// none. + pub fn analyse(self, note_count: u32) -> (Score, u32, u32) { + // Smallest possible difference between (zeta-)scores + let increment = Self::increment(note_count); + let zeta_increment = Rational64::new_raw(2_000_000, note_count as i64).reduced(); + + let score = Rational64::from_integer(self.0 as i64); + let score_units = (score / increment).floor(); + + let non_shiny_score = (score_units * increment).floor(); + let shinies = score - non_shiny_score; + + let zeta_score_units = Rational64::from_integer(2) * score_units + shinies; + let zeta_score = Score((zeta_increment * zeta_score_units).floor().to_integer() as u32); + + ( + zeta_score, + shinies.to_integer() as u32, + score_units.to_integer() as u32, + ) + } + // }}} + // {{{ Score => Play rating + #[inline] + pub fn play_rating(self, chart_constant: u32) -> i32 { + chart_constant as i32 + + if self.0 >= 10_000_000 { + 200 + } else if self.0 >= 9_800_000 { + 100 + (self.0 as i32 - 9_800_000) / 2_000 + } else { + (self.0 as i32 - 9_500_000) / 3_000 + } + } + // }}} + // {{{ Score => grade + #[inline] + // TODO: Perhaps make an enum for this + pub fn grade(self) -> &'static str { + let score = self.0; + if score > 9900000 { + "EX+" + } else if score > 9800000 { + "EX" + } else if score > 9500000 { + "AA" + } else if score > 9200000 { + "A" + } else if score > 8900000 { + "B" + } else if score > 8600000 { + "C" + } else { + "D" + } + } + // }}} + // {{{ Scores & Distribution => score + pub fn resolve_ambiguities( + scores: Vec, + read_distribution: Option<(u32, u32, u32)>, + note_count: u32, + ) -> Result<(Score, Option, Option<&'static str>), Error> { + if scores.len() == 0 { + return Err("No scores in list to disambiguate from.")?; + } + + let mut no_shiny_scores: Vec<_> = scores + .iter() + .map(|score| score.forget_shinies(note_count)) + .collect(); + no_shiny_scores.sort(); + no_shiny_scores.dedup(); + + if let Some(read_distribution) = read_distribution { + let pures = read_distribution.0; + let fars = read_distribution.1; + let losts = read_distribution.2; + + // Compute score from note breakdown subpairs + let pf_score = Score::compute_naive(note_count, pures, fars); + let fl_score = Score::compute_naive(note_count, note_count - losts - fars, fars); + let lp_score = Score::compute_naive(note_count, pures, note_count - losts - pures); + + if no_shiny_scores.len() == 1 { + // {{{ Score is fixed, gotta figure out the exact distribution + let score = *scores.iter().max().unwrap(); + + // {{{ Look for consensus among recomputed scores + // Lemma: if two computed scores agree, then so will the third + let consensus_fars = if pf_score == fl_score { + Some(fars) + } else { + // Due to the above lemma, we know all three scores must be distinct by + // this point. + // + // Our strategy is to check which of the three scores agrees with the real + // score, and to then trust the `far` value that contributed to that pair. + let no_shiny_score = score.forget_shinies(note_count); + let pf_appears = no_shiny_score == pf_score; + let fl_appears = no_shiny_score == fl_score; + let lp_appears = no_shiny_score == lp_score; + + match (pf_appears, fl_appears, lp_appears) { + (true, false, false) => Some(fars), + (false, true, false) => Some(fars), + (false, false, true) => Some(note_count - pures - losts), + _ => None, + } + }; + // }}} + + if scores.len() == 0 { + Ok((score, consensus_fars, None)) + } else { + Ok((score, consensus_fars, Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) + } + + // }}} + } else { + // {{{ Score is not fixed, gotta figure out everything at once + // Some of the values in the note distribution are likely wrong (due to reading + // errors). To get around this, we take each pair from the triplet, compute the score + // it induces, and figure out if there's any consensus as to which value in the + // provided score list is the real one. + // + // Note that sometimes the note distribution cannot resolve any of the issues. This is + // usually the case when the disagreement comes from the number of shinies. + + // {{{ Look for consensus among recomputed scores + // Lemma: if two computed scores agree, then so will the third + let (trusted_pure_count, consensus_computed_score, consensus_fars) = if pf_score + == fl_score + { + (true, pf_score, fars) + } else { + // Due to the above lemma, we know all three scores must be distinct by + // this point. + // + // Our strategy is to check which of the three scores appear in the + // provided score list. + let pf_appears = no_shiny_scores.contains(&pf_score); + let fl_appears = no_shiny_scores.contains(&fl_score); + let lp_appears = no_shiny_scores.contains(&lp_score); + + match (pf_appears, fl_appears, lp_appears) { + (true, false, false) => (true, pf_score, fars), + (false, true, false) => (false, fl_score, fars), + (false, false, true) => (true, lp_score, note_count - pures - losts), + _ => Err(format!("Cannot disambiguate scores {:?}. Multiple disjoint note breakdown subpair scores appear on the possibility list", scores))? + } + }; + // }}} + // {{{ Collect all scores that agree with the consensus score. + let agreement: Vec<_> = scores + .iter() + .filter(|score| score.forget_shinies(note_count) == consensus_computed_score) + .filter(|score| { + let shinies = score.shinies(note_count); + shinies <= note_count && (!trusted_pure_count || shinies <= pures) + }) + .map(|v| *v) + .collect(); + // }}} + // {{{ Case 1: Disagreement in the amount of shinies! + if agreement.len() > 1 { + let agreement_shiny_amounts: Vec<_> = + agreement.iter().map(|v| v.shinies(note_count)).collect(); + + println!( + "Shiny count disagreement. Possible scores: {:?}. Possible shiny amounts: {:?}, Read distribution: {:?}", + scores, agreement_shiny_amounts, read_distribution + ); + + let msg = Some( + "Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!" + ); + + Ok(( + agreement.into_iter().max().unwrap(), + Some(consensus_fars), + msg, + )) + // }}} + // {{{ Case 2: Total agreement! + } else if agreement.len() == 1 { + Ok((agreement[0], Some(consensus_fars), None)) + // }}} + // {{{ Case 3: No agreement! + } else { + Err(format!("Could not disambiguate between possible scores {:?}. Note distribution does not agree with any possibility, leading to a score of {:?}.", scores, consensus_computed_score))? + } + // }}} + // }}} + } + } else { + if no_shiny_scores.len() == 1 { + if scores.len() == 1 { + Ok((scores[0], None, None)) + } else { + Ok((scores.into_iter().max().unwrap(), None, Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) + } + } else { + Err("Cannot disambiguate between more than one score without a note distribution.")? + } + } + } + // }}} +} + +impl Display for Score { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let score = self.0; + write!( + f, + "{}'{:0>3}'{:0>3}", + score / 1000000, + (score / 1000) % 1000, + score % 1000 + ) + } +} +// }}} +// {{{ Plays +// {{{ Create play +#[derive(Debug, Clone)] +pub struct CreatePlay { + chart_id: u32, + user_id: u32, + discord_attachment_id: Option, + + // Actual score data + score: Score, + zeta_score: Score, + + // Optional score details + max_recall: Option, + far_notes: Option, + + // Creation data + creation_ptt: Option, + creation_zeta_ptt: Option, +} + +impl CreatePlay { + #[inline] + pub fn new(score: Score, chart: &Chart, user: &User) -> Self { + Self { + chart_id: chart.id, + user_id: user.id, + discord_attachment_id: None, + score, + zeta_score: score.to_zeta(chart.note_count as u32), + max_recall: None, + far_notes: None, + // TODO: populate these + creation_ptt: None, + creation_zeta_ptt: None, + } + } + + #[inline] + pub fn with_attachment(mut self, attachment: &Attachment) -> Self { + self.discord_attachment_id = Some(attachment.id); + self + } + + #[inline] + pub fn with_fars(mut self, far_count: Option) -> Self { + self.far_notes = far_count; + self + } + + // {{{ Save + pub async fn save(self, ctx: &UserContext) -> Result { + let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64); + let play = sqlx::query!( + " + INSERT INTO plays( + user_id,chart_id,discord_attachment_id, + score,zeta_score,max_recall,far_notes + ) + VALUES(?,?,?,?,?,?,?) + RETURNING id, created_at + ", + self.user_id, + self.chart_id, + attachment_id, + self.score.0, + self.zeta_score.0, + self.max_recall, + self.far_notes + ) + .fetch_one(&ctx.db) + .await?; + + Ok(Play { + id: play.id as u32, + created_at: play.created_at, + chart_id: self.chart_id, + user_id: self.user_id, + discord_attachment_id: self.discord_attachment_id, + score: self.score, + zeta_score: self.zeta_score, + max_recall: self.max_recall, + far_notes: self.far_notes, + creation_ptt: self.creation_ptt, + creation_zeta_ptt: self.creation_zeta_ptt, + }) + } + // }}} +} +// }}} +// {{{ DbPlay +/// Version of `Play` matching the format sqlx expects +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct DbPlay { + pub id: i64, + pub chart_id: i64, + pub user_id: i64, + pub discord_attachment_id: Option, + pub score: i64, + pub zeta_score: i64, + pub max_recall: Option, + pub far_notes: Option, + pub created_at: chrono::NaiveDateTime, + pub creation_ptt: Option, + pub creation_zeta_ptt: Option, +} + +impl DbPlay { + #[inline] + pub fn to_play(self) -> Play { + Play { + id: self.id as u32, + chart_id: self.chart_id as u32, + user_id: self.user_id as u32, + score: Score(self.score as u32), + zeta_score: Score(self.zeta_score as u32), + max_recall: self.max_recall.map(|r| r as u32), + far_notes: self.far_notes.map(|r| r as u32), + created_at: self.created_at, + discord_attachment_id: self + .discord_attachment_id + .and_then(|s| AttachmentId::from_str(&s).ok()), + creation_ptt: self.creation_ptt.map(|r| r as u32), + creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as u32), + } + } +} +// }}} +// {{{ Play +#[derive(Debug, Clone)] +pub struct Play { + pub id: u32, + pub chart_id: u32, + pub user_id: u32, + pub discord_attachment_id: Option, + + // Actual score data + pub score: Score, + pub zeta_score: Score, + + // Optional score details + pub max_recall: Option, + pub far_notes: Option, + + // Creation data + pub created_at: chrono::NaiveDateTime, + pub creation_ptt: Option, + pub creation_zeta_ptt: Option, +} + +impl Play { + // {{{ Play => distribution + pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> { + if let Some(fars) = self.far_notes { + let (_, shinies, units) = self.score.analyse(note_count); + let (pures, rem) = (units - fars).div_rem_euclid(&2); + if rem == 1 { + println!("The impossible happened: got an invalid amount of far notes!"); + return None; + } + + let lost = note_count - fars - pures; + let non_max_pures = pures - shinies; + Some((shinies, non_max_pures, fars, lost)) + } else { + None + } + } + // }}} + // {{{ Play => status + #[inline] + pub fn status(&self, chart: &Chart) -> Option { + let score = self.score.0; + if score >= 10_000_000 { + // Prevent subtracting with overflow + if score > chart.note_count + 10_000_000 { + return None; + } + + let non_max_pures = chart.note_count + 10_000_000 - score; + if non_max_pures == 0 { + Some("MPM".to_string()) + } else { + Some(format!("PM (-{})", non_max_pures)) + } + } else if let Some(distribution) = self.distribution(chart.note_count) { + // if no lost notes... + if distribution.3 == 0 { + Some(format!("FR (-{}/-{})", distribution.1, distribution.2)) + } else { + Some(format!( + "C (-{}/-{}/-{})", + distribution.1, distribution.2, distribution.3 + )) + } + } else { + None + } + } + // }}} + // {{{ Play to embed + /// Creates a discord embed for this play. + /// + /// The `index` variable is only used to create distinct filenames. + pub async fn to_embed( + &self, + song: &Song, + chart: &Chart, + index: usize, + author: Option<&poise::serenity_prelude::User>, + ) -> Result<(CreateEmbed, Option), Error> { + let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index); + let icon_attachement = match &chart.jacket { + Some(path) => Some( + CreateAttachment::file(&tokio::fs::File::open(path).await?, &attachement_name) + .await?, + ), + None => None, + }; + + let mut embed = CreateEmbed::default() + .title(format!( + "{} [{:?} {}]", + &song.title, chart.difficulty, chart.level + )) + .field("Score", format!("{} (+?)", self.score), true) + .field( + "Rating", + format!( + "{:.2} (+?)", + (self.score.play_rating(chart.chart_constant)) as f32 / 100. + ), + true, + ) + .field("Grade", self.score.grade(), true) + .field("ζ-Score", format!("{} (+?)", self.zeta_score), true) + .field( + "ζ-Rating", + format!( + "{:.2} (+?)", + (self.zeta_score.play_rating(chart.chart_constant)) as f32 / 100. + ), + true, + ) + .field("ζ-Grade", self.zeta_score.grade(), true) + .field( + "Status", + self.status(chart).unwrap_or("?".to_string()), + true, + ) + .field("Max recall", "?", true) + .field("Id", format!("{}", self.id), true); + + if icon_attachement.is_some() { + embed = embed.thumbnail(format!("attachment://{}", &attachement_name)); + } + + if let Some(user) = author { + let mut embed_author = CreateEmbedAuthor::new(&user.name); + if let Some(url) = user.avatar_url() { + embed_author = embed_author.icon_url(url); + } + + embed = embed + .timestamp(Timestamp::from_millis( + self.created_at.and_utc().timestamp_millis(), + )?) + .author(embed_author); + } + + Ok((embed, icon_attachement)) + } + // }}} +} +// }}} +// {{{ Tests +#[cfg(test)] +mod score_tests { + use super::*; + + #[test] + fn zeta_score_consistent_with_pms() { + // note counts + for note_count in 200..=2000 { + for shiny_count in 0..=note_count { + let score = Score(10000000 + shiny_count); + let zeta_score_units = 4 * (note_count - shiny_count) + 5 * shiny_count; + let (zeta_score, computed_shiny_count, units) = score.analyse(note_count); + let expected_zeta_score = Rational64::from_integer(zeta_score_units as i64) + * Rational64::new_raw(2000000, note_count as i64).reduced(); + + assert_eq!(zeta_score, Score(expected_zeta_score.to_integer() as u32)); + assert_eq!(computed_shiny_count, shiny_count); + assert_eq!(units, 2 * note_count); + } + } + } +} +// }}} +// }}} +// {{{ Image processing helpers // {{{ ImageDimensions #[derive(Debug, Clone, Copy)] pub struct ImageDimensions { @@ -265,522 +843,61 @@ pub fn jacket_rects() -> &'static [RelativeRect] { } // }}} // }}} -// {{{ Score -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct Score(pub u32); +// }}} +// {{{ Recognise chart name +pub async fn guess_chart_name( + raw_text: &str, + cache: &Mutex, + difficulty: Difficulty, +) -> Result<(Song, Chart), Error> { + let raw_text = raw_text.trim(); // not quite raw 🤔 + let mut text: &str = &raw_text.to_lowercase(); -impl Score { - // {{{ Score analysis - // {{{ Mini getters - #[inline] - pub fn to_zeta(self, note_count: u32) -> Score { - self.analyse(note_count).0 - } + let lock = cache.lock().await; + let (song, chart) = loop { + let close_enough: Vec<_> = lock + .songs() + .filter_map(|item| Some((&item.song, item.lookup(difficulty).ok()?))) + .map(|(song, chart)| { + let song_title = song.title.to_lowercase(); + let shortest_len = Ord::min(song_title.len(), text.len()); + let mut smallest_distance = edit_distance(&text, &song_title); - #[inline] - pub fn shinies(self, note_count: u32) -> u32 { - self.analyse(note_count).1 - } - - #[inline] - pub fn units(self, note_count: u32) -> u32 { - self.analyse(note_count).2 - } - // }}} - - #[inline] - pub fn increment(note_count: u32) -> Rational64 { - Rational64::new_raw(5_000_000, note_count as i64).reduced() - } - - /// Remove the contribution made by shinies to a score. - #[inline] - pub fn forget_shinies(self, note_count: u32) -> Self { - Self( - (Self::increment(note_count) * Rational64::from_integer(self.units(note_count) as i64)) - .floor() - .to_integer() as u32, - ) - } - - /// Compute a score without making a distinction between shinies and pures. That is, the given - /// value for `pures` must refer to the sum of `pure` and `shiny` notes. - /// - /// This is the simplest way to compute a score, and is useful for error analysis. - #[inline] - pub fn compute_naive(note_count: u32, pures: u32, fars: u32) -> Self { - Self( - (Self::increment(note_count) * Rational64::from_integer((2 * pures + fars) as i64)) - .floor() - .to_integer() as u32, - ) - } - - /// Returns the zeta score, the number of shinies, and the number of score units. - /// - /// Pure (and higher) notes reward two score units, far notes reward one, and lost notes reward - /// none. - pub fn analyse(self, note_count: u32) -> (Score, u32, u32) { - // Smallest possible difference between (zeta-)scores - let increment = Self::increment(note_count); - let zeta_increment = Rational64::new_raw(2_000_000, note_count as i64).reduced(); - - let score = Rational64::from_integer(self.0 as i64); - let score_units = (score / increment).floor(); - - let non_shiny_score = (score_units * increment).floor(); - let shinies = score - non_shiny_score; - - let zeta_score_units = Rational64::from_integer(2) * score_units + shinies; - let zeta_score = Score((zeta_increment * zeta_score_units).floor().to_integer() as u32); - - ( - zeta_score, - shinies.to_integer() as u32, - score_units.to_integer() as u32, - ) - } - // }}} - // {{{ Score => Play rating - #[inline] - pub fn play_rating(self, chart_constant: u32) -> i32 { - chart_constant as i32 - + if self.0 >= 10_000_000 { - 200 - } else if self.0 >= 9_800_000 { - 100 + (self.0 as i32 - 9_800_000) / 2_000 - } else { - (self.0 as i32 - 9_500_000) / 3_000 - } - } - // }}} - // {{{ Score => grade - #[inline] - // TODO: Perhaps make an enum for this - pub fn grade(self) -> &'static str { - let score = self.0; - if score > 9900000 { - "EX+" - } else if score > 9800000 { - "EX" - } else if score > 9500000 { - "AA" - } else if score > 9200000 { - "A" - } else if score > 8900000 { - "B" - } else if score > 8600000 { - "C" - } else { - "D" - } - } - // }}} - // {{{ Scores & Distribution => score - pub fn resolve_ambiguities( - scores: Vec, - read_distribution: Option<(u32, u32, u32)>, - note_count: u32, - ) -> Result<(Score, Option, Option<&'static str>), Error> { - if scores.len() == 0 { - return Err("No scores in list to disambiguate from.")?; - } - - let mut no_shiny_scores: Vec<_> = scores - .iter() - .map(|score| score.forget_shinies(note_count)) - .collect(); - no_shiny_scores.sort(); - no_shiny_scores.dedup(); - - if let Some(read_distribution) = read_distribution { - let pures = read_distribution.0; - let fars = read_distribution.1; - let losts = read_distribution.2; - - // Compute score from note breakdown subpairs - let pf_score = Score::compute_naive(note_count, pures, fars); - let fl_score = Score::compute_naive(note_count, note_count - losts - fars, fars); - let lp_score = Score::compute_naive(note_count, pures, note_count - losts - pures); - - // {{{ Score is fixed, gotta figure out the exact distribution - if no_shiny_scores.len() == 1 { - let score = *scores.iter().max().unwrap(); - - // {{{ Look for consensus among recomputed scores - // Lemma: if two computed scores agree, then so will the third - let consensus_fars = if pf_score == fl_score { - Some(fars) - } else { - // Due to the above lemma, we know all three scores must be distinct by - // this point. - // - // Our strategy is to check which of the three scores agrees with the real - // score, and to then trust the `far` value that contributed to that pair. - let no_shiny_score = score.forget_shinies(note_count); - let pf_appears = no_shiny_score == pf_score; - let fl_appears = no_shiny_score == fl_score; - let lp_appears = no_shiny_score == lp_score; - - match (pf_appears, fl_appears, lp_appears) { - (true, false, false) => Some(fars), - (false, true, false) => Some(fars), - (false, false, true) => Some(note_count - pures - losts), - _ => None, - } - }; - // }}} - - if scores.len() == 0 { - Ok((score, consensus_fars, None)) - } else { - Ok((score, consensus_fars, Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) - } - - // }}} - // {{{ Score is not fixed, gotta figure out everything at once - } else { - // Some of the values in the note distribution are likely wrong (due to reading - // errors). To get around this, we take each pair from the triplet, compute the score - // it induces, and figure out if there's any consensus as to which value in the - // provided score list is the real one. - // - // Note that sometimes the note distribution cannot resolve any of the issues. This is - // usually the case when the disagreement comes from the number of shinies. - - // {{{ Look for consensus among recomputed scores - // Lemma: if two computed scores agree, then so will the third - let (trusted_pure_count, consensus_computed_score, consensus_fars) = if pf_score - == fl_score + if let Some(sliced) = &song_title.get(..shortest_len) + && text.len() >= 6 { - (true, pf_score, fars) - } else { - // Due to the above lemma, we know all three scores must be distinct by - // this point. - // - // Our strategy is to check which of the three scores appear in the - // provided score list. - let pf_appears = no_shiny_scores.contains(&pf_score); - let fl_appears = no_shiny_scores.contains(&fl_score); - let lp_appears = no_shiny_scores.contains(&lp_score); - - match (pf_appears, fl_appears, lp_appears) { - (true, false, false) => (true, pf_score, fars), - (false, true, false) => (false, fl_score, fars), - (false, false, true) => (true, lp_score, note_count - pures - losts), - _ => Err(format!("Cannot disambiguate scores {:?}. Multiple disjoint note breakdown subpair scores appear on the possibility list", scores))? - } - }; - // }}} - // {{{ Collect all scores that agree with the consensus score. - let agreement: Vec<_> = scores - .iter() - .filter(|score| score.forget_shinies(note_count) == consensus_computed_score) - .filter(|score| { - let shinies = score.shinies(note_count); - shinies <= note_count && (!trusted_pure_count || shinies <= pures) - }) - .map(|v| *v) - .collect(); - // }}} - // {{{ Case 1: Disagreement in the amount of shinies! - if agreement.len() > 1 { - let agreement_shiny_amounts: Vec<_> = - agreement.iter().map(|v| v.shinies(note_count)).collect(); - - println!( - "Shiny count disagreement. Possible scores: {:?}. Possible shiny amounts: {:?}, Read distribution: {:?}", - scores, agreement_shiny_amounts, read_distribution - ); - - Ok((agreement.into_iter().max().unwrap(), Some(consensus_fars), - Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) - - // }}} - // {{{ Case 2: Total agreement! - } else if agreement.len() == 1 { - Ok((agreement[0], Some(consensus_fars), None)) - // }}} - // {{{ Case 3: No agreement! - } else { - Err(format!("Could not disambiguate between possible scores {:?}. Note distribution does not agree with any possibility, leading to a score of {:?}.", scores, consensus_computed_score))? - } - // }}} - } - // }}} - } else { - if no_shiny_scores.len() == 1 { - if scores.len() == 1 { - Ok((scores[0], None, None)) - } else { - Ok((scores.into_iter().max().unwrap(), None, Some("Due to a reading error, I could not make sure the shiny-amount I calculated is accurate!"))) + // We want to make this route super costly, which is why we multiply by 50 + smallest_distance = smallest_distance.min(50 * edit_distance(&text, sliced)); } + + (song, chart, smallest_distance) + }) + .filter(|(song, _, d)| *d < song.title.len() / 3) + .collect(); + + if close_enough.len() == 0 { + if text.len() == 1 { + Err(format!( + "Could not find match for chart name '{}'", + raw_text + ))?; } else { - Err("Cannot disambiguate between more than one score without a note distribution.")? + text = &text[..text.len() - 1]; } - } - } - // }}} -} - -impl Display for Score { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let score = self.0; - write!( - f, - "{}'{:0>3}'{:0>3}", - score / 1000000, - (score / 1000) % 1000, - score % 1000 - ) - } -} -// }}} -// {{{ Plays -// {{{ Create play -#[derive(Debug, Clone)] -pub struct CreatePlay { - chart_id: u32, - user_id: u32, - discord_attachment_id: Option, - - // Actual score data - score: Score, - zeta_score: Score, - - // Optional score details - max_recall: Option, - far_notes: Option, - - // Creation data - creation_ptt: Option, - creation_zeta_ptt: Option, -} - -impl CreatePlay { - #[inline] - pub fn new(score: Score, chart: &Chart, user: &User) -> Self { - Self { - chart_id: chart.id, - user_id: user.id, - discord_attachment_id: None, - score, - zeta_score: score.to_zeta(chart.note_count as u32), - max_recall: None, - far_notes: None, - // TODO: populate these - creation_ptt: None, - creation_zeta_ptt: None, - } - } - - #[inline] - pub fn with_attachment(mut self, attachment: &Attachment) -> Self { - self.discord_attachment_id = Some(attachment.id); - self - } - - #[inline] - pub fn with_fars(mut self, far_count: Option) -> Self { - self.far_notes = far_count; - self - } - - // {{{ Save - pub async fn save(self, ctx: &UserContext) -> Result { - let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64); - let play = sqlx::query!( - " - INSERT INTO plays( - user_id,chart_id,discord_attachment_id, - score,zeta_score,max_recall,far_notes - ) - VALUES(?,?,?,?,?,?,?) - RETURNING id, created_at - ", - self.user_id, - self.chart_id, - attachment_id, - self.score.0, - self.zeta_score.0, - self.max_recall, - self.far_notes - ) - .fetch_one(&ctx.db) - .await?; - - Ok(Play { - id: play.id as u32, - created_at: play.created_at, - chart_id: self.chart_id, - user_id: self.user_id, - discord_attachment_id: self.discord_attachment_id, - score: self.score, - zeta_score: self.zeta_score, - max_recall: self.max_recall, - far_notes: self.far_notes, - creation_ptt: self.creation_ptt, - creation_zeta_ptt: self.creation_zeta_ptt, - }) - } - // }}} -} -// }}} -// {{{ Play -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct Play { - id: u32, - chart_id: u32, - user_id: u32, - discord_attachment_id: Option, - - // Actual score data - score: Score, - zeta_score: Score, - - // Optional score details - max_recall: Option, - far_notes: Option, - - // Creation data - created_at: chrono::NaiveDateTime, - creation_ptt: Option, - creation_zeta_ptt: Option, -} - -impl Play { - // {{{ Play => distribution - pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> { - if let Some(fars) = self.far_notes { - let (_, shinies, units) = self.score.analyse(note_count); - let (pures, rem) = (units - fars).div_rem_euclid(&2); - if rem == 1 { - println!("The impossible happened: got an invalid amount of far notes!"); - return None; - } - - let lost = note_count - fars - pures; - let non_max_pures = pures - shinies; - Some((shinies, non_max_pures, fars, lost)) + } else if close_enough.len() == 1 { + break (close_enough[0].0, close_enough[0].1); } else { - None - } - } - // }}} - // {{{ Play => status - #[inline] - pub fn status(&self, chart: &Chart) -> Option { - let score = self.score.0; - if score >= 10_000_000 { - let non_max_pures = chart.note_count + 10_000_000 - score; - if non_max_pures == 0 { - Some("MPM".to_string()) - } else { - Some(format!("PM (-{})", non_max_pures)) - } - } else if let Some(distribution) = self.distribution(chart.note_count) { - // if no lost notes... - if distribution.3 == 0 { - Some(format!("FR (-{}/-{})", distribution.1, distribution.2)) - } else { - Some(format!( - "C (-{}/-{}/-{})", - distribution.1, distribution.2, distribution.3 - )) - } - } else { - None - } - } - // }}} - // {{{ Play to embed - /// Creates a discord embed for this play. - /// - /// The `index` variable is only used to create distinct filenames. - pub async fn to_embed( - &self, - song: &Song, - chart: &Chart, - index: usize, - ) -> Result<(CreateEmbed, Option), Error> { - let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index); - let icon_attachement = match &chart.jacket { - Some(path) => Some( - CreateAttachment::file(&tokio::fs::File::open(path).await?, &attachement_name) - .await?, - ), - None => None, + Err(format!( + "Name '{}' is too vague to choose a match", + raw_text + ))?; }; + }; - println!("{:?}", self.score.shinies(chart.note_count)); - - let mut embed = CreateEmbed::default() - .title(format!( - "{} [{:?} {}]", - &song.title, chart.difficulty, chart.level - )) - .field("Score", format!("{} (+?)", self.score), true) - .field( - "Rating", - format!( - "{:.2} (+?)", - (self.score.play_rating(chart.chart_constant)) as f32 / 100. - ), - true, - ) - .field("Grade", self.score.grade(), true) - .field("ζ-Score", format!("{} (+?)", self.zeta_score), true) - .field( - "ζ-Rating", - format!( - "{:.2} (+?)", - (self.zeta_score.play_rating(chart.chart_constant)) as f32 / 100. - ), - true, - ) - .field("ζ-Grade", self.zeta_score.grade(), true) - .field( - "Status", - self.status(chart).unwrap_or("?".to_string()), - true, - ) - .field("Max recall", "?", true) - .field("Id", format!("{}", self.id), true); - - if icon_attachement.is_some() { - embed = embed.thumbnail(format!("attachment://{}", &attachement_name)); - } - - Ok((embed, icon_attachement)) - } - // }}} + // NOTE: this will reallocate a few strings, but it is what it is + Ok((song.clone(), chart.clone())) } // }}} -// {{{ Tests -#[cfg(test)] -mod score_tests { - use super::*; - - #[test] - fn zeta_score_consistent_with_pms() { - // note counts - for note_count in 200..=2000 { - for shiny_count in 0..=note_count { - let score = Score(10000000 + shiny_count); - let zeta_score_units = 4 * (note_count - shiny_count) + 5 * shiny_count; - let (zeta_score, computed_shiny_count, units) = score.analyse(note_count); - let expected_zeta_score = Rational64::from_integer(zeta_score_units as i64) - * Rational64::new_raw(2000000, note_count as i64).reduced(); - - assert_eq!(zeta_score, Score(expected_zeta_score.to_integer() as u32)); - assert_eq!(computed_shiny_count, shiny_count); - assert_eq!(units, 2 * note_count); - } - } - } -} -// }}} -// }}} // {{{ Run OCR /// Caches a byte vector in order to prevent reallocation #[derive(Debug, Clone, Default)] @@ -964,11 +1081,12 @@ impl ImageCropper { } // }}} // {{{ Read song - pub fn read_song( + pub async fn read_song( &mut self, image: &DynamicImage, cache: &Mutex, - ) -> Result { + difficulty: Difficulty, + ) -> Result<(Song, Chart), Error> { self.crop_image_to_bytes( &image, RelativeRect::from_aspect_ratio(ImageDimensions::from_image(image), title_rects()) @@ -986,68 +1104,25 @@ impl ImageCropper { t = t.recognize()?; let raw_text: &str = &t.get_text()?; - let raw_text = raw_text.trim(); // not quite raw 🤔 - let mut text: &str = &raw_text.to_lowercase(); let conf = t.mean_text_conf(); if conf < 20 && conf != 0 { Err(format!( "Title text is not readable (confidence = {}, text = {}).", - conf, raw_text + conf, + raw_text.trim() ))?; } - let lock = cache.lock().map_err(|_| "Poisoned song cache")?; - let cached_song = loop { - let close_enough: Vec<_> = lock - .songs() - .map(|item| { - let song_title = item.song.title.to_lowercase(); - let shortest_len = Ord::min(song_title.len(), text.len()); - let mut smallest_distance = edit_distance(&text, &song_title); - - if let Some(sliced) = &song_title.get(..shortest_len) - && text.len() >= 6 - { - // We want to make this route super costly, which is why we multiply by 50 - smallest_distance = - smallest_distance.min(50 * edit_distance(&text, sliced)); - } - - (item, smallest_distance) - }) - .filter(|(item, d)| *d < item.song.title.len() / 3) - .collect(); - - if close_enough.len() == 0 { - if text.len() == 1 { - Err(format!( - "Could not find match for chart name '{}'", - raw_text - ))?; - } else { - text = &text[..text.len() - 1]; - } - } else if close_enough.len() == 1 { - break close_enough[0].0; - } else { - Err(format!( - "Name '{}' is too vague to choose a match", - raw_text - ))?; - }; - }; - - // NOTE: this will reallocate a few strings, but it is what it is - Ok(cached_song.clone()) + guess_chart_name(raw_text, cache, difficulty).await } // }}} // {{{ Read jacket - pub fn read_jacket<'a>( + pub async fn read_jacket<'a>( &mut self, ctx: &UserContext, image: &DynamicImage, - ) -> Result { + ) -> Result<(Song, Chart), Error> { let rect = RelativeRect::from_aspect_ratio(ImageDimensions::from_image(image), jacket_rects()) .ok_or_else(|| "Could not find jacket area in picture")? @@ -1063,16 +1138,11 @@ impl ImageCropper { Err("No known jacket looks like this")?; } - let song = ctx - .song_cache - .lock() - .map_err(|_| "Poisoned song cache")? - .lookup(*song_id) - .ok_or_else(|| format!("Could not find song with id {}", song_id))? - // NOTE: this will reallocate a few strings, but it is what it is - .clone(); + let lock = ctx.song_cache.lock().await; + let (song, chart) = lock.lookup_chart(*song_id)?; - Ok(song) + // NOTE: this will reallocate a few strings, but it is what it is + Ok((song.clone(), chart.clone())) } // }}} } diff --git a/src/user.rs b/src/user.rs index 11bea55..453c975 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,3 +1,7 @@ +use std::str::FromStr; + +use poise::serenity_prelude::UserId; + use crate::context::{Context, Error}; #[derive(Debug, Clone)] @@ -21,3 +25,14 @@ impl User { }) } } + +#[inline] +pub async fn discord_it_to_discord_user( + &ctx: &Context<'_>, + discord_id: &str, +) -> Result { + UserId::from_str(discord_id)? + .to_user(ctx.http()) + .await + .map_err(|e| e.into()) +}