use std::str::FromStr; use num::traits::Euclid; use poise::serenity_prelude::{ Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp, }; use sqlx::{query_as, SqlitePool}; use crate::arcaea::chart::{Chart, Song}; use crate::context::{Error, UserContext}; use crate::user::User; use super::score::Score; // {{{ Create play #[derive(Debug, Clone)] pub struct CreatePlay { chart_id: u32, user_id: u32, discord_attachment_id: Option<AttachmentId>, // Actual score data score: Score, zeta_score: Score, // Optional score details max_recall: Option<u32>, far_notes: Option<u32>, // Creation data creation_ptt: Option<u32>, creation_zeta_ptt: Option<u32>, } 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<u32>) -> Self { self.far_notes = far_count; self } #[inline] pub fn with_max_recall(mut self, max_recall: Option<u32>) -> Self { self.max_recall = max_recall; self } // {{{ Save pub async fn save(self, ctx: &UserContext) -> Result<Play, Error> { 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<String>, pub score: i64, pub zeta_score: i64, pub max_recall: Option<i64>, pub far_notes: Option<i64>, pub created_at: chrono::NaiveDateTime, pub creation_ptt: Option<i64>, pub creation_zeta_ptt: Option<i64>, } 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, #[allow(unused)] pub discord_attachment_id: Option<AttachmentId>, // Actual score data pub score: Score, pub zeta_score: Score, // Optional score details pub max_recall: Option<u32>, pub far_notes: Option<u32>, // Creation data pub created_at: chrono::NaiveDateTime, #[allow(unused)] pub creation_ptt: Option<u32>, #[allow(unused)] pub creation_zeta_ptt: Option<u32>, } 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.checked_sub(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.checked_sub(fars + pures)?; let non_max_pures = pures.checked_sub(shinies)?; Some((shinies, non_max_pures, fars, lost)) } else { None } } // }}} // {{{ Play => status #[inline] pub fn status(&self, chart: &Chart) -> Option<String> { let score = self.score.0; if score >= 10_000_000 { if score > chart.note_count + 10_000_000 { return None; } let non_max_pures = (chart.note_count + 10_000_000).checked_sub(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 } } #[inline] pub fn short_status(&self, chart: &Chart) -> Option<char> { let score = self.score.0; if score >= 10_000_000 { let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; if non_max_pures == 0 { Some('M') } else { Some('P') } } else if let Some(distribution) = self.distribution(chart.note_count) && distribution.3 == 0 { Some('F') } else { Some('C') } } // }}} // {{{ 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, db: &SqlitePool, user: &User, song: &Song, chart: &Chart, index: usize, author: Option<&poise::serenity_prelude::User>, ) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> { // {{{ Get previously best score let previously_best = query_as!( DbPlay, " SELECT * FROM plays WHERE user_id=? AND chart_id=? AND created_at<? ORDER BY score DESC ", user.id, chart.id, self.created_at ) .fetch_optional(db) .await .map_err(|_| { format!( "Could not find any scores for {} [{:?}]", song.title, chart.difficulty ) })? .map(|p| p.to_play()); // }}} let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index); let icon_attachement = match chart.cached_jacket.as_ref() { Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)), 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_f32(chart.chart_constant) ), true, ) .field("Grade", format!("{}", self.score.grade()), true) .field("ξ-Score", format!("{} (+?)", self.zeta_score), true) // {{{ ξ-Rating .field( "ξ-Rating", { let play_rating = self.zeta_score.play_rating_f32(chart.chart_constant); if let Some(previous) = previously_best { let previous_play_rating = previous.zeta_score.play_rating_f32(chart.chart_constant); if play_rating >= previous_play_rating { format!( "{:.2} (+{})", play_rating, play_rating - previous_play_rating ) } else { format!( "{:.2} (-{})", play_rating, play_rating - previous_play_rating ) } } else { format!("{:.2}", play_rating) } }, true, ) // }}} .field("ξ-Grade", format!("{}", self.zeta_score.grade()), true) .field( "Status", self.status(chart).unwrap_or("-".to_string()), true, ) .field( "Max recall", if let Some(max_recall) = self.max_recall { format!("{}", max_recall) } else { format!("-") }, 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)) } // }}} } // }}}