Generalize code to more scoring systems
This commit is contained in:
parent
62949004f2
commit
4ed3fe276b
15
schema.sql
15
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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,4 +2,5 @@ pub mod achievement;
|
|||
pub mod chart;
|
||||
pub mod jacket;
|
||||
pub mod play;
|
||||
pub mod rating;
|
||||
pub mod score;
|
||||
|
|
|
@ -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<AttachmentId>,
|
||||
|
||||
// Actual score data
|
||||
// Scoring details
|
||||
score: Score,
|
||||
zeta_score: Score,
|
||||
|
||||
// Optional score details
|
||||
max_recall: Option<u32>,
|
||||
far_notes: Option<u32>,
|
||||
}
|
||||
|
||||
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<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);
|
||||
|
||||
// {{{ 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<String>,
|
||||
pub score: i64,
|
||||
pub zeta_score: i64,
|
||||
pub created_at: chrono::NaiveDateTime,
|
||||
|
||||
// Score details
|
||||
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>,
|
||||
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<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(dead_code)]
|
||||
pub creation_ptt: Option<i32>,
|
||||
#[allow(dead_code)]
|
||||
pub creation_zeta_ptt: Option<i32>,
|
||||
// Score details
|
||||
pub max_recall: Option<u32>,
|
||||
pub far_notes: Option<u32>,
|
||||
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<String> {
|
||||
let score = self.score.0;
|
||||
pub fn status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<String> {
|
||||
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<char> {
|
||||
let score = self.score.0;
|
||||
pub fn short_status(&self, scoring_system: ScoringSystem, chart: &Chart) -> Option<char> {
|
||||
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<CreateAttachment>), 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<?
|
||||
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=?
|
||||
AND p.created_at<?
|
||||
ORDER BY s.score DESC
|
||||
LIMIT 1
|
||||
",
|
||||
user.id,
|
||||
|
@ -320,13 +308,18 @@ impl Play {
|
|||
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_zeta_score = prev_play.as_ref().map(|p| p.zeta_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.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() {
|
||||
Some(jacket) => 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<NaiveDateTime>,
|
||||
) -> Result<Result<PlayCollection<'a>, String>, Error> {
|
||||
// {{{ DB data fetching
|
||||
let plays: Vec<DbPlay> = 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::<Result<Vec<_>, 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::<i32>()
|
||||
.checked_div(plays.len() as i32)
|
||||
.unwrap_or(0)
|
||||
.sum::<Rational32>()
|
||||
.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(())
|
||||
}
|
||||
// }}}
|
||||
|
|
23
src/arcaea/rating.rs
Normal file
23
src/arcaea/rating.rs
Normal 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)
|
||||
}
|
|
@ -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<Self>, chart: &Chart) -> Result<String, Error> {
|
||||
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)?;
|
||||
|
|
|
@ -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()?;
|
||||
|
|
|
@ -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?;
|
||||
|
|
|
@ -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?;
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue