From 4ed3fe276b96ee538f55eed69e358bc600170c26 Mon Sep 17 00:00:00 2001 From: prescientmoon Date: Tue, 20 Aug 2024 19:24:32 +0200 Subject: [PATCH] Generalize code to more scoring systems --- schema.sql | 15 +- src/arcaea/achievement.rs | 33 ++-- src/arcaea/mod.rs | 1 + src/arcaea/play.rs | 366 +++++++++++++++++++++++--------------- src/arcaea/rating.rs | 23 +++ src/arcaea/score.rs | 50 ++++-- src/commands/chart.rs | 57 +++--- src/commands/score.rs | 36 ++-- src/commands/stats.rs | 12 +- src/main.rs | 8 + 10 files changed, 375 insertions(+), 226 deletions(-) create mode 100644 src/arcaea/rating.rs diff --git a/schema.sql b/schema.sql index c4fc7d3..3b3d512 100644 --- a/schema.sql +++ b/schema.sql @@ -1,10 +1,10 @@ # {{{ users -# }}} create table IF NOT EXISTS users ( id INTEGER NOT NULL PRIMARY KEY, discord_id TEXT UNIQUE NOT NULL, is_pookie BOOL NOT NULL DEFAULT 0 ); +# }}} # {{{ songs CREATE TABLE IF NOT EXISTS songs ( id INTEGER NOT NULL PRIMARY KEY, @@ -55,5 +55,18 @@ CREATE TABLE IF NOT EXISTS plays ( FOREIGN KEY (user_id) REFERENCES users(id) ); # }}} +# {{{ scores +CREATE TABLE IF NOT EXISTS scores ( + id INTEGER NOT NULL PRIMARY KEY, + play_id INTEGER NOT NULL, + + score INTEGER NOT NULL, + creation_ptt INTEGER, + scoring_system NOT NULL CHECK (scoring_system IN ('standard', 'ex')), + + FOREIGN KEY (play_id) REFERENCES plays(id), + UNIQUE(play_id, scoring_system) +) +# }}} insert into users(discord_id) values (385759924917108740); diff --git a/src/arcaea/achievement.rs b/src/arcaea/achievement.rs index 0680097..21bc90e 100644 --- a/src/arcaea/achievement.rs +++ b/src/arcaea/achievement.rs @@ -125,10 +125,11 @@ impl GoalStats { let plays = get_best_plays( &ctx.db, &ctx.song_cache, - user, + user.id, scoring_system, 0, usize::MAX, + None, ) .await??; @@ -150,22 +151,20 @@ impl GoalStats { .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, - } - } + let peak_ptt = query!( + " + SELECT s.creation_ptt + FROM plays p + JOIN scores s ON s.play_id = p.id + WHERE user_id = ? + AND scoring_system = ? + ", + user.id, + ScoringSystem::SCORING_SYSTEM_DB_STRINGS[scoring_system.to_index()] + ) + .fetch_one(&ctx.db) + .await? + .creation_ptt .ok_or_else(|| "No ptt history data found")? as u32; // }}} // {{{ Peak PM relay diff --git a/src/arcaea/mod.rs b/src/arcaea/mod.rs index 50dea45..f6f249f 100644 --- a/src/arcaea/mod.rs +++ b/src/arcaea/mod.rs @@ -2,4 +2,5 @@ pub mod achievement; pub mod chart; pub mod jacket; pub mod play; +pub mod rating; pub mod score; diff --git a/src/arcaea/play.rs b/src/arcaea/play.rs index 4421d89..9e59236 100644 --- a/src/arcaea/play.rs +++ b/src/arcaea/play.rs @@ -1,41 +1,42 @@ -use std::str::FromStr; +use std::array; +use chrono::NaiveDateTime; +use chrono::Utc; use num::traits::Euclid; +use num::CheckedDiv; +use num::Rational32; +use num::Zero; use poise::serenity_prelude::{ Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp, }; -use sqlx::{query, query_as, SqlitePool}; +use sqlx::query_as; +use sqlx::{query, SqlitePool}; use crate::arcaea::chart::{Chart, Song}; use crate::context::{Error, UserContext}; use crate::user::User; use super::chart::SongCache; +use super::rating::{rating_as_fixed, rating_as_float}; use super::score::{Score, ScoringSystem}; // {{{ Create play #[derive(Debug, Clone)] pub struct CreatePlay { - chart_id: u32, discord_attachment_id: Option, - // Actual score data + // Scoring details score: Score, - zeta_score: Score, - - // Optional score details max_recall: Option, far_notes: Option, } impl CreatePlay { #[inline] - pub fn new(score: Score, chart: &Chart) -> Self { + pub fn new(score: Score) -> Self { Self { - chart_id: chart.id, discord_attachment_id: None, score, - zeta_score: score.to_zeta(chart.note_count as u32), max_recall: None, far_notes: None, } @@ -60,24 +61,22 @@ impl CreatePlay { } // {{{ Save - pub async fn save(self, ctx: &UserContext, user: &User) -> Result { + pub async fn save(self, ctx: &UserContext, user: &User, chart: &Chart) -> 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( - user_id,chart_id,discord_attachment_id, - score,zeta_score,max_recall,far_notes + user_id,chart_id,discord_attachment_id, + max_recall,far_notes ) - VALUES(?,?,?,?,?,?,?) + VALUES(?,?,?,?,?) RETURNING id, created_at ", user.id, - self.chart_id, + chart.id, attachment_id, - self.score.0, - self.zeta_score.0, self.max_recall, self.far_notes ) @@ -85,93 +84,88 @@ impl CreatePlay { .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) + let scores = ScoreCollection::from_standard_score(self.score, chart); + for system in ScoringSystem::SCORING_SYSTEMS { + let i = system.to_index(); + let plays = get_best_plays(&ctx.db, &ctx.song_cache, user.id, system, 30, 30, None) .await? - .ok() - .map(|plays| compute_b30_ptt(ScoringSystem::EX, &plays)); + .ok(); + + let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) }; + + query!( + " + INSERT INTO scores(play_id, score, creation_ptt, scoring_system) + VALUES (?,?,?,?) + ", + play.id, + scores.0[i].0, + creation_ptt, + ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i] + ) + .execute(&ctx.db) + .await?; + } - 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, + chart_id: chart.id, user_id: user.id, - discord_attachment_id: self.discord_attachment_id, - score: self.score, - zeta_score: self.zeta_score, + scores, max_recall: self.max_recall, far_notes: self.far_notes, - creation_ptt, - creation_zeta_ptt, }) } // }}} } // }}} // {{{ DbPlay -/// Version of `Play` matching the format sqlx expects -#[derive(Debug, Clone, sqlx::FromRow)] +/// Construct a `Play` from a sqlite return record. +#[macro_export] +macro_rules! play_from_db_record { + ($chart:expr, $record:expr) => {{ + use crate::arcaea::play::{Play, ScoreCollection}; + use crate::arcaea::score::Score; + Play { + id: $record.id as u32, + chart_id: $record.chart_id as u32, + user_id: $record.user_id as u32, + scores: ScoreCollection::from_standard_score(Score($record.score as u32), $chart), + max_recall: $record.max_recall.map(|r| r as u32), + far_notes: $record.far_notes.map(|r| r as u32), + created_at: $record.created_at, + } + }}; +} + +/// Typed version of the input to the macro above. +/// Useful when using the non-macro version of the sqlx functions. +#[derive(Debug, 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 created_at: chrono::NaiveDateTime, + + // Score details pub max_recall: Option, pub far_notes: Option, - pub created_at: chrono::NaiveDateTime, - pub creation_ptt: Option, - pub creation_zeta_ptt: Option, + pub score: i64, } -impl DbPlay { - #[inline] - pub fn into_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 i32), - creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as i32), - } +// }}} +// {{{ Score data +#[derive(Debug, Clone, Copy)] +pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]); + +impl ScoreCollection { + pub fn from_standard_score(score: Score, chart: &Chart) -> Self { + ScoreCollection(array::from_fn(|i| { + score.convert_to(ScoringSystem::SCORING_SYSTEMS[i], chart) + })) } } // }}} @@ -179,48 +173,38 @@ impl DbPlay { #[derive(Debug, Clone)] pub struct Play { pub id: u32, + #[allow(unused)] pub chart_id: u32, pub user_id: u32, - - #[allow(unused)] - 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, - #[allow(dead_code)] - pub creation_ptt: Option, - #[allow(dead_code)] - pub creation_zeta_ptt: Option, + // Score details + pub max_recall: Option, + pub far_notes: Option, + pub scores: ScoreCollection, } impl Play { // {{{ Query the underlying score #[inline] pub fn score(&self, system: ScoringSystem) -> Score { - match system { - ScoringSystem::Standard => self.score, - ScoringSystem::EX => self.zeta_score, - } + self.scores.0[system.to_index()] } #[inline] - pub fn play_rating(&self, system: ScoringSystem, chart_constant: u32) -> i32 { + pub fn play_rating(&self, system: ScoringSystem, chart_constant: u32) -> Rational32 { self.score(system).play_rating(chart_constant) } + + #[inline] + pub fn play_rating_f32(&self, system: ScoringSystem, chart_constant: u32) -> f32 { + rating_as_float(self.score(system).play_rating(chart_constant)) + } // }}} // {{{ 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 (_, shinies, units) = self.score(ScoringSystem::Standard).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!"); @@ -237,8 +221,8 @@ impl Play { // }}} // {{{ Play => status #[inline] - pub fn status(&self, chart: &Chart) -> Option { - let score = self.score.0; + pub fn status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option { + let score = self.score(scoring_system).0; if score >= 10_000_000 { if score > chart.note_count + 10_000_000 { return None; @@ -266,8 +250,8 @@ impl Play { } #[inline] - pub fn short_status(&self, chart: &Chart) -> Option { - let score = self.score.0; + pub fn short_status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option { + let score = self.score(scoring_system).0; if score >= 10_000_000 { let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; if non_max_pures == 0 { @@ -298,14 +282,18 @@ impl Play { author: Option<&poise::serenity_prelude::User>, ) -> Result<(CreateEmbed, Option), Error> { // {{{ Get previously best score - let prev_play = query_as!( - DbPlay, + let prev_play = query!( " - SELECT * FROM plays - WHERE user_id=? - AND chart_id=? - AND created_at Some(CreateAttachment::bytes(jacket.raw, &attachement_name)), None => None, @@ -337,30 +330,46 @@ impl Play { "{} [{:?} {}]", &song.title, chart.difficulty, chart.level )) - .field("Score", self.score.display_with_diff(prev_score)?, true) .field( - "Rating", - self.score.display_play_rating(prev_score, chart)?, + "Score", + self.score(ScoringSystem::Standard) + .display_with_diff(prev_score)?, + true, + ) + .field( + "Rating", + self.score(ScoringSystem::Standard) + .display_play_rating(prev_score, chart)?, + true, + ) + .field( + "Grade", + format!("{}", self.score(ScoringSystem::Standard).grade()), true, ) - .field("Grade", format!("{}", self.score.grade()), true) .field( "ξ-Score", - self.zeta_score.display_with_diff(prev_zeta_score)?, + self.score(ScoringSystem::EX) + .display_with_diff(prev_zeta_score)?, true, ) // {{{ ξ-Rating .field( "ξ-Rating", - self.zeta_score + self.score(ScoringSystem::EX) .display_play_rating(prev_zeta_score, chart)?, true, ) // }}} - .field("ξ-Grade", format!("{}", self.zeta_score.grade()), true) + .field( + "ξ-Grade", + format!("{}", self.score(ScoringSystem::EX).grade()), + true, + ) .field( "Status", - self.status(chart).unwrap_or("-".to_string()), + self.status(ScoringSystem::Standard, chart) + .unwrap_or("-".to_string()), true, ) .field( @@ -402,24 +411,33 @@ pub type PlayCollection<'a> = Vec<(Play, &'a Song, &'a Chart)>; pub async fn get_best_plays<'a>( db: &SqlitePool, song_cache: &'a SongCache, - user: &User, + user_id: u32, scoring_system: ScoringSystem, min_amount: usize, max_amount: usize, + before: Option, ) -> Result, String>, Error> { // {{{ DB data fetching let plays: Vec = query_as( " - SELECT id, chart_id, user_id, - created_at, MAX(score) as score, zeta_score, - creation_ptt, creation_zeta_ptt, far_notes, max_recall, discord_attachment_id - FROM plays p - WHERE user_id = ? - GROUP BY chart_id - ORDER BY score DESC + SELECT + p.id, p.chart_id, p.user_id, p.created_at, + p.max_recall, p.far_notes, s.score, + MAX(s.score) as _cscore + -- ^ This is only here to make sqlite pick the correct row for the bare columns + FROM plays p + JOIN scores s ON s.play_id = p.id + JOIN scores cs ON cs.play_id = p.id + WHERE s.scoring_system='standard' + AND cs.scoring_system=? + AND p.user_id=? + AND p.created_at<=? + GROUP BY p.chart_id ", ) - .bind(user.id) + .bind(ScoringSystem::SCORING_SYSTEM_DB_STRINGS[scoring_system.to_index()]) + .bind(user_id) + .bind(before.unwrap_or_else(|| Utc::now().naive_utc())) .fetch_all(db) .await?; // }}} @@ -437,8 +455,8 @@ pub async fn get_best_plays<'a>( let mut plays: Vec<(Play, &Song, &Chart)> = plays .into_iter() .map(|play| { - let play = play.into_play(); - let (song, chart) = song_cache.lookup_chart(play.chart_id)?; + let (song, chart) = song_cache.lookup_chart(play.chart_id as u32)?; + let play = play_from_db_record!(chart, play); Ok((play, song, chart)) }) .collect::, Error>>()?; @@ -451,12 +469,78 @@ pub async fn get_best_plays<'a>( } #[inline] -pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> i32 { +pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> Rational32 { plays .iter() .map(|(play, _, chart)| play.play_rating(scoring_system, chart.chart_constant)) - .sum::() - .checked_div(plays.len() as i32) - .unwrap_or(0) + .sum::() + .checked_div(&Rational32::from_integer(plays.len() as i32)) + .unwrap_or(Rational32::zero()) +} +// }}} +// {{{ Maintenance functions +pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), Error> { + let plays = query!( + " + SELECT + p.id, p.chart_id, p.user_id, p.created_at, + p.max_recall, p.far_notes, s.score + FROM plays p + JOIN scores s ON s.play_id = p.id + WHERE s.scoring_system='standard' + ORDER BY p.created_at ASC + " + ) + // Can't use the stream based version because of db locking... + .fetch_all(&ctx.db) + .await?; + + let mut i = 0; + + for play in plays { + let (_, chart) = ctx.song_cache.lookup_chart(play.chart_id as u32)?; + let play = play_from_db_record!(chart, play); + + for system in ScoringSystem::SCORING_SYSTEMS { + let i = system.to_index(); + let plays = get_best_plays( + &ctx.db, + &ctx.song_cache, + play.user_id, + system, + 30, + 30, + Some(play.created_at), + ) + .await? + .ok(); + + let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) }; + let raw_score = play.scores.0[i].0; + + query!( + " + INSERT INTO scores(play_id, score, creation_ptt, scoring_system) + VALUES ($1, $2, $3, $4) + ON CONFLICT(play_id, scoring_system) + DO UPDATE SET + score=$2, creation_ptt=$3 + WHERE play_id = $1 + AND scoring_system = $4 + + ", + play.id, + raw_score, + creation_ptt, + ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i], + ) + .execute(&ctx.db) + .await?; + } + + i += 1; + println!("Processed {i} plays"); + } + Ok(()) } // }}} diff --git a/src/arcaea/rating.rs b/src/arcaea/rating.rs new file mode 100644 index 0000000..149d197 --- /dev/null +++ b/src/arcaea/rating.rs @@ -0,0 +1,23 @@ +use num::Rational32; + +pub type Rating = Rational32; + +/// Saves a rating rational as an integer where it's multiplied by 100. +#[inline] +pub fn rating_as_fixed(rating: Rating) -> i32 { + (rating * Rational32::from_integer(100)) + .round() + .to_integer() +} + +/// Saves a rating rational as a float with precision 2. +#[inline] +pub fn rating_as_float(rating: Rating) -> f32 { + rating_as_fixed(rating) as f32 / 100.0 +} + +/// The pseudo-inverse of `rating_as_fixed`. +#[inline] +pub fn rating_from_fixed(fixed: i32) -> Rating { + Rating::new(fixed, 100) +} diff --git a/src/arcaea/score.rs b/src/arcaea/score.rs index 5e49143..ef9cd2d 100644 --- a/src/arcaea/score.rs +++ b/src/arcaea/score.rs @@ -1,10 +1,13 @@ use std::fmt::{Display, Write}; -use num::Rational64; +use num::{Rational32, Rational64}; use crate::context::Error; -use super::chart::Chart; +use super::{ + chart::Chart, + rating::{rating_as_float, rating_from_fixed, Rating}, +}; // {{{ Scoring system #[derive(Debug, Clone, Copy, poise::ChoiceParameter)] @@ -15,6 +18,18 @@ pub enum ScoringSystem { EX, } +impl ScoringSystem { + pub const SCORING_SYSTEMS: [Self; 2] = [Self::Standard, Self::EX]; + + /// Values used inside sqlite + pub const SCORING_SYSTEM_DB_STRINGS: [&'static str; 2] = ["standard", "ex"]; + + #[inline] + pub fn to_index(self) -> usize { + self as usize + } +} + impl Default for ScoringSystem { fn default() -> Self { Self::Standard @@ -125,32 +140,39 @@ impl Score { ) } // }}} + // {{{ Scoring system conversion + /// Convert a standard score to any other scoring system. The output might be + /// nonsense if the given score is not using the standard system. + #[inline] + pub fn convert_to(self, scoring_system: ScoringSystem, chart: &Chart) -> Self { + match scoring_system { + ScoringSystem::Standard => self, + ScoringSystem::EX => self.to_zeta(chart.note_count), + } + } + // }}} // {{{ Score => Play rating #[inline] - pub fn play_rating(self, chart_constant: u32) -> i32 { - chart_constant as i32 + pub fn play_rating(self, chart_constant: u32) -> Rating { + rating_from_fixed(chart_constant as i32) + if self.0 >= 10_000_000 { - 200 + Rational32::from_integer(2) } else if self.0 >= 9_800_000 { - 100 + (self.0 as i32 - 9_800_000) / 2_000 + Rational32::from_integer(1) + + Rational32::new(self.0 as i32 - 9_800_000, 200_000).reduced() } else { - (self.0 as i32 - 9_500_000) / 3_000 + Rational32::new(self.0 as i32 - 9_500_000, 300_000).reduced() } } - #[inline] - pub fn play_rating_f32(self, chart_constant: u32) -> f32 { - (self.play_rating(chart_constant)) as f32 / 100.0 - } - pub fn display_play_rating(self, prev: Option, chart: &Chart) -> Result { let mut buffer = String::with_capacity(14); - let play_rating = self.play_rating_f32(chart.chart_constant); + let play_rating = rating_as_float(self.play_rating(chart.chart_constant)); write!(buffer, "{:.2}", play_rating)?; if let Some(prev) = prev { - let prev_play_rating = prev.play_rating_f32(chart.chart_constant); + let prev_play_rating = rating_as_float(prev.play_rating(chart.chart_constant)); if play_rating >= prev_play_rating { write!(buffer, " (+{:.2})", play_rating - prev_play_rating)?; diff --git a/src/commands/chart.rs b/src/commands/chart.rs index 7375274..71a8a29 100644 --- a/src/commands/chart.rs +++ b/src/commands/chart.rs @@ -4,7 +4,7 @@ use sqlx::query; use crate::{ arcaea::chart::Side, context::{Context, Error}, - get_user, + get_user, play_from_db_record, recognition::fuzzy_song_name::guess_song_and_chart, }; use std::io::Cursor; @@ -20,13 +20,9 @@ use plotters::{ style::{IntoFont, TextStyle, BLUE, WHITE}, }; use poise::CreateReply; -use sqlx::query_as; use crate::{ - arcaea::{ - play::DbPlay, - score::{Score, ScoringSystem}, - }, + arcaea::score::{Score, ScoringSystem}, user::discord_it_to_discord_user, }; @@ -121,13 +117,18 @@ async fn best( let user = get_user!(&ctx); let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; - let play = query_as!( - DbPlay, + let play = query!( " - SELECT * FROM plays - WHERE user_id=? - AND chart_id=? - ORDER BY score DESC + SELECT + p.id, p.chart_id, p.user_id, p.created_at, + p.max_recall, p.far_notes, s.score + FROM plays p + JOIN scores s ON s.play_id = p.id + WHERE s.scoring_system='standard' + AND p.user_id=? + AND p.chart_id=? + ORDER BY s.score DESC + LIMIT 1 ", user.id, chart.id @@ -139,8 +140,8 @@ async fn best( "Could not find any scores for {} [{:?}]", song.title, chart.difficulty ) - })? - .into_play(); + })?; + let play = play_from_db_record!(chart, play); let (embed, attachment) = play .to_embed( @@ -176,13 +177,17 @@ async fn plot( 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, + let plays = query!( " - SELECT * FROM plays - WHERE user_id=? - AND chart_id=? - ORDER BY created_at ASC + SELECT + p.id, p.chart_id, p.user_id, p.created_at, + p.max_recall, p.far_notes, s.score + FROM plays p + JOIN scores s ON s.play_id = p.id + WHERE s.scoring_system='standard' + AND p.user_id=? + AND p.chart_id=? + ORDER BY s.score DESC LIMIT 1000 ", user.id, @@ -204,7 +209,7 @@ async fn plot( 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)) + .map(|p| play_from_db_record!(chart, p).score(scoring_system)) .min() .unwrap() .0 as i64; @@ -228,7 +233,7 @@ async fn plot( { let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area(); - let mut chart = ChartBuilder::on(&root) + let mut chart_buider = ChartBuilder::on(&root) .margin(25) .caption( format!("{} [{:?}]", song.title, chart.difficulty), @@ -241,7 +246,7 @@ async fn plot( min_score..max_score, )?; - chart + chart_buider .configure_mesh() .light_line_style(WHITE) .y_label_formatter(&|s| format!("{}", Score(*s as u32))) @@ -261,7 +266,7 @@ async fn plot( .map(|play| { ( play.created_at.and_utc().timestamp_millis(), - play.into_play().score(scoring_system), + play_from_db_record!(chart, play).score(scoring_system), ) }) .collect(); @@ -269,12 +274,12 @@ async fn plot( points.sort(); points.dedup(); - chart.draw_series(LineSeries::new( + chart_buider.draw_series(LineSeries::new( points.iter().map(|(t, s)| (*t, s.0 as i64)), &BLUE, ))?; - chart.draw_series(points.iter().map(|(t, s)| { + chart_buider.draw_series(points.iter().map(|(t, s)| { Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE)) }))?; root.present()?; diff --git a/src/commands/score.rs b/src/commands/score.rs index 62f5fb1..efa6b23 100644 --- a/src/commands/score.rs +++ b/src/commands/score.rs @@ -1,11 +1,11 @@ use std::time::Instant; -use crate::arcaea::play::{CreatePlay, Play}; +use crate::arcaea::play::CreatePlay; use crate::arcaea::score::Score; use crate::context::{Context, Error}; use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; use crate::user::{discord_it_to_discord_user, User}; -use crate::{edit_reply, get_user, timed}; +use crate::{edit_reply, get_user, play_from_db_record, timed}; use image::DynamicImage; use poise::serenity_prelude::futures::future::join_all; use poise::serenity_prelude::CreateMessage; @@ -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) + let play = CreatePlay::new(score) .with_attachment(file) .with_fars(maybe_fars) .with_max_recall(max_recall) - .save(&ctx.data(), &user) + .save(&ctx.data(), &user, &chart) .await?; // }}} // }}} @@ -220,12 +220,16 @@ pub async fn show( 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, + p.id, p.chart_id, p.user_id, p.created_at, + p.max_recall, p.far_notes, s.score, u.discord_id - FROM plays p + FROM plays p + JOIN scores s ON s.play_id = p.id JOIN users u ON p.user_id = u.id - WHERE p.id=? + WHERE s.scoring_system='standard' + AND p.id=? + ORDER BY s.score DESC + LIMIT 1 ", id ) @@ -233,24 +237,12 @@ pub async fn show( .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 (song, chart) = ctx.data().song_cache.lookup_chart(res.chart_id as u32)?; + let play = play_from_db_record!(chart, res); let author = discord_it_to_discord_user(&ctx, &res.discord_id).await?; let user = User::by_id(&ctx.data().db, play.user_id).await?; - let (song, chart) = ctx.data().song_cache.lookup_chart(play.chart_id)?; let (embed, attachment) = play .to_embed(&ctx.data().db, &user, song, chart, i, Some(&author)) .await?; diff --git a/src/commands/stats.rs b/src/commands/stats.rs index f84af03..3cbc6da 100644 --- a/src/commands/stats.rs +++ b/src/commands/stats.rs @@ -13,6 +13,7 @@ use crate::{ chart::Level, jacket::BITMAP_IMAGE_SIZE, play::{compute_b30_ptt, get_best_plays}, + rating::rating_as_float, score::ScoringSystem, }, assert_is_pookie, @@ -55,14 +56,15 @@ async fn best_plays( get_best_plays( &user_ctx.db, &user_ctx.song_cache, - &user, + user.id, scoring_system, if require_full { grid_size.0 * grid_size.1 } else { grid_size.0 * (grid_size.1.max(1) - 1) + 1 } as usize, - (grid_size.0 * grid_size.1) as usize + (grid_size.0 * grid_size.1) as usize, + None ) .await? ); @@ -287,7 +289,7 @@ async fn best_plays( // }}} // {{{ Display status text with_font(&EXO_FONT, |faces| { - let status = play.short_status(chart).ok_or_else(|| { + let status = play.short_status(scoring_system, chart).ok_or_else(|| { format!( "Could not get status for score {}", play.score(scoring_system) @@ -379,7 +381,7 @@ async fn best_plays( style, &format!( "{:.2}", - play.play_rating(scoring_system, chart.chart_constant) as f32 / 100.0 + play.play_rating_f32(scoring_system, chart.chart_constant) ), )?; @@ -415,7 +417,7 @@ async fn best_plays( .attachment(CreateAttachment::bytes(out_buffer, "b30.png")) .content(format!( "Your ptt is {:.2}", - compute_b30_ptt(scoring_system, &plays) as f32 / 100.0 + rating_as_float(compute_b30_ptt(scoring_system, &plays)) )); ctx.send(reply).await?; diff --git a/src/main.rs b/src/main.rs index 445882d..d93124e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ #![feature(async_closure)] #![feature(try_blocks)] #![feature(thread_local)] +#![feature(generic_arg_infer)] mod arcaea; mod assets; @@ -18,6 +19,7 @@ mod time; mod transform; mod user; +use arcaea::play::generate_missing_scores; use assets::get_data_dir; use context::{Error, UserContext}; use poise::serenity_prelude::{self as serenity}; @@ -89,6 +91,12 @@ async fn main() { poise::builtins::register_globally(ctx, &framework.options().commands).await?; let ctx = UserContext::new(pool).await?; + if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" { + timed!("generate_missing_scores", { + generate_missing_scores(&ctx).await?; + }); + } + Ok(ctx) }) })