1
Fork 0

Generalize code to more scoring systems

This commit is contained in:
prescientmoon 2024-08-20 19:24:32 +02:00
parent 62949004f2
commit 4ed3fe276b
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
10 changed files with 375 additions and 226 deletions

View file

@ -1,10 +1,10 @@
# {{{ users # {{{ users
# }}}
create table IF NOT EXISTS users ( create table IF NOT EXISTS users (
id INTEGER NOT NULL PRIMARY KEY, id INTEGER NOT NULL PRIMARY KEY,
discord_id TEXT UNIQUE NOT NULL, discord_id TEXT UNIQUE NOT NULL,
is_pookie BOOL NOT NULL DEFAULT 0 is_pookie BOOL NOT NULL DEFAULT 0
); );
# }}}
# {{{ songs # {{{ songs
CREATE TABLE IF NOT EXISTS songs ( CREATE TABLE IF NOT EXISTS songs (
id INTEGER NOT NULL PRIMARY KEY, id INTEGER NOT NULL PRIMARY KEY,
@ -55,5 +55,18 @@ CREATE TABLE IF NOT EXISTS plays (
FOREIGN KEY (user_id) REFERENCES users(id) 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); insert into users(discord_id) values (385759924917108740);

View file

@ -125,10 +125,11 @@ impl GoalStats {
let plays = get_best_plays( let plays = get_best_plays(
&ctx.db, &ctx.db,
&ctx.song_cache, &ctx.song_cache,
user, user.id,
scoring_system, scoring_system,
0, 0,
usize::MAX, usize::MAX,
None,
) )
.await??; .await??;
@ -150,22 +151,20 @@ impl GoalStats {
.count as usize; .count as usize;
// }}} // }}}
// {{{ Peak ptt // {{{ Peak ptt
let peak_ptt = { let peak_ptt = query!(
let record = query!(
"
SELECT
max(creation_ptt) as standard,
max(creation_zeta_ptt) as ex
FROM plays
" "
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) .fetch_one(&ctx.db)
.await?; .await?
match scoring_system { .creation_ptt
ScoringSystem::Standard => record.standard,
ScoringSystem::EX => record.ex,
}
}
.ok_or_else(|| "No ptt history data found")? as u32; .ok_or_else(|| "No ptt history data found")? as u32;
// }}} // }}}
// {{{ Peak PM relay // {{{ Peak PM relay

View file

@ -2,4 +2,5 @@ pub mod achievement;
pub mod chart; pub mod chart;
pub mod jacket; pub mod jacket;
pub mod play; pub mod play;
pub mod rating;
pub mod score; pub mod score;

View file

@ -1,41 +1,42 @@
use std::str::FromStr; use std::array;
use chrono::NaiveDateTime;
use chrono::Utc;
use num::traits::Euclid; use num::traits::Euclid;
use num::CheckedDiv;
use num::Rational32;
use num::Zero;
use poise::serenity_prelude::{ use poise::serenity_prelude::{
Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp, 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::arcaea::chart::{Chart, Song};
use crate::context::{Error, UserContext}; use crate::context::{Error, UserContext};
use crate::user::User; use crate::user::User;
use super::chart::SongCache; use super::chart::SongCache;
use super::rating::{rating_as_fixed, rating_as_float};
use super::score::{Score, ScoringSystem}; use super::score::{Score, ScoringSystem};
// {{{ Create play // {{{ Create play
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CreatePlay { pub struct CreatePlay {
chart_id: u32,
discord_attachment_id: Option<AttachmentId>, discord_attachment_id: Option<AttachmentId>,
// Actual score data // Scoring details
score: Score, score: Score,
zeta_score: Score,
// Optional score details
max_recall: Option<u32>, max_recall: Option<u32>,
far_notes: Option<u32>, far_notes: Option<u32>,
} }
impl CreatePlay { impl CreatePlay {
#[inline] #[inline]
pub fn new(score: Score, chart: &Chart) -> Self { pub fn new(score: Score) -> Self {
Self { Self {
chart_id: chart.id,
discord_attachment_id: None, discord_attachment_id: None,
score, score,
zeta_score: score.to_zeta(chart.note_count as u32),
max_recall: None, max_recall: None,
far_notes: None, far_notes: None,
} }
@ -60,7 +61,7 @@ impl CreatePlay {
} }
// {{{ Save // {{{ Save
pub async fn save(self, ctx: &UserContext, user: &User) -> Result<Play, Error> { pub async fn save(self, ctx: &UserContext, user: &User, chart: &Chart) -> Result<Play, Error> {
let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64); let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);
// {{{ Save current data to play // {{{ Save current data to play
@ -68,16 +69,14 @@ impl CreatePlay {
" "
INSERT INTO plays( INSERT INTO plays(
user_id,chart_id,discord_attachment_id, user_id,chart_id,discord_attachment_id,
score,zeta_score,max_recall,far_notes max_recall,far_notes
) )
VALUES(?,?,?,?,?,?,?) VALUES(?,?,?,?,?)
RETURNING id, created_at RETURNING id, created_at
", ",
user.id, user.id,
self.chart_id, chart.id,
attachment_id, attachment_id,
self.score.0,
self.zeta_score.0,
self.max_recall, self.max_recall,
self.far_notes self.far_notes
) )
@ -85,93 +84,88 @@ impl CreatePlay {
.await?; .await?;
// }}} // }}}
// {{{ Update creation ptt data // {{{ Update creation ptt data
let creation_ptt = get_best_plays( let scores = ScoreCollection::from_standard_score(self.score, chart);
&ctx.db, for system in ScoringSystem::SCORING_SYSTEMS {
&ctx.song_cache, let i = system.to_index();
user, let plays = get_best_plays(&ctx.db, &ctx.song_cache, user.id, system, 30, 30, None)
ScoringSystem::Standard,
30,
30,
)
.await? .await?
.ok() .ok();
.map(|plays| compute_b30_ptt(ScoringSystem::Standard, &plays));
let creation_zeta_ptt = let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) };
get_best_plays(&ctx.db, &ctx.song_cache, user, ScoringSystem::EX, 30, 30)
.await?
.ok()
.map(|plays| compute_b30_ptt(ScoringSystem::EX, &plays));
query!( query!(
" "
UPDATE plays INSERT INTO scores(play_id, score, creation_ptt, scoring_system)
SET VALUES (?,?,?,?)
creation_ptt=?,
creation_zeta_ptt=?
WHERE
id=?
", ",
play.id,
scores.0[i].0,
creation_ptt, creation_ptt,
creation_zeta_ptt, ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i]
play.id
) )
.execute(&ctx.db) .execute(&ctx.db)
.await?; .await?;
}
// }}} // }}}
Ok(Play { Ok(Play {
id: play.id as u32, id: play.id as u32,
created_at: play.created_at, created_at: play.created_at,
chart_id: self.chart_id, chart_id: chart.id,
user_id: user.id, user_id: user.id,
discord_attachment_id: self.discord_attachment_id, scores,
score: self.score,
zeta_score: self.zeta_score,
max_recall: self.max_recall, max_recall: self.max_recall,
far_notes: self.far_notes, far_notes: self.far_notes,
creation_ptt,
creation_zeta_ptt,
}) })
} }
// }}} // }}}
} }
// }}} // }}}
// {{{ DbPlay // {{{ DbPlay
/// Version of `Play` matching the format sqlx expects /// Construct a `Play` from a sqlite return record.
#[derive(Debug, Clone, sqlx::FromRow)] #[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 struct DbPlay {
pub id: i64, pub id: i64,
pub chart_id: i64, pub chart_id: i64,
pub user_id: i64, pub user_id: i64,
pub discord_attachment_id: Option<String>, pub created_at: chrono::NaiveDateTime,
pub score: i64,
pub zeta_score: i64, // Score details
pub max_recall: Option<i64>, pub max_recall: Option<i64>,
pub far_notes: Option<i64>, pub far_notes: Option<i64>,
pub created_at: chrono::NaiveDateTime, pub score: i64,
pub creation_ptt: Option<i64>,
pub creation_zeta_ptt: Option<i64>,
} }
impl DbPlay { // }}}
#[inline] // {{{ Score data
pub fn into_play(self) -> Play { #[derive(Debug, Clone, Copy)]
Play { pub struct ScoreCollection([Score; ScoringSystem::SCORING_SYSTEMS.len()]);
id: self.id as u32,
chart_id: self.chart_id as u32, impl ScoreCollection {
user_id: self.user_id as u32, pub fn from_standard_score(score: Score, chart: &Chart) -> Self {
score: Score(self.score as u32), ScoreCollection(array::from_fn(|i| {
zeta_score: Score(self.zeta_score as u32), score.convert_to(ScoringSystem::SCORING_SYSTEMS[i], chart)
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),
}
} }
} }
// }}} // }}}
@ -179,48 +173,38 @@ impl DbPlay {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Play { pub struct Play {
pub id: u32, pub id: u32,
#[allow(unused)]
pub chart_id: u32, pub chart_id: u32,
pub user_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, pub created_at: chrono::NaiveDateTime,
#[allow(dead_code)] // Score details
pub creation_ptt: Option<i32>, pub max_recall: Option<u32>,
#[allow(dead_code)] pub far_notes: Option<u32>,
pub creation_zeta_ptt: Option<i32>, pub scores: ScoreCollection,
} }
impl Play { impl Play {
// {{{ Query the underlying score // {{{ Query the underlying score
#[inline] #[inline]
pub fn score(&self, system: ScoringSystem) -> Score { pub fn score(&self, system: ScoringSystem) -> Score {
match system { self.scores.0[system.to_index()]
ScoringSystem::Standard => self.score,
ScoringSystem::EX => self.zeta_score,
}
} }
#[inline] #[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) 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 // {{{ Play => distribution
pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> { pub fn distribution(&self, note_count: u32) -> Option<(u32, u32, u32, u32)> {
if let Some(fars) = self.far_notes { 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); let (pures, rem) = units.checked_sub(fars)?.div_rem_euclid(&2);
if rem == 1 { if rem == 1 {
println!("The impossible happened: got an invalid amount of far notes!"); println!("The impossible happened: got an invalid amount of far notes!");
@ -237,8 +221,8 @@ impl Play {
// }}} // }}}
// {{{ Play => status // {{{ Play => status
#[inline] #[inline]
pub fn status(&self, chart: &Chart) -> Option<String> { pub fn status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<String> {
let score = self.score.0; let score = self.score(scoring_system).0;
if score >= 10_000_000 { if score >= 10_000_000 {
if score > chart.note_count + 10_000_000 { if score > chart.note_count + 10_000_000 {
return None; return None;
@ -266,8 +250,8 @@ impl Play {
} }
#[inline] #[inline]
pub fn short_status(&self, chart: &Chart) -> Option<char> { pub fn short_status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<char> {
let score = self.score.0; let score = self.score(scoring_system).0;
if score >= 10_000_000 { if score >= 10_000_000 {
let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?; let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?;
if non_max_pures == 0 { if non_max_pures == 0 {
@ -298,14 +282,18 @@ impl Play {
author: Option<&poise::serenity_prelude::User>, author: Option<&poise::serenity_prelude::User>,
) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> { ) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> {
// {{{ Get previously best score // {{{ Get previously best score
let prev_play = query_as!( let prev_play = query!(
DbPlay,
" "
SELECT * FROM plays SELECT
WHERE user_id=? p.id, p.chart_id, p.user_id, p.created_at,
AND chart_id=? p.max_recall, p.far_notes, s.score
AND created_at<? FROM plays p
ORDER BY score DESC JOIN scores s ON s.play_id = p.id
WHERE s.scoring_system='standard'
AND p.user_id=?
AND p.chart_id=?
AND p.created_at<?
ORDER BY s.score DESC
LIMIT 1 LIMIT 1
", ",
user.id, user.id,
@ -320,13 +308,18 @@ impl Play {
song.title, chart.difficulty song.title, chart.difficulty
) )
})? })?
.map(|p| p.into_play()); .map(|p| play_from_db_record!(chart, p));
let prev_score = prev_play.as_ref().map(|p| p.score); let prev_score = prev_play.as_ref().map(|p| p.score(ScoringSystem::Standard));
let prev_zeta_score = prev_play.as_ref().map(|p| p.zeta_score); let prev_zeta_score = prev_play.as_ref().map(|p| p.score(ScoringSystem::EX));
// }}} // }}}
let attachement_name = format!("{:?}-{:?}-{:?}.png", song.id, self.score.0, index); let attachement_name = format!(
"{:?}-{:?}-{:?}.png",
song.id,
self.score(ScoringSystem::Standard).0,
index
);
let icon_attachement = match chart.cached_jacket.as_ref() { let icon_attachement = match chart.cached_jacket.as_ref() {
Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)), Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)),
None => None, None => None,
@ -337,30 +330,46 @@ impl Play {
"{} [{:?} {}]", "{} [{:?} {}]",
&song.title, chart.difficulty, chart.level &song.title, chart.difficulty, chart.level
)) ))
.field("Score", self.score.display_with_diff(prev_score)?, true)
.field( .field(
"Rating", "Score",
self.score.display_play_rating(prev_score, chart)?, 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, true,
) )
.field("Grade", format!("{}", self.score.grade()), true)
.field( .field(
"ξ-Score", "ξ-Score",
self.zeta_score.display_with_diff(prev_zeta_score)?, self.score(ScoringSystem::EX)
.display_with_diff(prev_zeta_score)?,
true, true,
) )
// {{{ ξ-Rating // {{{ ξ-Rating
.field( .field(
"ξ-Rating", "ξ-Rating",
self.zeta_score self.score(ScoringSystem::EX)
.display_play_rating(prev_zeta_score, chart)?, .display_play_rating(prev_zeta_score, chart)?,
true, true,
) )
// }}} // }}}
.field("ξ-Grade", format!("{}", self.zeta_score.grade()), true) .field(
"ξ-Grade",
format!("{}", self.score(ScoringSystem::EX).grade()),
true,
)
.field( .field(
"Status", "Status",
self.status(chart).unwrap_or("-".to_string()), self.status(ScoringSystem::Standard, chart)
.unwrap_or("-".to_string()),
true, true,
) )
.field( .field(
@ -402,24 +411,33 @@ pub type PlayCollection<'a> = Vec<(Play, &'a Song, &'a Chart)>;
pub async fn get_best_plays<'a>( pub async fn get_best_plays<'a>(
db: &SqlitePool, db: &SqlitePool,
song_cache: &'a SongCache, song_cache: &'a SongCache,
user: &User, user_id: u32,
scoring_system: ScoringSystem, scoring_system: ScoringSystem,
min_amount: usize, min_amount: usize,
max_amount: usize, max_amount: usize,
before: Option<NaiveDateTime>,
) -> Result<Result<PlayCollection<'a>, String>, Error> { ) -> Result<Result<PlayCollection<'a>, String>, Error> {
// {{{ DB data fetching // {{{ DB data fetching
let plays: Vec<DbPlay> = query_as( let plays: Vec<DbPlay> = query_as(
" "
SELECT id, chart_id, user_id, SELECT
created_at, MAX(score) as score, zeta_score, p.id, p.chart_id, p.user_id, p.created_at,
creation_ptt, creation_zeta_ptt, far_notes, max_recall, discord_attachment_id 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 FROM plays p
WHERE user_id = ? JOIN scores s ON s.play_id = p.id
GROUP BY chart_id JOIN scores cs ON cs.play_id = p.id
ORDER BY score DESC 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) .fetch_all(db)
.await?; .await?;
// }}} // }}}
@ -437,8 +455,8 @@ pub async fn get_best_plays<'a>(
let mut plays: Vec<(Play, &Song, &Chart)> = plays let mut plays: Vec<(Play, &Song, &Chart)> = plays
.into_iter() .into_iter()
.map(|play| { .map(|play| {
let play = play.into_play(); let (song, chart) = song_cache.lookup_chart(play.chart_id as u32)?;
let (song, chart) = song_cache.lookup_chart(play.chart_id)?; let play = play_from_db_record!(chart, play);
Ok((play, song, chart)) Ok((play, song, chart))
}) })
.collect::<Result<Vec<_>, Error>>()?; .collect::<Result<Vec<_>, Error>>()?;
@ -451,12 +469,78 @@ pub async fn get_best_plays<'a>(
} }
#[inline] #[inline]
pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> i32 { pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>) -> Rational32 {
plays plays
.iter() .iter()
.map(|(play, _, chart)| play.play_rating(scoring_system, chart.chart_constant)) .map(|(play, _, chart)| play.play_rating(scoring_system, chart.chart_constant))
.sum::<i32>() .sum::<Rational32>()
.checked_div(plays.len() as i32) .checked_div(&Rational32::from_integer(plays.len() as i32))
.unwrap_or(0) .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(())
} }
// }}} // }}}

23
src/arcaea/rating.rs Normal file
View file

@ -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)
}

View file

@ -1,10 +1,13 @@
use std::fmt::{Display, Write}; use std::fmt::{Display, Write};
use num::Rational64; use num::{Rational32, Rational64};
use crate::context::Error; use crate::context::Error;
use super::chart::Chart; use super::{
chart::Chart,
rating::{rating_as_float, rating_from_fixed, Rating},
};
// {{{ Scoring system // {{{ Scoring system
#[derive(Debug, Clone, Copy, poise::ChoiceParameter)] #[derive(Debug, Clone, Copy, poise::ChoiceParameter)]
@ -15,6 +18,18 @@ pub enum ScoringSystem {
EX, 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 { impl Default for ScoringSystem {
fn default() -> Self { fn default() -> Self {
Self::Standard 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 // {{{ Score => Play rating
#[inline] #[inline]
pub fn play_rating(self, chart_constant: u32) -> i32 { pub fn play_rating(self, chart_constant: u32) -> Rating {
chart_constant as i32 rating_from_fixed(chart_constant as i32)
+ if self.0 >= 10_000_000 { + if self.0 >= 10_000_000 {
200 Rational32::from_integer(2)
} else if self.0 >= 9_800_000 { } 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 { } 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<Self>, chart: &Chart) -> Result<String, Error> { pub fn display_play_rating(self, prev: Option<Self>, chart: &Chart) -> Result<String, Error> {
let mut buffer = String::with_capacity(14); 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)?; write!(buffer, "{:.2}", play_rating)?;
if let Some(prev) = prev { 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 { if play_rating >= prev_play_rating {
write!(buffer, " (+{:.2})", play_rating - prev_play_rating)?; write!(buffer, " (+{:.2})", play_rating - prev_play_rating)?;

View file

@ -4,7 +4,7 @@ use sqlx::query;
use crate::{ use crate::{
arcaea::chart::Side, arcaea::chart::Side,
context::{Context, Error}, context::{Context, Error},
get_user, get_user, play_from_db_record,
recognition::fuzzy_song_name::guess_song_and_chart, recognition::fuzzy_song_name::guess_song_and_chart,
}; };
use std::io::Cursor; use std::io::Cursor;
@ -20,13 +20,9 @@ use plotters::{
style::{IntoFont, TextStyle, BLUE, WHITE}, style::{IntoFont, TextStyle, BLUE, WHITE},
}; };
use poise::CreateReply; use poise::CreateReply;
use sqlx::query_as;
use crate::{ use crate::{
arcaea::{ arcaea::score::{Score, ScoringSystem},
play::DbPlay,
score::{Score, ScoringSystem},
},
user::discord_it_to_discord_user, user::discord_it_to_discord_user,
}; };
@ -121,13 +117,18 @@ async fn best(
let user = get_user!(&ctx); let user = get_user!(&ctx);
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
let play = query_as!( let play = query!(
DbPlay,
" "
SELECT * FROM plays SELECT
WHERE user_id=? p.id, p.chart_id, p.user_id, p.created_at,
AND chart_id=? p.max_recall, p.far_notes, s.score
ORDER BY score DESC 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, user.id,
chart.id chart.id
@ -139,8 +140,8 @@ async fn best(
"Could not find any scores for {} [{:?}]", "Could not find any scores for {} [{:?}]",
song.title, chart.difficulty song.title, chart.difficulty
) )
})? })?;
.into_play(); let play = play_from_db_record!(chart, play);
let (embed, attachment) = play let (embed, attachment) = play
.to_embed( .to_embed(
@ -176,13 +177,17 @@ async fn plot(
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?; let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
// SAFETY: we limit the amount of plotted plays to 1000. // SAFETY: we limit the amount of plotted plays to 1000.
let plays = query_as!( let plays = query!(
DbPlay,
" "
SELECT * FROM plays SELECT
WHERE user_id=? p.id, p.chart_id, p.user_id, p.created_at,
AND chart_id=? p.max_recall, p.far_notes, s.score
ORDER BY created_at ASC 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 LIMIT 1000
", ",
user.id, user.id,
@ -204,7 +209,7 @@ async fn plot(
let max_time = plays.iter().map(|p| p.created_at).max().unwrap(); let max_time = plays.iter().map(|p| p.created_at).max().unwrap();
let mut min_score = plays let mut min_score = plays
.iter() .iter()
.map(|p| p.clone().into_play().score(scoring_system)) .map(|p| play_from_db_record!(chart, p).score(scoring_system))
.min() .min()
.unwrap() .unwrap()
.0 as i64; .0 as i64;
@ -228,7 +233,7 @@ async fn plot(
{ {
let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area(); 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) .margin(25)
.caption( .caption(
format!("{} [{:?}]", song.title, chart.difficulty), format!("{} [{:?}]", song.title, chart.difficulty),
@ -241,7 +246,7 @@ async fn plot(
min_score..max_score, min_score..max_score,
)?; )?;
chart chart_buider
.configure_mesh() .configure_mesh()
.light_line_style(WHITE) .light_line_style(WHITE)
.y_label_formatter(&|s| format!("{}", Score(*s as u32))) .y_label_formatter(&|s| format!("{}", Score(*s as u32)))
@ -261,7 +266,7 @@ async fn plot(
.map(|play| { .map(|play| {
( (
play.created_at.and_utc().timestamp_millis(), play.created_at.and_utc().timestamp_millis(),
play.into_play().score(scoring_system), play_from_db_record!(chart, play).score(scoring_system),
) )
}) })
.collect(); .collect();
@ -269,12 +274,12 @@ async fn plot(
points.sort(); points.sort();
points.dedup(); points.dedup();
chart.draw_series(LineSeries::new( chart_buider.draw_series(LineSeries::new(
points.iter().map(|(t, s)| (*t, s.0 as i64)), points.iter().map(|(t, s)| (*t, s.0 as i64)),
&BLUE, &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)) Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE))
}))?; }))?;
root.present()?; root.present()?;

View file

@ -1,11 +1,11 @@
use std::time::Instant; use std::time::Instant;
use crate::arcaea::play::{CreatePlay, Play}; use crate::arcaea::play::CreatePlay;
use crate::arcaea::score::Score; use crate::arcaea::score::Score;
use crate::context::{Context, Error}; use crate::context::{Context, Error};
use crate::recognition::recognize::{ImageAnalyzer, ScoreKind}; use crate::recognition::recognize::{ImageAnalyzer, ScoreKind};
use crate::user::{discord_it_to_discord_user, User}; 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 image::DynamicImage;
use poise::serenity_prelude::futures::future::join_all; use poise::serenity_prelude::futures::future::join_all;
use poise::serenity_prelude::CreateMessage; use poise::serenity_prelude::CreateMessage;
@ -117,11 +117,11 @@ pub async fn magic(
let maybe_fars = let maybe_fars =
Score::resolve_distibution_ambiguities(score, note_distribution, chart.note_count); 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_attachment(file)
.with_fars(maybe_fars) .with_fars(maybe_fars)
.with_max_recall(max_recall) .with_max_recall(max_recall)
.save(&ctx.data(), &user) .save(&ctx.data(), &user, &chart)
.await?; .await?;
// }}} // }}}
// }}} // }}}
@ -220,12 +220,16 @@ pub async fn show(
let res = query!( let res = query!(
" "
SELECT SELECT
p.id,p.chart_id,p.user_id,p.score,p.zeta_score, p.id, p.chart_id, p.user_id, p.created_at,
p.max_recall,p.created_at,p.far_notes, p.max_recall, p.far_notes, s.score,
u.discord_id 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 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 id
) )
@ -233,24 +237,12 @@ pub async fn show(
.await .await
.map_err(|_| format!("Could not find play with id {}", id))?; .map_err(|_| format!("Could not find play with id {}", id))?;
let play = Play { let (song, chart) = ctx.data().song_cache.lookup_chart(res.chart_id as u32)?;
id: res.id as u32, let play = play_from_db_record!(chart, res);
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 author = discord_it_to_discord_user(&ctx, &res.discord_id).await?; 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 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 let (embed, attachment) = play
.to_embed(&ctx.data().db, &user, song, chart, i, Some(&author)) .to_embed(&ctx.data().db, &user, song, chart, i, Some(&author))
.await?; .await?;

View file

@ -13,6 +13,7 @@ use crate::{
chart::Level, chart::Level,
jacket::BITMAP_IMAGE_SIZE, jacket::BITMAP_IMAGE_SIZE,
play::{compute_b30_ptt, get_best_plays}, play::{compute_b30_ptt, get_best_plays},
rating::rating_as_float,
score::ScoringSystem, score::ScoringSystem,
}, },
assert_is_pookie, assert_is_pookie,
@ -55,14 +56,15 @@ async fn best_plays(
get_best_plays( get_best_plays(
&user_ctx.db, &user_ctx.db,
&user_ctx.song_cache, &user_ctx.song_cache,
&user, user.id,
scoring_system, scoring_system,
if require_full { if require_full {
grid_size.0 * grid_size.1 grid_size.0 * grid_size.1
} else { } else {
grid_size.0 * (grid_size.1.max(1) - 1) + 1 grid_size.0 * (grid_size.1.max(1) - 1) + 1
} as usize, } as usize,
(grid_size.0 * grid_size.1) as usize (grid_size.0 * grid_size.1) as usize,
None
) )
.await? .await?
); );
@ -287,7 +289,7 @@ async fn best_plays(
// }}} // }}}
// {{{ Display status text // {{{ Display status text
with_font(&EXO_FONT, |faces| { 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!( format!(
"Could not get status for score {}", "Could not get status for score {}",
play.score(scoring_system) play.score(scoring_system)
@ -379,7 +381,7 @@ async fn best_plays(
style, style,
&format!( &format!(
"{:.2}", "{:.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")) .attachment(CreateAttachment::bytes(out_buffer, "b30.png"))
.content(format!( .content(format!(
"Your ptt is {:.2}", "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?; ctx.send(reply).await?;

View file

@ -5,6 +5,7 @@
#![feature(async_closure)] #![feature(async_closure)]
#![feature(try_blocks)] #![feature(try_blocks)]
#![feature(thread_local)] #![feature(thread_local)]
#![feature(generic_arg_infer)]
mod arcaea; mod arcaea;
mod assets; mod assets;
@ -18,6 +19,7 @@ mod time;
mod transform; mod transform;
mod user; mod user;
use arcaea::play::generate_missing_scores;
use assets::get_data_dir; use assets::get_data_dir;
use context::{Error, UserContext}; use context::{Error, UserContext};
use poise::serenity_prelude::{self as serenity}; use poise::serenity_prelude::{self as serenity};
@ -89,6 +91,12 @@ async fn main() {
poise::builtins::register_globally(ctx, &framework.options().commands).await?; poise::builtins::register_globally(ctx, &framework.options().commands).await?;
let ctx = UserContext::new(pool).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) Ok(ctx)
}) })
}) })