604 lines
14 KiB
Rust
604 lines
14 KiB
Rust
// {{{ Imports
|
|
use std::array;
|
|
use std::num::NonZeroU64;
|
|
|
|
use anyhow::anyhow;
|
|
use anyhow::Context;
|
|
use chrono::NaiveDateTime;
|
|
use chrono::Utc;
|
|
use num::traits::Euclid;
|
|
use num::CheckedDiv;
|
|
use num::Rational32;
|
|
use num::Zero;
|
|
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp};
|
|
use rusqlite::Row;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
|
|
use crate::arcaea::chart::{Chart, Song};
|
|
use crate::commands::DataSource;
|
|
use crate::context::ErrorKind;
|
|
use crate::context::TagError;
|
|
use crate::context::TaggedError;
|
|
use crate::context::{Error, UserContext};
|
|
use crate::user::User;
|
|
|
|
use super::rating::{rating_as_fixed, rating_as_float};
|
|
use super::score::{Score, ScoringSystem};
|
|
// }}}
|
|
|
|
// {{{ Create play
|
|
#[derive(Debug, Clone)]
|
|
pub struct CreatePlay {
|
|
discord_attachment_id: Option<NonZeroU64>,
|
|
|
|
// Scoring details
|
|
score: Score,
|
|
max_recall: Option<u32>,
|
|
far_notes: Option<u32>,
|
|
}
|
|
|
|
impl CreatePlay {
|
|
#[inline]
|
|
pub fn new(score: Score) -> Self {
|
|
Self {
|
|
discord_attachment_id: None,
|
|
score,
|
|
max_recall: None,
|
|
far_notes: None,
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub fn with_attachment(mut self, attachment_id: NonZeroU64) -> Self {
|
|
self.discord_attachment_id = Some(attachment_id);
|
|
self
|
|
}
|
|
|
|
#[inline]
|
|
pub fn with_fars(mut self, far_count: Option<u32>) -> Self {
|
|
self.far_notes = far_count;
|
|
self
|
|
}
|
|
|
|
#[inline]
|
|
pub fn with_max_recall(mut self, max_recall: Option<u32>) -> Self {
|
|
self.max_recall = max_recall;
|
|
self
|
|
}
|
|
|
|
// {{{ Save
|
|
pub async fn save(
|
|
self,
|
|
ctx: &UserContext,
|
|
user: &User,
|
|
chart: &Chart,
|
|
) -> Result<Play, TaggedError> {
|
|
let conn = ctx.db.get()?;
|
|
let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);
|
|
|
|
// {{{ Save current data to play
|
|
let (id, created_at) = conn
|
|
.prepare_cached(
|
|
"
|
|
INSERT INTO plays(
|
|
user_id,chart_id,discord_attachment_id,
|
|
max_recall,far_notes
|
|
)
|
|
VALUES(?,?,?,?,?)
|
|
RETURNING id, created_at
|
|
",
|
|
)?
|
|
.query_row(
|
|
(
|
|
user.id,
|
|
chart.id,
|
|
attachment_id,
|
|
self.max_recall,
|
|
self.far_notes,
|
|
),
|
|
|row| {
|
|
Ok((
|
|
row.get("id")?,
|
|
default_while_testing(row.get("created_at")?),
|
|
))
|
|
},
|
|
)
|
|
.with_context(|| {
|
|
format!(
|
|
"Could not create play {self:?} with user {:?} and chart {:?}",
|
|
user.id, chart.id
|
|
)
|
|
})?;
|
|
// }}}
|
|
// {{{ Update creation ptt data
|
|
let scores = ScoreCollection::from_standard_score(self.score, chart);
|
|
|
|
for system in ScoringSystem::SCORING_SYSTEMS {
|
|
let i = system.to_index();
|
|
let creation_ptt = try_compute_ptt(ctx, user, DataSource::Local, system, None).await?;
|
|
|
|
conn.prepare_cached(
|
|
"
|
|
INSERT INTO scores(play_id, score, creation_ptt, scoring_system)
|
|
VALUES (?,?,?,?)
|
|
",
|
|
)?
|
|
.execute((
|
|
id,
|
|
scores.0[i].0,
|
|
creation_ptt,
|
|
ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i],
|
|
))?;
|
|
}
|
|
|
|
// }}}
|
|
|
|
Ok(Play {
|
|
id,
|
|
created_at,
|
|
scores,
|
|
chart_id: chart.id,
|
|
user_id: user.id,
|
|
max_recall: self.max_recall,
|
|
far_notes: self.far_notes,
|
|
})
|
|
}
|
|
// }}}
|
|
}
|
|
// }}}
|
|
// {{{ Score data
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
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)
|
|
}))
|
|
}
|
|
}
|
|
// }}}
|
|
// {{{ Play
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Play {
|
|
pub id: u32,
|
|
#[allow(unused)]
|
|
pub chart_id: u32,
|
|
pub user_id: u32,
|
|
pub created_at: chrono::NaiveDateTime,
|
|
|
|
// Score details
|
|
pub max_recall: Option<u32>,
|
|
pub far_notes: Option<u32>,
|
|
pub scores: ScoreCollection,
|
|
}
|
|
|
|
/// Timestamps and other similar values break golden testing.
|
|
/// This function can be used to replace such values with [Default::default]
|
|
/// while testing.
|
|
#[inline]
|
|
fn default_while_testing<D: Default>(v: D) -> D {
|
|
if cfg!(test) {
|
|
D::default()
|
|
} else {
|
|
v
|
|
}
|
|
}
|
|
|
|
impl Play {
|
|
// {{{ Row parsing
|
|
#[inline]
|
|
pub fn from_sql(chart: &Chart, row: &Row) -> Result<Self, rusqlite::Error> {
|
|
Ok(Play {
|
|
id: row.get("id")?,
|
|
chart_id: row.get("chart_id")?,
|
|
user_id: row.get("user_id")?,
|
|
max_recall: row.get("max_recall")?,
|
|
far_notes: row.get("far_notes")?,
|
|
scores: ScoreCollection::from_standard_score(Score(row.get("score")?), chart),
|
|
created_at: default_while_testing(row.get("created_at")?),
|
|
})
|
|
}
|
|
// }}}
|
|
// {{{ Query the underlying score
|
|
#[inline]
|
|
pub fn score(&self, system: ScoringSystem) -> Score {
|
|
self.scores.0[system.to_index()]
|
|
}
|
|
|
|
#[inline]
|
|
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(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!");
|
|
return None;
|
|
}
|
|
|
|
let lost = note_count.checked_sub(fars + pures)?;
|
|
let non_max_pures = pures.checked_sub(shinies)?;
|
|
Some((shinies, non_max_pures, fars, lost))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
// }}}
|
|
// {{{ Play => status
|
|
#[inline]
|
|
pub fn status(&self, 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;
|
|
}
|
|
|
|
let non_max_pures = (chart.note_count + 10_000_000).checked_sub(score)?;
|
|
if non_max_pures == 0 {
|
|
Some("MPM".to_string())
|
|
} else {
|
|
Some(format!("PM (-{})", non_max_pures))
|
|
}
|
|
} else if let Some(distribution) = self.distribution(chart.note_count) {
|
|
// if no lost notes...
|
|
if distribution.3 == 0 {
|
|
Some(format!("FR (-{}/-{})", distribution.1, distribution.2))
|
|
} else {
|
|
Some(format!(
|
|
"C (-{}/-{}/-{})",
|
|
distribution.1, distribution.2, distribution.3
|
|
))
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
pub fn short_status(&self, 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 {
|
|
Some('M')
|
|
} else {
|
|
Some('P')
|
|
}
|
|
} else if let Some((_, _, _, 0)) = self.distribution(chart.note_count) {
|
|
Some('F')
|
|
} else {
|
|
Some('C')
|
|
}
|
|
}
|
|
// }}}
|
|
// {{{ Play to embed
|
|
/// Creates a discord embed for this play.
|
|
///
|
|
/// The `index` variable is only used to create distinct filenames.
|
|
pub fn to_embed(
|
|
&self,
|
|
ctx: &UserContext,
|
|
user: &User,
|
|
song: &Song,
|
|
chart: &Chart,
|
|
index: usize,
|
|
author: Option<&poise::serenity_prelude::User>,
|
|
) -> Result<(CreateEmbed, Option<CreateAttachment>), Error> {
|
|
// {{{ Get previously best score
|
|
let prev_play = ctx
|
|
.db
|
|
.get()?
|
|
.prepare_cached(
|
|
"
|
|
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
|
|
",
|
|
)?
|
|
.query_row((user.id, chart.id, self.created_at), |row| {
|
|
Self::from_sql(chart, row)
|
|
})
|
|
.ok();
|
|
|
|
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(ScoringSystem::Standard).0,
|
|
index
|
|
);
|
|
let icon_attachement = chart
|
|
.cached_jacket
|
|
.map(|jacket| CreateAttachment::bytes(jacket.raw, &attachement_name));
|
|
|
|
let mut embed = CreateEmbed::default()
|
|
.title(format!(
|
|
"{} [{:?} {}]",
|
|
&song.title, chart.difficulty, chart.level
|
|
))
|
|
.field(
|
|
"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(
|
|
"ξ-Score",
|
|
self.score(ScoringSystem::EX)
|
|
.display_with_diff(prev_zeta_score)?,
|
|
true,
|
|
)
|
|
// {{{ ξ-Rating
|
|
.field(
|
|
"ξ-Rating",
|
|
self.score(ScoringSystem::EX)
|
|
.display_play_rating(prev_zeta_score, chart)?,
|
|
true,
|
|
)
|
|
// }}}
|
|
.field(
|
|
"ξ-Grade",
|
|
format!("{}", self.score(ScoringSystem::EX).grade()),
|
|
true,
|
|
)
|
|
.field(
|
|
"Status",
|
|
self.status(ScoringSystem::Standard, chart)
|
|
.unwrap_or("-".to_string()),
|
|
true,
|
|
)
|
|
.field(
|
|
"Max recall",
|
|
if let Some(max_recall) = self.max_recall {
|
|
format!("{}", max_recall)
|
|
} else {
|
|
"-".to_string()
|
|
},
|
|
true,
|
|
);
|
|
|
|
if self.id != 0 {
|
|
embed = embed.field("ID", format!("{}", self.id), true);
|
|
}
|
|
|
|
if icon_attachement.is_some() {
|
|
embed = embed.thumbnail(format!("attachment://{}", &attachement_name));
|
|
}
|
|
|
|
if let Some(user) = author {
|
|
let mut embed_author = CreateEmbedAuthor::new(&user.name);
|
|
if let Some(url) = user.avatar_url() {
|
|
embed_author = embed_author.icon_url(url);
|
|
}
|
|
|
|
embed = embed
|
|
.timestamp(Timestamp::from_millis(
|
|
self.created_at.and_utc().timestamp_millis(),
|
|
)?)
|
|
.author(embed_author);
|
|
}
|
|
|
|
Ok((embed, icon_attachement))
|
|
}
|
|
// }}}
|
|
}
|
|
// }}}
|
|
// {{{ General functions
|
|
pub type PlayCollection<'a> = Vec<(Play, &'a Song, &'a Chart)>;
|
|
|
|
pub async fn get_best_plays<'a>(
|
|
ctx: &'a UserContext,
|
|
user: &User,
|
|
source: DataSource,
|
|
scoring_system: ScoringSystem,
|
|
min_amount: usize,
|
|
max_amount: usize,
|
|
before: Option<NaiveDateTime>,
|
|
) -> Result<PlayCollection<'a>, TaggedError> {
|
|
// {{{ DB data fetching
|
|
let conn = ctx.db.get()?;
|
|
let mut plays = match source {
|
|
DataSource::Local => {
|
|
// {{{ Fetch plays from db
|
|
conn.prepare_cached(
|
|
"
|
|
SELECT
|
|
p.id, p.chart_id, p.user_id, p.created_at,
|
|
p.max_recall, p.far_notes, s.score,
|
|
MAX(cs.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
|
|
",
|
|
)?
|
|
.query_and_then(
|
|
(
|
|
ScoringSystem::SCORING_SYSTEM_DB_STRINGS[scoring_system.to_index()],
|
|
user.id,
|
|
before.unwrap_or_else(|| Utc::now().naive_utc()),
|
|
),
|
|
|row| {
|
|
let (song, chart) = ctx.song_cache.lookup_chart(row.get("chart_id")?)?;
|
|
let play = Play::from_sql(chart, row)?;
|
|
Ok((play, song, chart))
|
|
},
|
|
)?
|
|
.collect::<Result<Vec<_>, Error>>()?
|
|
// }}}
|
|
}
|
|
DataSource::Server => {
|
|
// {{{ Fetch data remotely
|
|
crate::private_server::best(ctx, user, crate::private_server::BestOptions::default())
|
|
.await?
|
|
.into_iter()
|
|
.map(|play| {
|
|
let (song, chart) = ctx.song_cache.lookup_chart(play.chart_id)?;
|
|
Ok((play, song, chart))
|
|
})
|
|
.collect::<Result<Vec<_>, Error>>()?
|
|
// }}}
|
|
}
|
|
};
|
|
// }}}
|
|
|
|
if plays.len() < min_amount {
|
|
return Err(anyhow!(
|
|
"Not enough plays found ({} out of a minimum of {min_amount})",
|
|
plays.len()
|
|
)
|
|
.tag(crate::context::ErrorKind::User));
|
|
}
|
|
|
|
// {{{ B30 computation
|
|
plays.sort_by_key(|(play, _, chart)| -play.play_rating(scoring_system, chart.chart_constant));
|
|
plays.truncate(max_amount);
|
|
// }}}
|
|
|
|
Ok(plays)
|
|
}
|
|
|
|
/// Compute the current ptt of a given user.
|
|
///
|
|
/// This is similar to directly calling [get_best_plays] and then passing the
|
|
/// result into [compute_b30_ptt], except any user errors (i.e.: not enough
|
|
/// plays available) get turned into [None] values.
|
|
pub async fn try_compute_ptt(
|
|
ctx: &UserContext,
|
|
user: &User,
|
|
source: DataSource,
|
|
system: ScoringSystem,
|
|
before: Option<NaiveDateTime>,
|
|
) -> Result<Option<i32>, Error> {
|
|
match get_best_plays(ctx, user, source, system, 30, 30, before).await {
|
|
Err(err) => match err.kind {
|
|
ErrorKind::User => Ok(None),
|
|
ErrorKind::Internal => Err(err.error),
|
|
},
|
|
Ok(plays) => Ok(Some(rating_as_fixed(compute_b30_ptt(system, &plays)))),
|
|
}
|
|
}
|
|
|
|
#[inline]
|
|
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::<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 conn = ctx.db.get()?;
|
|
let mut query = conn.prepare_cached(
|
|
"
|
|
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
|
|
",
|
|
)?;
|
|
|
|
let plays = query.query_and_then((), |row| -> Result<_, Error> {
|
|
let (_, chart) = ctx.song_cache.lookup_chart(row.get("chart_id")?)?;
|
|
let play = Play::from_sql(chart, row)?;
|
|
Ok(play)
|
|
})?;
|
|
|
|
let mut i = 0;
|
|
|
|
for play in plays {
|
|
let play = play?;
|
|
for system in ScoringSystem::SCORING_SYSTEMS {
|
|
let i = system.to_index();
|
|
let creation_ptt = try_compute_ptt(
|
|
ctx,
|
|
&User {
|
|
id: play.user_id,
|
|
..Default::default()
|
|
},
|
|
DataSource::Local,
|
|
system,
|
|
Some(play.created_at),
|
|
)
|
|
.await?;
|
|
|
|
let raw_score = play.scores.0[i].0;
|
|
|
|
conn.prepare_cached(
|
|
"
|
|
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
|
|
",
|
|
)?
|
|
.execute((
|
|
play.id,
|
|
raw_score,
|
|
creation_ptt,
|
|
ScoringSystem::SCORING_SYSTEM_DB_STRINGS[i],
|
|
))?;
|
|
}
|
|
|
|
i += 1;
|
|
println!("Processed {i} plays");
|
|
}
|
|
Ok(())
|
|
}
|
|
// }}}
|
|
// {{{ Play + chart + song triplet
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct PlayWithDetails {
|
|
pub play: Play,
|
|
pub song: Song,
|
|
pub chart: Chart,
|
|
}
|
|
// }}}
|