diff --git a/src/arcaea/achievement.rs b/src/arcaea/achievement.rs new file mode 100644 index 0000000..0680097 --- /dev/null +++ b/src/arcaea/achievement.rs @@ -0,0 +1,408 @@ +use image::RgbaImage; +use sqlx::query; + +use crate::{ + assets::get_data_dir, + context::{Error, UserContext}, + user::User, +}; + +use super::{ + chart::{Difficulty, Level}, + play::get_best_plays, + score::{Grade, ScoringSystem}, +}; + +// {{{ Goal +#[derive(Debug, Clone, Copy)] +pub enum Goal { + /// PM X FTR<=charts + PMCount(usize), + /// PM a given number of packs + PMPacks(usize), + /// PM at least one song of each level up to a given one + PMRelay(Level), + /// Reach a given b30 ptt + PTT(u32), + /// Get a given grade or better on everything you own of a given level, + /// with a minum of X owned charts. + #[allow(dead_code)] + GradeEntireLevel(Grade, Level, usize), + /// Submit at least a given number of plays + SubmitPlays(usize), + /// PM the same song on all difficulties up to a given one + MultiDifficultyPM(Difficulty), +} + +impl Goal { + // {{{ Texture names + #[inline] + pub fn texture_name(&self) -> String { + match self { + Self::PMCount(count) => format!("pm_count_{count:0>3}"), + Self::PMPacks(count) => format!("pm_packs_{count:0>3}"), + Self::PMRelay(level) => format!("pm_relay_{level}"), + Self::PTT(min) => format!("ptt_min_{min:0>4}"), + Self::GradeEntireLevel(grade, level, _) => format!("grade_{grade}_level_{level}"), + Self::SubmitPlays(count) => format!("play_count_{count:0>6}"), + Self::MultiDifficultyPM(difficulty) => format!("multi_pm_{difficulty}"), + } + } + // }}} + // {{{ Difficulty + #[inline] + pub fn difficulty(&self) -> Difficulty { + use Difficulty::*; + use Grade::*; + use Level::*; + + match *self { + Self::PMCount(count) if count < 25 => PST, + Self::PMCount(count) if count < 100 => PRS, + Self::PMCount(count) if count < 200 => FTR, + Self::PMCount(count) if count < 350 => ETR, + Self::PMCount(_) => BYD, + Self::PMPacks(count) if count < 5 => PRS, + Self::PMPacks(count) if count < 10 => FTR, + Self::PMPacks(count) if count < 25 => ETR, + Self::PMPacks(_) => BYD, + Self::PMRelay(level) if level < Nine => PST, + Self::PMRelay(level) if level < Ten => PRS, + Self::PMRelay(level) if level < Eleven => FTR, + Self::PMRelay(level) if level < Twelve => ETR, + Self::PMRelay(_) => BYD, + Self::PTT(amount) if amount < 1100 => PST, + Self::PTT(amount) if amount < 1200 => PRS, + Self::PTT(amount) if amount < 1250 => FTR, + Self::PTT(amount) if amount < 1300 => ETR, + Self::PTT(_) => BYD, + Self::GradeEntireLevel(EXP, level, _) if level < Eight => PST, + Self::GradeEntireLevel(EX, level, _) if level < Nine => PST, + Self::GradeEntireLevel(EXP, level, _) if level < Nine => PRS, + Self::GradeEntireLevel(EX, level, _) if level < Ten => PRS, + Self::GradeEntireLevel(EXP, level, _) if level < Ten => FTR, + Self::GradeEntireLevel(EX, level, _) if level < Eleven => FTR, + Self::GradeEntireLevel(EXP, level, _) if level < Eleven => ETR, + Self::GradeEntireLevel(EX, level, _) if level < Twelve => ETR, + Self::GradeEntireLevel(EXP, _, _) => BYD, + Self::GradeEntireLevel(EX, _, _) => BYD, + Self::GradeEntireLevel(_, _, _) => PST, + Self::SubmitPlays(count) if count < 500 => PST, + Self::SubmitPlays(count) if count < 2500 => PRS, + Self::SubmitPlays(count) if count < 5000 => FTR, + Self::SubmitPlays(count) if count < 10000 => ETR, + Self::SubmitPlays(_) => BYD, + Self::MultiDifficultyPM(ETR) => FTR, + Self::MultiDifficultyPM(BYD) => FTR, + Self::MultiDifficultyPM(FTR) => PRS, + Self::MultiDifficultyPM(_) => PST, + } + } + // }}} +} +// }}} +// {{{ GoalStats +/// Stats collected in order to efficiently compute whether +/// a set of achievements were completed. +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct GoalStats { + pm_count: usize, + pmed_packs: usize, + peak_pm_relay: Option, + peak_ptt: u32, + per_level_lowest_grades: [(Grade, usize); Level::LEVELS.len()], + play_count: usize, + multi_difficulty_pm_table: [bool; Difficulty::DIFFICULTIES.len()], +} + +impl GoalStats { + pub async fn make( + ctx: &UserContext, + user: &User, + scoring_system: ScoringSystem, + ) -> Result { + let plays = get_best_plays( + &ctx.db, + &ctx.song_cache, + user, + scoring_system, + 0, + usize::MAX, + ) + .await??; + + // {{{ PM count + let pm_count = plays + .iter() + .filter(|(play, _, chart)| { + play.score(scoring_system).0 >= 10_000_000 && chart.difficulty >= Difficulty::FTR + }) + .count(); + // }}} + // {{{ Play count + let play_count = query!( + "SELECT count() as count FROM plays WHERE user_id=?", + user.id + ) + .fetch_one(&ctx.db) + .await? + .count as usize; + // }}} + // {{{ Peak ptt + let peak_ptt = { + let record = query!( + " + SELECT + max(creation_ptt) as standard, + max(creation_zeta_ptt) as ex + FROM plays + " + ) + .fetch_one(&ctx.db) + .await?; + match scoring_system { + ScoringSystem::Standard => record.standard, + ScoringSystem::EX => record.ex, + } + } + .ok_or_else(|| "No ptt history data found")? as u32; + // }}} + // {{{ Peak PM relay + let peak_pm_relay = { + let mut pm_checklist = [false; Level::LEVELS.len()]; + for (play, _, chart) in &plays { + if play.score(scoring_system).is_pm() { + pm_checklist[chart.level.to_index()] = true; + } + } + + pm_checklist + .into_iter() + .enumerate() + .find(|(_, has_pm)| !*has_pm) + .map_or(Some(Level::Twelve), |(i, _)| { + Level::LEVELS.get(i.checked_sub(1)?).copied() + }) + }; + // }}} + // {{{ Per level lowest grades + let mut per_level_lowest_grades = [(Grade::EXP, 0); Level::LEVELS.len()]; + for (play, _, chart) in plays { + let element = &mut per_level_lowest_grades[chart.level.to_index()]; + *element = ( + element.0.min(play.score(scoring_system).grade()), + element.1 + 1, + ); + } + // }}} + + Ok(GoalStats { + pm_count, + play_count, + peak_ptt, + peak_pm_relay, + per_level_lowest_grades, + pmed_packs: 0, + multi_difficulty_pm_table: [false; Difficulty::DIFFICULTIES.len()], + }) + } +} +// }}} +// {{{ Achievement +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct Achievement { + pub goal: Goal, + pub texture: &'static RgbaImage, +} + +impl Achievement { + pub fn new(goal: Goal) -> Self { + let texture_name = goal.texture_name(); + Self { + goal, + texture: Box::leak(Box::new( + image::open( + get_data_dir() + .join("achievements") + .join(format!("{texture_name}.png")), + ) + .unwrap_or_else(|_| { + panic!("Cannot read texture `{texture_name}` for achievement {goal:?}") + }) + .into_rgba8(), + )), + } + } +} + +// }}} +// {{{ Achievement towers +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct AchievementTower { + pub achievements: Vec, +} + +impl AchievementTower { + pub fn new(achievements: Vec) -> Self { + Self { achievements } + } +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct AchievementTowers { + pub towers: Vec, +} + +impl Default for AchievementTowers { + // {{{ Construct towers + fn default() -> Self { + use Difficulty::*; + use Goal::*; + use Grade::*; + use Level::*; + + // {{{ PM count tower + let pm_count_tower = AchievementTower::new(vec![ + Achievement::new(PMCount(1)), + Achievement::new(PMCount(5)), + Achievement::new(PMCount(10)), + Achievement::new(PMCount(20)), + Achievement::new(PMCount(30)), + Achievement::new(PMCount(40)), + Achievement::new(PMCount(50)), + Achievement::new(PMCount(75)), + Achievement::new(PMCount(100)), + Achievement::new(PMCount(125)), + Achievement::new(PMCount(150)), + Achievement::new(PMCount(175)), + Achievement::new(PMCount(200)), + Achievement::new(PMCount(250)), + Achievement::new(PMCount(300)), + Achievement::new(PMCount(350)), + Achievement::new(PMCount(400)), + ]); + // }}} + // {{{ PM pack tower + let pm_pack_tower = AchievementTower::new(vec![ + Achievement::new(PMPacks(1)), + Achievement::new(PMPacks(3)), + Achievement::new(PMPacks(5)), + Achievement::new(PMPacks(7)), + Achievement::new(PMPacks(10)), + Achievement::new(PMPacks(15)), + Achievement::new(PMPacks(20)), + Achievement::new(PMPacks(25)), + Achievement::new(PMPacks(30)), + Achievement::new(PMPacks(35)), + Achievement::new(PMPacks(40)), + Achievement::new(PMPacks(45)), + Achievement::new(PMPacks(50)), + ]); + // }}} + // {{{ PM relay tower + let pm_relay_tower = AchievementTower::new(vec![ + Achievement::new(PMRelay(Seven)), + Achievement::new(PMRelay(SevenP)), + Achievement::new(PMRelay(Eight)), + Achievement::new(PMRelay(EightP)), + Achievement::new(PMRelay(Nine)), + Achievement::new(PMRelay(NineP)), + Achievement::new(PMRelay(Ten)), + Achievement::new(PMRelay(TenP)), + Achievement::new(PMRelay(Eleven)), + Achievement::new(PMRelay(Twelve)), + ]); + // }}} + // {{{ PTT tower + let ptt_tower = AchievementTower::new(vec![ + Achievement::new(PTT(0800)), + Achievement::new(PTT(0900)), + Achievement::new(PTT(1000)), + Achievement::new(PTT(1050)), + Achievement::new(PTT(1100)), + Achievement::new(PTT(1125)), + Achievement::new(PTT(1150)), + Achievement::new(PTT(1200)), + Achievement::new(PTT(1210)), + Achievement::new(PTT(1220)), + Achievement::new(PTT(1230)), + Achievement::new(PTT(1240)), + Achievement::new(PTT(1250)), + Achievement::new(PTT(1260)), + Achievement::new(PTT(1270)), + Achievement::new(PTT(1280)), + Achievement::new(PTT(1290)), + Achievement::new(PTT(1300)), + ]); + // }}} + // {{{ EX(+) level tower + let ex_level_tower = AchievementTower::new(vec![ + Achievement::new(GradeEntireLevel(EX, Seven, 5)), + Achievement::new(GradeEntireLevel(EX, SevenP, 5)), + Achievement::new(GradeEntireLevel(EX, Eight, 10)), + Achievement::new(GradeEntireLevel(EX, EightP, 5)), + Achievement::new(GradeEntireLevel(EX, Nine, 20)), + Achievement::new(GradeEntireLevel(EX, NineP, 15)), + Achievement::new(GradeEntireLevel(EX, Ten, 15)), + Achievement::new(GradeEntireLevel(EX, TenP, 10)), + Achievement::new(GradeEntireLevel(EX, Eleven, 5)), + Achievement::new(GradeEntireLevel(EX, Twelve, 1)), + ]); + + let exp_level_tower = AchievementTower::new(vec![ + Achievement::new(GradeEntireLevel(EXP, Seven, 5)), + Achievement::new(GradeEntireLevel(EXP, SevenP, 5)), + Achievement::new(GradeEntireLevel(EXP, Eight, 10)), + Achievement::new(GradeEntireLevel(EXP, EightP, 5)), + Achievement::new(GradeEntireLevel(EXP, Nine, 20)), + Achievement::new(GradeEntireLevel(EXP, NineP, 15)), + Achievement::new(GradeEntireLevel(EXP, Ten, 15)), + Achievement::new(GradeEntireLevel(EXP, TenP, 10)), + Achievement::new(GradeEntireLevel(EXP, Eleven, 5)), + Achievement::new(GradeEntireLevel(EXP, Twelve, 1)), + ]); + // }}} + // {{{ Submit plays + let submit_plays_tower = AchievementTower::new(vec![ + Achievement::new(SubmitPlays(100)), + Achievement::new(SubmitPlays(250)), + Achievement::new(SubmitPlays(500)), + Achievement::new(SubmitPlays(1000)), + Achievement::new(SubmitPlays(2000)), + Achievement::new(SubmitPlays(3000)), + Achievement::new(SubmitPlays(4000)), + Achievement::new(SubmitPlays(5000)), + Achievement::new(SubmitPlays(7500)), + Achievement::new(SubmitPlays(10000)), + ]); + // }}} + // {{{ Multi-difficulty PM + let multi_difficulty_tower = AchievementTower::new(vec![ + Achievement::new(MultiDifficultyPM(PST)), + Achievement::new(MultiDifficultyPM(PRS)), + Achievement::new(MultiDifficultyPM(FTR)), + Achievement::new(MultiDifficultyPM(ETR)), + Achievement::new(MultiDifficultyPM(BYD)), + ]); + // }}} + + let towers = vec![ + pm_count_tower, + pm_pack_tower, + pm_relay_tower, + ptt_tower, + ex_level_tower, + exp_level_tower, + submit_plays_tower, + multi_difficulty_tower, + ]; + + Self { towers } + } + // }}} +} +// }}} diff --git a/src/arcaea/chart.rs b/src/arcaea/chart.rs index 5a8ae7e..0ca4a46 100644 --- a/src/arcaea/chart.rs +++ b/src/arcaea/chart.rs @@ -1,4 +1,4 @@ -use std::{num::NonZeroU16, path::PathBuf}; +use std::{fmt::Display, num::NonZeroU16, path::PathBuf}; use image::{ImageBuffer, Rgb}; use sqlx::SqlitePool; @@ -43,6 +43,16 @@ impl TryFrom for Difficulty { } } +impl Display for Difficulty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + Self::DIFFICULTY_SHORTHANDS[self.to_index()].to_lowercase() + ) + } +} + pub const DIFFICULTY_MENU_PIXEL_COLORS: [Color; Difficulty::DIFFICULTIES.len()] = [ Color::from_rgb_int(0xAAE5F7), Color::from_rgb_int(0xBFDD85), @@ -51,6 +61,79 @@ pub const DIFFICULTY_MENU_PIXEL_COLORS: [Color; Difficulty::DIFFICULTIES.len()] Color::from_rgb_int(0xF89AAC), ]; // }}} +// {{{ Level +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + Unknown, + One, + Two, + Three, + Four, + Five, + Six, + Seven, + SevenP, + Eight, + EightP, + Nine, + NineP, + Ten, + TenP, + Eleven, + Twelve, +} + +impl Level { + pub const LEVELS: [Self; 17] = [ + Self::Unknown, + Self::One, + Self::Two, + Self::Three, + Self::Four, + Self::Five, + Self::Six, + Self::Seven, + Self::SevenP, + Self::Eight, + Self::EightP, + Self::Nine, + Self::NineP, + Self::Ten, + Self::TenP, + Self::Eleven, + Self::Twelve, + ]; + + pub const LEVEL_STRINGS: [&'static str; 17] = [ + "?", "1", "2", "3", "4", "5", "6", "7", "7+", "8", "8+", "9", "9+", "10", "10+", "11", "12", + ]; + + #[inline] + pub fn to_index(self) -> usize { + self as usize + } +} + +impl Display for Level { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", Self::LEVEL_STRINGS[self.to_index()]) + } +} + +impl TryFrom for Level { + type Error = String; + + fn try_from(value: String) -> Result { + for (i, s) in Self::LEVEL_STRINGS.iter().enumerate() { + if value == **s { + return Ok(Self::LEVELS[i]); + } + } + + Err(format!("Cannot convert {} to a level", value)) + } +} +// }}} // {{{ Side #[derive(Debug, Clone, Copy)] pub enum Side { @@ -113,7 +196,7 @@ pub struct Chart { pub note_design: Option, pub difficulty: Difficulty, - pub level: String, // TODO: this could become an enum + pub level: Level, pub note_count: u32, pub chart_constant: u32, @@ -253,7 +336,7 @@ impl SongCache { song_id: chart.song_id as u32, shorthand: chart.shorthand, difficulty: Difficulty::try_from(chart.difficulty)?, - level: chart.level, + level: Level::try_from(chart.level)?, chart_constant: chart.chart_constant as u32, note_count: chart.note_count as u32, cached_jacket: None, diff --git a/src/arcaea/mod.rs b/src/arcaea/mod.rs index a86bb49..50dea45 100644 --- a/src/arcaea/mod.rs +++ b/src/arcaea/mod.rs @@ -1,3 +1,4 @@ +pub mod achievement; pub mod chart; pub mod jacket; pub mod play; diff --git a/src/arcaea/play.rs b/src/arcaea/play.rs index d75b39b..4421d89 100644 --- a/src/arcaea/play.rs +++ b/src/arcaea/play.rs @@ -4,7 +4,7 @@ use num::traits::Euclid; use poise::serenity_prelude::{ Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp, }; -use sqlx::{query_as, SqlitePool}; +use sqlx::{query, query_as, SqlitePool}; use crate::arcaea::chart::{Chart, Song}; use crate::context::{Error, UserContext}; @@ -17,7 +17,6 @@ use super::score::{Score, ScoringSystem}; #[derive(Debug, Clone)] pub struct CreatePlay { chart_id: u32, - user_id: u32, discord_attachment_id: Option, // Actual score data @@ -27,26 +26,18 @@ pub struct CreatePlay { // 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 { + pub fn new(score: Score, chart: &Chart) -> 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, } } @@ -69,8 +60,10 @@ impl CreatePlay { } // {{{ Save - pub async fn save(self, ctx: &UserContext) -> Result { + pub async fn save(self, ctx: &UserContext, user: &User) -> Result { let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64); + + // {{{ Save current data to play let play = sqlx::query!( " INSERT INTO plays( @@ -80,7 +73,7 @@ impl CreatePlay { VALUES(?,?,?,?,?,?,?) RETURNING id, created_at ", - self.user_id, + user.id, self.chart_id, attachment_id, self.score.0, @@ -90,19 +83,55 @@ impl CreatePlay { ) .fetch_one(&ctx.db) .await?; + // }}} + // {{{ Update creation ptt data + let creation_ptt = get_best_plays( + &ctx.db, + &ctx.song_cache, + user, + ScoringSystem::Standard, + 30, + 30, + ) + .await? + .ok() + .map(|plays| compute_b30_ptt(ScoringSystem::Standard, &plays)); + + let creation_zeta_ptt = + get_best_plays(&ctx.db, &ctx.song_cache, user, ScoringSystem::EX, 30, 30) + .await? + .ok() + .map(|plays| compute_b30_ptt(ScoringSystem::EX, &plays)); + + query!( + " + UPDATE plays + SET + creation_ptt=?, + creation_zeta_ptt=? + WHERE + id=? + ", + creation_ptt, + creation_zeta_ptt, + play.id + ) + .execute(&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, + user_id: 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, + creation_ptt, + creation_zeta_ptt, }) } // }}} @@ -140,8 +169,8 @@ impl DbPlay { 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), + creation_ptt: self.creation_ptt.map(|r| r as i32), + creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as i32), } } } @@ -167,11 +196,10 @@ pub struct Play { // Creation data pub created_at: chrono::NaiveDateTime, - #[allow(unused)] - pub creation_ptt: Option, - - #[allow(unused)] - pub creation_zeta_ptt: Option, + #[allow(dead_code)] + pub creation_ptt: Option, + #[allow(dead_code)] + pub creation_zeta_ptt: Option, } impl Play { diff --git a/src/arcaea/score.rs b/src/arcaea/score.rs index e9294db..5e49143 100644 --- a/src/arcaea/score.rs +++ b/src/arcaea/score.rs @@ -24,18 +24,18 @@ impl Default for ScoringSystem { // {{{ Grade #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Grade { - EXP, - EX, - AA, - A, - B, - C, D, + C, + B, + A, + AA, + EX, + EXP, } impl Grade { - pub const GRADE_STRINGS: [&'static str; 7] = ["EX+", "EX", "AA", "A", "B", "C", "D"]; - pub const GRADE_SHORTHANDS: [&'static str; 7] = ["exp", "ex", "aa", "a", "b", "c", "d"]; + pub const GRADE_STRINGS: [&'static str; 7] = ["D", "C", "B", "A", "AA", "EX", "EX+"]; + pub const GRADE_SHORTHANDS: [&'static str; 7] = ["d", "c", "b", "a", "aa", "ex", "exp"]; #[inline] pub fn to_index(self) -> usize { @@ -268,6 +268,12 @@ impl Score { Ok(buffer) } // }}} + // {{{ PM detection + #[inline] + pub fn is_pm(&self) -> bool { + self.0 >= 10_000_000 + } + // }}} } impl Display for Score { diff --git a/src/commands/chart.rs b/src/commands/chart.rs index 4675726..7375274 100644 --- a/src/commands/chart.rs +++ b/src/commands/chart.rs @@ -4,13 +4,48 @@ use sqlx::query; use crate::{ arcaea::chart::Side, context::{Context, Error}, + get_user, recognition::fuzzy_song_name::guess_song_and_chart, }; +use std::io::Cursor; -// {{{ Chart +use chrono::DateTime; +use image::{ImageBuffer, Rgb}; +use plotters::{ + backend::{BitMapBackend, PixelFormat, RGBPixel}, + chart::{ChartBuilder, LabelAreaPosition}, + drawing::IntoDrawingArea, + element::Circle, + series::LineSeries, + style::{IntoFont, TextStyle, BLUE, WHITE}, +}; +use poise::CreateReply; +use sqlx::query_as; + +use crate::{ + arcaea::{ + play::DbPlay, + score::{Score, ScoringSystem}, + }, + user::discord_it_to_discord_user, +}; + +// {{{ Top command +/// Chart-related stats +#[poise::command( + prefix_command, + slash_command, + subcommands("info", "best", "plot"), + subcommand_required +)] +pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> { + Ok(()) +} +// }}} +// {{{ Info /// Show a chart given it's name -#[poise::command(prefix_command, slash_command)] -pub async fn chart( +#[poise::command(prefix_command, slash_command, user_cooldown = 1)] +async fn info( ctx: Context<'_>, #[rest] #[description = "Name of chart to show (difficulty at the end)"] @@ -26,10 +61,10 @@ pub async fn chart( let play_count = query!( " - SELECT COUNT(*) as count - FROM plays - WHERE chart_id=? - ", + SELECT COUNT(*) as count + FROM plays + WHERE chart_id=? + ", chart.id ) .fetch_one(&ctx.data().db) @@ -74,3 +109,186 @@ pub async fn chart( Ok(()) } // }}} +// {{{ Best score +/// Show the best score on a given chart +#[poise::command(prefix_command, slash_command, user_cooldown = 1)] +async fn best( + ctx: Context<'_>, + #[rest] + #[description = "Name of chart to show (difficulty at the end)"] + name: String, +) -> Result<(), Error> { + let user = get_user!(&ctx); + + let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; + 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 {} [{:?}]", + song.title, chart.difficulty + ) + })? + .into_play(); + + let (embed, attachment) = play + .to_embed( + &ctx.data().db, + &user, + &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, user_cooldown = 10)] +async fn plot( + ctx: Context<'_>, + scoring_system: Option, + #[rest] + #[description = "Name of chart to show (difficulty at the end)"] + name: String, +) -> Result<(), Error> { + let user = get_user!(&ctx); + let scoring_system = scoring_system.unwrap_or_default(); + + let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; + + // SAFETY: we limit the amount of plotted plays to 1000. + let plays = query_as!( + DbPlay, + " + SELECT * FROM plays + WHERE user_id=? + AND chart_id=? + ORDER BY created_at ASC + LIMIT 1000 + ", + user.id, + chart.id + ) + .fetch_all(&ctx.data().db) + .await?; + + if plays.len() == 0 { + ctx.reply(format!( + "No plays found on {} [{:?}]", + song.title, chart.difficulty + )) + .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.clone().into_play().score(scoring_system)) + .min() + .unwrap() + .0 as i64; + + 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 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 + .into_iter() + .map(|play| { + ( + play.created_at.and_utc().timestamp_millis(), + play.into_play().score(scoring_system), + ) + }) + .collect(); + + points.sort(); + points.dedup(); + + chart.draw_series(LineSeries::new( + points.iter().map(|(t, s)| (*t, s.0 as i64)), + &BLUE, + ))?; + + chart.draw_series(points.iter().map(|(t, s)| { + Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE)) + }))?; + 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/commands/mod.rs b/src/commands/mod.rs index 1a1ac06..e645b56 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -19,6 +19,7 @@ pub async fn help( command.as_deref(), poise::builtins::HelpConfiguration { extra_text_at_bottom: "For additional support, message @prescientmoon", + show_subcommands: true, ..Default::default() }, ) diff --git a/src/commands/score.rs b/src/commands/score.rs index 13007ca..62f5fb1 100644 --- a/src/commands/score.rs +++ b/src/commands/score.rs @@ -117,11 +117,11 @@ pub async fn magic( let maybe_fars = Score::resolve_distibution_ambiguities(score, note_distribution, chart.note_count); - let play = CreatePlay::new(score, &chart, &user) + let play = CreatePlay::new(score, &chart) .with_attachment(file) .with_fars(maybe_fars) .with_max_recall(max_recall) - .save(&ctx.data()) + .save(&ctx.data(), &user) .await?; // }}} // }}} diff --git a/src/commands/stats.rs b/src/commands/stats.rs index 09e9c6e..f84af03 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -1,26 +1,19 @@ use std::io::Cursor; -use chrono::DateTime; -use image::{DynamicImage, ImageBuffer, Rgb}; -use plotters::{ - backend::{BitMapBackend, PixelFormat, RGBPixel}, - chart::{ChartBuilder, LabelAreaPosition}, - drawing::IntoDrawingArea, - element::Circle, - series::LineSeries, - style::{IntoFont, TextStyle, BLUE, WHITE}, -}; +use image::{DynamicImage, ImageBuffer}; use poise::{ - serenity_prelude::{CreateAttachment, CreateMessage}, + serenity_prelude::{CreateAttachment, CreateEmbed}, CreateReply, }; -use sqlx::query_as; +use sqlx::query; use crate::{ arcaea::{ + achievement::GoalStats, + chart::Level, jacket::BITMAP_IMAGE_SIZE, - play::{compute_b30_ptt, get_best_plays, DbPlay}, - score::{Score, ScoringSystem}, + play::{compute_b30_ptt, get_best_plays}, + score::ScoringSystem, }, assert_is_pookie, assets::{ @@ -32,9 +25,8 @@ use crate::{ context::{Context, Error}, get_user, logs::debug_image_log, - recognition::fuzzy_song_name::guess_song_and_chart, reply_errors, - user::{discord_it_to_discord_user, User}, + user::User, }; // {{{ Stats @@ -42,208 +34,13 @@ use crate::{ #[poise::command( prefix_command, slash_command, - subcommands("chart", "b30", "bany"), + subcommands("meta", "b30", "bany"), 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 = get_user!(&ctx); - - let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; - 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 {} [{:?}]", - song.title, chart.difficulty - ) - })? - .into_play(); - - let (embed, attachment) = play - .to_embed( - &ctx.data().db, - &user, - &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<'_>, - scoring_system: Option, - #[rest] - #[description = "Name of chart to show (difficulty at the end)"] - name: String, -) -> Result<(), Error> { - let user = get_user!(&ctx); - let scoring_system = scoring_system.unwrap_or_default(); - - let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; - - // SAFETY: we limit the amount of plotted plays to 1000. - let plays = query_as!( - DbPlay, - " - SELECT * FROM plays - WHERE user_id=? - AND chart_id=? - ORDER BY created_at ASC - LIMIT 1000 - ", - user.id, - chart.id - ) - .fetch_all(&ctx.data().db) - .await?; - - if plays.len() == 0 { - ctx.reply(format!( - "No plays found on {} [{:?}]", - song.title, chart.difficulty - )) - .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.clone().into_play().score(scoring_system)) - .min() - .unwrap() - .0 as i64; - - 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 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 - .into_iter() - .map(|play| { - ( - play.created_at.and_utc().timestamp_millis(), - play.into_play().score(scoring_system), - ) - }) - .collect(); - - points.sort(); - points.dedup(); - - chart.draw_series(LineSeries::new( - points.iter().map(|(t, s)| (*t, s.0 as i64)), - &BLUE, - ))?; - - chart.draw_series(points.iter().map(|(t, s)| { - Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE)) - }))?; - 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(()) -} -// }}} // {{{ Render best plays async fn best_plays( ctx: &Context<'_>, @@ -415,9 +212,10 @@ async fn best_plays( drawer.blit_rbga(jacket_with_border, diff_bg_area.top_left(), diff_bg); // }}} // {{{ Display difficulty text - let x_offset = if chart.level.ends_with("+") { + let level_text = Level::LEVEL_STRINGS[chart.level.to_index()]; + let x_offset = if level_text.ends_with("+") { 3 - } else if chart.level == "11" { + } else if chart.level == Level::Eleven { -2 } else { 0 @@ -438,7 +236,7 @@ async fn best_plays( stroke: None, drop_shadow: None, }, - &chart.level, + level_text, ) })?; // }}} @@ -658,3 +456,70 @@ pub async fn bany( .await } // }}} +// {{{ Meta +/// Show stats about the bot itself. +#[poise::command(prefix_command, slash_command, user_cooldown = 1)] +async fn meta(ctx: Context<'_>) -> Result<(), Error> { + let user = get_user!(&ctx); + let song_count = query!("SELECT count() as count FROM songs") + .fetch_one(&ctx.data().db) + .await? + .count; + + let chart_count = query!("SELECT count() as count FROM charts") + .fetch_one(&ctx.data().db) + .await? + .count; + + let users_count = query!("SELECT count() as count FROM users") + .fetch_one(&ctx.data().db) + .await? + .count; + + let pookie_count = query!( + " + SELECT count() as count + FROM users + WHERE is_pookie=1 + " + ) + .fetch_one(&ctx.data().db) + .await? + .count; + + let play_count = query!("SELECT count() as count FROM plays") + .fetch_one(&ctx.data().db) + .await? + .count; + + let your_play_count = query!( + " + SELECT count() as count + FROM plays + WHERE user_id=? + ", + user.id + ) + .fetch_one(&ctx.data().db) + .await? + .count; + + let embed = CreateEmbed::default() + .title("Bot statistics") + .field("Songs", format!("{song_count}"), true) + .field("Charts", format!("{chart_count}"), true) + .field("Users", format!("{users_count}"), true) + .field("Pookies", format!("{pookie_count}"), true) + .field("Plays", format!("{play_count}"), true) + .field("Your plays", format!("{your_play_count}"), true); + + ctx.send(CreateReply::default().embed(embed)).await?; + + println!( + "{:?}", + GoalStats::make(ctx.data(), &user, ScoringSystem::Standard).await? + ); + + Ok(()) +} +// }}} diff --git a/src/context.rs b/src/context.rs index 6089aab..91ceb64 100644 --- a/src/context.rs +++ b/src/context.rs @@ -6,6 +6,7 @@ use crate::{ arcaea::{chart::SongCache, jacket::JacketCache}, assets::{get_data_dir, EXO_FONT, GEOSANS_FONT, KAZESAWA_BOLD_FONT, KAZESAWA_FONT}, recognition::{hyperglass::CharMeasurements, ui::UIMeasurements}, + timed, }; // Types used by all command functions @@ -29,36 +30,38 @@ pub struct UserContext { impl UserContext { #[inline] pub async fn new(db: SqlitePool) -> Result { - fs::create_dir_all(get_data_dir())?; + timed!("create_context", { + fs::create_dir_all(get_data_dir())?; - let mut song_cache = SongCache::new(&db).await?; - let jacket_cache = JacketCache::new(&mut song_cache)?; - let ui_measurements = UIMeasurements::read()?; + let mut song_cache = timed!("make_song_cache", { SongCache::new(&db).await? }); + let jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? }); + let ui_measurements = timed!("read_ui_measurements", { UIMeasurements::read()? }); - // {{{ Font measurements - static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ"; + // {{{ Font measurements + static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ"; - let geosans_measurements = GEOSANS_FONT - .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; - let kazesawa_measurements = KAZESAWA_FONT - .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; - let kazesawa_bold_measurements = KAZESAWA_BOLD_FONT - .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; - let exo_measurements = EXO_FONT - .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, Some(700)))?; - // }}} + let geosans_measurements = GEOSANS_FONT + .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; + let kazesawa_measurements = KAZESAWA_FONT + .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; + let kazesawa_bold_measurements = KAZESAWA_BOLD_FONT + .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; + let exo_measurements = EXO_FONT + .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, Some(700)))?; + // }}} - println!("Created user context"); + println!("Created user context"); - Ok(Self { - db, - song_cache, - jacket_cache, - ui_measurements, - geosans_measurements, - exo_measurements, - kazesawa_measurements, - kazesawa_bold_measurements, + Ok(Self { + db, + song_cache, + jacket_cache, + ui_measurements, + geosans_measurements, + exo_measurements, + kazesawa_measurements, + kazesawa_bold_measurements, + }) }) } } diff --git a/src/recognition/hyperglass.rs b/src/recognition/hyperglass.rs index 9dde6c1..6ac0ee3 100644 --- a/src/recognition/hyperglass.rs +++ b/src/recognition/hyperglass.rs @@ -233,66 +233,69 @@ pub struct CharMeasurements { impl CharMeasurements { // {{{ Creation pub fn from_text(face: &mut Face, string: &str, weight: Option) -> Result { - // These are bad estimates lol - let style = TextStyle { - stroke: None, - drop_shadow: None, - align: (Align::Start, Align::Start), - size: 60, - color: Color::BLACK, - // TODO: do we want to use the weight hint for resilience? - weight, - }; - let padding = (5, 5); - let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?; + timed!("measure_chars", { + // These are bad estimates lol + let style = TextStyle { + stroke: None, + drop_shadow: None, + align: (Align::Start, Align::Start), + size: 60, + color: Color::BLACK, + // TODO: do we want to use the weight hint for resilience? + weight, + }; + let padding = (5, 5); + let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?; - let mut canvas = BitmapCanvas::new( - (planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32, - (planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32, - ); + let mut canvas = BitmapCanvas::new( + (planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32, + (planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32, + ); - canvas.text(padding, &mut [face], style, &string)?; - let buffer = ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec()) - .ok_or_else(|| "Failed to turn buffer into canvas")?; - let image = DynamicImage::ImageRgb8(buffer); + canvas.text(padding, &mut [face], style, &string)?; + let buffer = + ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec()) + .ok_or_else(|| "Failed to turn buffer into canvas")?; + let image = DynamicImage::ImageRgb8(buffer); - debug_image_log(&image)?; + debug_image_log(&image)?; - let components = ComponentsWithBounds::from_image(&image, 100)?; + let components = ComponentsWithBounds::from_image(&image, 100)?; - // {{{ Compute max width/height - let max_width = components - .bounds - .iter() - .filter_map(|o| o.as_ref()) - .map(|b| b.x_max - b.x_min) - .max() - .ok_or_else(|| "No connected components found")?; - let max_height = components - .bounds - .iter() - .filter_map(|o| o.as_ref()) - .map(|b| b.y_max - b.y_min) - .max() - .ok_or_else(|| "No connected components found")?; - // }}} + // {{{ Compute max width/height + let max_width = components + .bounds + .iter() + .filter_map(|o| o.as_ref()) + .map(|b| b.x_max - b.x_min) + .max() + .ok_or_else(|| "No connected components found")?; + let max_height = components + .bounds + .iter() + .filter_map(|o| o.as_ref()) + .map(|b| b.y_max - b.y_min) + .max() + .ok_or_else(|| "No connected components found")?; + // }}} - let mut chars = Vec::with_capacity(string.len()); - for (i, char) in string.chars().enumerate() { - chars.push(( - char, - ComponentVec::from_component( - &components, - (max_width, max_height), - components.bounds_by_position[i] as u32 + 1, - )?, - )) - } + let mut chars = Vec::with_capacity(string.len()); + for (i, char) in string.chars().enumerate() { + chars.push(( + char, + ComponentVec::from_component( + &components, + (max_width, max_height), + components.bounds_by_position[i] as u32 + 1, + )?, + )) + } - Ok(Self { - chars, - max_width, - max_height, + Ok(Self { + chars, + max_width, + max_height, + }) }) } // }}}