Start implementing achievement system
This commit is contained in:
parent
5c062df309
commit
62949004f2
408
src/arcaea/achievement.rs
Normal file
408
src/arcaea/achievement.rs
Normal file
|
@ -0,0 +1,408 @@
|
||||||
|
use image::RgbaImage;
|
||||||
|
use sqlx::query;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
assets::get_data_dir,
|
||||||
|
context::{Error, UserContext},
|
||||||
|
user::User,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
chart::{Difficulty, Level},
|
||||||
|
play::get_best_plays,
|
||||||
|
score::{Grade, ScoringSystem},
|
||||||
|
};
|
||||||
|
|
||||||
|
// {{{ Goal
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum Goal {
|
||||||
|
/// PM X FTR<=charts
|
||||||
|
PMCount(usize),
|
||||||
|
/// PM a given number of packs
|
||||||
|
PMPacks(usize),
|
||||||
|
/// PM at least one song of each level up to a given one
|
||||||
|
PMRelay(Level),
|
||||||
|
/// Reach a given b30 ptt
|
||||||
|
PTT(u32),
|
||||||
|
/// Get a given grade or better on everything you own of a given level,
|
||||||
|
/// with a minum of X owned charts.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
GradeEntireLevel(Grade, Level, usize),
|
||||||
|
/// Submit at least a given number of plays
|
||||||
|
SubmitPlays(usize),
|
||||||
|
/// PM the same song on all difficulties up to a given one
|
||||||
|
MultiDifficultyPM(Difficulty),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Goal {
|
||||||
|
// {{{ Texture names
|
||||||
|
#[inline]
|
||||||
|
pub fn texture_name(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::PMCount(count) => format!("pm_count_{count:0>3}"),
|
||||||
|
Self::PMPacks(count) => format!("pm_packs_{count:0>3}"),
|
||||||
|
Self::PMRelay(level) => format!("pm_relay_{level}"),
|
||||||
|
Self::PTT(min) => format!("ptt_min_{min:0>4}"),
|
||||||
|
Self::GradeEntireLevel(grade, level, _) => format!("grade_{grade}_level_{level}"),
|
||||||
|
Self::SubmitPlays(count) => format!("play_count_{count:0>6}"),
|
||||||
|
Self::MultiDifficultyPM(difficulty) => format!("multi_pm_{difficulty}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Difficulty
|
||||||
|
#[inline]
|
||||||
|
pub fn difficulty(&self) -> Difficulty {
|
||||||
|
use Difficulty::*;
|
||||||
|
use Grade::*;
|
||||||
|
use Level::*;
|
||||||
|
|
||||||
|
match *self {
|
||||||
|
Self::PMCount(count) if count < 25 => PST,
|
||||||
|
Self::PMCount(count) if count < 100 => PRS,
|
||||||
|
Self::PMCount(count) if count < 200 => FTR,
|
||||||
|
Self::PMCount(count) if count < 350 => ETR,
|
||||||
|
Self::PMCount(_) => BYD,
|
||||||
|
Self::PMPacks(count) if count < 5 => PRS,
|
||||||
|
Self::PMPacks(count) if count < 10 => FTR,
|
||||||
|
Self::PMPacks(count) if count < 25 => ETR,
|
||||||
|
Self::PMPacks(_) => BYD,
|
||||||
|
Self::PMRelay(level) if level < Nine => PST,
|
||||||
|
Self::PMRelay(level) if level < Ten => PRS,
|
||||||
|
Self::PMRelay(level) if level < Eleven => FTR,
|
||||||
|
Self::PMRelay(level) if level < Twelve => ETR,
|
||||||
|
Self::PMRelay(_) => BYD,
|
||||||
|
Self::PTT(amount) if amount < 1100 => PST,
|
||||||
|
Self::PTT(amount) if amount < 1200 => PRS,
|
||||||
|
Self::PTT(amount) if amount < 1250 => FTR,
|
||||||
|
Self::PTT(amount) if amount < 1300 => ETR,
|
||||||
|
Self::PTT(_) => BYD,
|
||||||
|
Self::GradeEntireLevel(EXP, level, _) if level < Eight => PST,
|
||||||
|
Self::GradeEntireLevel(EX, level, _) if level < Nine => PST,
|
||||||
|
Self::GradeEntireLevel(EXP, level, _) if level < Nine => PRS,
|
||||||
|
Self::GradeEntireLevel(EX, level, _) if level < Ten => PRS,
|
||||||
|
Self::GradeEntireLevel(EXP, level, _) if level < Ten => FTR,
|
||||||
|
Self::GradeEntireLevel(EX, level, _) if level < Eleven => FTR,
|
||||||
|
Self::GradeEntireLevel(EXP, level, _) if level < Eleven => ETR,
|
||||||
|
Self::GradeEntireLevel(EX, level, _) if level < Twelve => ETR,
|
||||||
|
Self::GradeEntireLevel(EXP, _, _) => BYD,
|
||||||
|
Self::GradeEntireLevel(EX, _, _) => BYD,
|
||||||
|
Self::GradeEntireLevel(_, _, _) => PST,
|
||||||
|
Self::SubmitPlays(count) if count < 500 => PST,
|
||||||
|
Self::SubmitPlays(count) if count < 2500 => PRS,
|
||||||
|
Self::SubmitPlays(count) if count < 5000 => FTR,
|
||||||
|
Self::SubmitPlays(count) if count < 10000 => ETR,
|
||||||
|
Self::SubmitPlays(_) => BYD,
|
||||||
|
Self::MultiDifficultyPM(ETR) => FTR,
|
||||||
|
Self::MultiDifficultyPM(BYD) => FTR,
|
||||||
|
Self::MultiDifficultyPM(FTR) => PRS,
|
||||||
|
Self::MultiDifficultyPM(_) => PST,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ GoalStats
|
||||||
|
/// Stats collected in order to efficiently compute whether
|
||||||
|
/// a set of achievements were completed.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct GoalStats {
|
||||||
|
pm_count: usize,
|
||||||
|
pmed_packs: usize,
|
||||||
|
peak_pm_relay: Option<Level>,
|
||||||
|
peak_ptt: u32,
|
||||||
|
per_level_lowest_grades: [(Grade, usize); Level::LEVELS.len()],
|
||||||
|
play_count: usize,
|
||||||
|
multi_difficulty_pm_table: [bool; Difficulty::DIFFICULTIES.len()],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GoalStats {
|
||||||
|
pub async fn make(
|
||||||
|
ctx: &UserContext,
|
||||||
|
user: &User,
|
||||||
|
scoring_system: ScoringSystem,
|
||||||
|
) -> Result<Self, Error> {
|
||||||
|
let plays = get_best_plays(
|
||||||
|
&ctx.db,
|
||||||
|
&ctx.song_cache,
|
||||||
|
user,
|
||||||
|
scoring_system,
|
||||||
|
0,
|
||||||
|
usize::MAX,
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
|
||||||
|
// {{{ PM count
|
||||||
|
let pm_count = plays
|
||||||
|
.iter()
|
||||||
|
.filter(|(play, _, chart)| {
|
||||||
|
play.score(scoring_system).0 >= 10_000_000 && chart.difficulty >= Difficulty::FTR
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
// }}}
|
||||||
|
// {{{ Play count
|
||||||
|
let play_count = query!(
|
||||||
|
"SELECT count() as count FROM plays WHERE user_id=?",
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ok_or_else(|| "No ptt history data found")? as u32;
|
||||||
|
// }}}
|
||||||
|
// {{{ Peak PM relay
|
||||||
|
let peak_pm_relay = {
|
||||||
|
let mut pm_checklist = [false; Level::LEVELS.len()];
|
||||||
|
for (play, _, chart) in &plays {
|
||||||
|
if play.score(scoring_system).is_pm() {
|
||||||
|
pm_checklist[chart.level.to_index()] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pm_checklist
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.find(|(_, has_pm)| !*has_pm)
|
||||||
|
.map_or(Some(Level::Twelve), |(i, _)| {
|
||||||
|
Level::LEVELS.get(i.checked_sub(1)?).copied()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// }}}
|
||||||
|
// {{{ Per level lowest grades
|
||||||
|
let mut per_level_lowest_grades = [(Grade::EXP, 0); Level::LEVELS.len()];
|
||||||
|
for (play, _, chart) in plays {
|
||||||
|
let element = &mut per_level_lowest_grades[chart.level.to_index()];
|
||||||
|
*element = (
|
||||||
|
element.0.min(play.score(scoring_system).grade()),
|
||||||
|
element.1 + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
Ok(GoalStats {
|
||||||
|
pm_count,
|
||||||
|
play_count,
|
||||||
|
peak_ptt,
|
||||||
|
peak_pm_relay,
|
||||||
|
per_level_lowest_grades,
|
||||||
|
pmed_packs: 0,
|
||||||
|
multi_difficulty_pm_table: [false; Difficulty::DIFFICULTIES.len()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Achievement
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct Achievement {
|
||||||
|
pub goal: Goal,
|
||||||
|
pub texture: &'static RgbaImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Achievement {
|
||||||
|
pub fn new(goal: Goal) -> Self {
|
||||||
|
let texture_name = goal.texture_name();
|
||||||
|
Self {
|
||||||
|
goal,
|
||||||
|
texture: Box::leak(Box::new(
|
||||||
|
image::open(
|
||||||
|
get_data_dir()
|
||||||
|
.join("achievements")
|
||||||
|
.join(format!("{texture_name}.png")),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
panic!("Cannot read texture `{texture_name}` for achievement {goal:?}")
|
||||||
|
})
|
||||||
|
.into_rgba8(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// }}}
|
||||||
|
// {{{ Achievement towers
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AchievementTower {
|
||||||
|
pub achievements: Vec<Achievement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AchievementTower {
|
||||||
|
pub fn new(achievements: Vec<Achievement>) -> Self {
|
||||||
|
Self { achievements }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct AchievementTowers {
|
||||||
|
pub towers: Vec<AchievementTower>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AchievementTowers {
|
||||||
|
// {{{ Construct towers
|
||||||
|
fn default() -> Self {
|
||||||
|
use Difficulty::*;
|
||||||
|
use Goal::*;
|
||||||
|
use Grade::*;
|
||||||
|
use Level::*;
|
||||||
|
|
||||||
|
// {{{ PM count tower
|
||||||
|
let pm_count_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(PMCount(1)),
|
||||||
|
Achievement::new(PMCount(5)),
|
||||||
|
Achievement::new(PMCount(10)),
|
||||||
|
Achievement::new(PMCount(20)),
|
||||||
|
Achievement::new(PMCount(30)),
|
||||||
|
Achievement::new(PMCount(40)),
|
||||||
|
Achievement::new(PMCount(50)),
|
||||||
|
Achievement::new(PMCount(75)),
|
||||||
|
Achievement::new(PMCount(100)),
|
||||||
|
Achievement::new(PMCount(125)),
|
||||||
|
Achievement::new(PMCount(150)),
|
||||||
|
Achievement::new(PMCount(175)),
|
||||||
|
Achievement::new(PMCount(200)),
|
||||||
|
Achievement::new(PMCount(250)),
|
||||||
|
Achievement::new(PMCount(300)),
|
||||||
|
Achievement::new(PMCount(350)),
|
||||||
|
Achievement::new(PMCount(400)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
// {{{ PM pack tower
|
||||||
|
let pm_pack_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(PMPacks(1)),
|
||||||
|
Achievement::new(PMPacks(3)),
|
||||||
|
Achievement::new(PMPacks(5)),
|
||||||
|
Achievement::new(PMPacks(7)),
|
||||||
|
Achievement::new(PMPacks(10)),
|
||||||
|
Achievement::new(PMPacks(15)),
|
||||||
|
Achievement::new(PMPacks(20)),
|
||||||
|
Achievement::new(PMPacks(25)),
|
||||||
|
Achievement::new(PMPacks(30)),
|
||||||
|
Achievement::new(PMPacks(35)),
|
||||||
|
Achievement::new(PMPacks(40)),
|
||||||
|
Achievement::new(PMPacks(45)),
|
||||||
|
Achievement::new(PMPacks(50)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
// {{{ PM relay tower
|
||||||
|
let pm_relay_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(PMRelay(Seven)),
|
||||||
|
Achievement::new(PMRelay(SevenP)),
|
||||||
|
Achievement::new(PMRelay(Eight)),
|
||||||
|
Achievement::new(PMRelay(EightP)),
|
||||||
|
Achievement::new(PMRelay(Nine)),
|
||||||
|
Achievement::new(PMRelay(NineP)),
|
||||||
|
Achievement::new(PMRelay(Ten)),
|
||||||
|
Achievement::new(PMRelay(TenP)),
|
||||||
|
Achievement::new(PMRelay(Eleven)),
|
||||||
|
Achievement::new(PMRelay(Twelve)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
// {{{ PTT tower
|
||||||
|
let ptt_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(PTT(0800)),
|
||||||
|
Achievement::new(PTT(0900)),
|
||||||
|
Achievement::new(PTT(1000)),
|
||||||
|
Achievement::new(PTT(1050)),
|
||||||
|
Achievement::new(PTT(1100)),
|
||||||
|
Achievement::new(PTT(1125)),
|
||||||
|
Achievement::new(PTT(1150)),
|
||||||
|
Achievement::new(PTT(1200)),
|
||||||
|
Achievement::new(PTT(1210)),
|
||||||
|
Achievement::new(PTT(1220)),
|
||||||
|
Achievement::new(PTT(1230)),
|
||||||
|
Achievement::new(PTT(1240)),
|
||||||
|
Achievement::new(PTT(1250)),
|
||||||
|
Achievement::new(PTT(1260)),
|
||||||
|
Achievement::new(PTT(1270)),
|
||||||
|
Achievement::new(PTT(1280)),
|
||||||
|
Achievement::new(PTT(1290)),
|
||||||
|
Achievement::new(PTT(1300)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
// {{{ EX(+) level tower
|
||||||
|
let ex_level_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(GradeEntireLevel(EX, Seven, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, SevenP, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, Eight, 10)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, EightP, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, Nine, 20)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, NineP, 15)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, Ten, 15)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, TenP, 10)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, Eleven, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EX, Twelve, 1)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let exp_level_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, Seven, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, SevenP, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, Eight, 10)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, EightP, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, Nine, 20)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, NineP, 15)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, Ten, 15)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, TenP, 10)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, Eleven, 5)),
|
||||||
|
Achievement::new(GradeEntireLevel(EXP, Twelve, 1)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
// {{{ Submit plays
|
||||||
|
let submit_plays_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(SubmitPlays(100)),
|
||||||
|
Achievement::new(SubmitPlays(250)),
|
||||||
|
Achievement::new(SubmitPlays(500)),
|
||||||
|
Achievement::new(SubmitPlays(1000)),
|
||||||
|
Achievement::new(SubmitPlays(2000)),
|
||||||
|
Achievement::new(SubmitPlays(3000)),
|
||||||
|
Achievement::new(SubmitPlays(4000)),
|
||||||
|
Achievement::new(SubmitPlays(5000)),
|
||||||
|
Achievement::new(SubmitPlays(7500)),
|
||||||
|
Achievement::new(SubmitPlays(10000)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
// {{{ Multi-difficulty PM
|
||||||
|
let multi_difficulty_tower = AchievementTower::new(vec![
|
||||||
|
Achievement::new(MultiDifficultyPM(PST)),
|
||||||
|
Achievement::new(MultiDifficultyPM(PRS)),
|
||||||
|
Achievement::new(MultiDifficultyPM(FTR)),
|
||||||
|
Achievement::new(MultiDifficultyPM(ETR)),
|
||||||
|
Achievement::new(MultiDifficultyPM(BYD)),
|
||||||
|
]);
|
||||||
|
// }}}
|
||||||
|
|
||||||
|
let towers = vec![
|
||||||
|
pm_count_tower,
|
||||||
|
pm_pack_tower,
|
||||||
|
pm_relay_tower,
|
||||||
|
ptt_tower,
|
||||||
|
ex_level_tower,
|
||||||
|
exp_level_tower,
|
||||||
|
submit_plays_tower,
|
||||||
|
multi_difficulty_tower,
|
||||||
|
];
|
||||||
|
|
||||||
|
Self { towers }
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
}
|
||||||
|
// }}}
|
|
@ -1,4 +1,4 @@
|
||||||
use std::{num::NonZeroU16, path::PathBuf};
|
use std::{fmt::Display, num::NonZeroU16, path::PathBuf};
|
||||||
|
|
||||||
use image::{ImageBuffer, Rgb};
|
use image::{ImageBuffer, Rgb};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
@ -43,6 +43,16 @@ impl TryFrom<String> for Difficulty {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for Difficulty {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}",
|
||||||
|
Self::DIFFICULTY_SHORTHANDS[self.to_index()].to_lowercase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub const DIFFICULTY_MENU_PIXEL_COLORS: [Color; Difficulty::DIFFICULTIES.len()] = [
|
pub const DIFFICULTY_MENU_PIXEL_COLORS: [Color; Difficulty::DIFFICULTIES.len()] = [
|
||||||
Color::from_rgb_int(0xAAE5F7),
|
Color::from_rgb_int(0xAAE5F7),
|
||||||
Color::from_rgb_int(0xBFDD85),
|
Color::from_rgb_int(0xBFDD85),
|
||||||
|
@ -51,6 +61,79 @@ pub const DIFFICULTY_MENU_PIXEL_COLORS: [Color; Difficulty::DIFFICULTIES.len()]
|
||||||
Color::from_rgb_int(0xF89AAC),
|
Color::from_rgb_int(0xF89AAC),
|
||||||
];
|
];
|
||||||
// }}}
|
// }}}
|
||||||
|
// {{{ Level
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum Level {
|
||||||
|
Unknown,
|
||||||
|
One,
|
||||||
|
Two,
|
||||||
|
Three,
|
||||||
|
Four,
|
||||||
|
Five,
|
||||||
|
Six,
|
||||||
|
Seven,
|
||||||
|
SevenP,
|
||||||
|
Eight,
|
||||||
|
EightP,
|
||||||
|
Nine,
|
||||||
|
NineP,
|
||||||
|
Ten,
|
||||||
|
TenP,
|
||||||
|
Eleven,
|
||||||
|
Twelve,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Level {
|
||||||
|
pub const LEVELS: [Self; 17] = [
|
||||||
|
Self::Unknown,
|
||||||
|
Self::One,
|
||||||
|
Self::Two,
|
||||||
|
Self::Three,
|
||||||
|
Self::Four,
|
||||||
|
Self::Five,
|
||||||
|
Self::Six,
|
||||||
|
Self::Seven,
|
||||||
|
Self::SevenP,
|
||||||
|
Self::Eight,
|
||||||
|
Self::EightP,
|
||||||
|
Self::Nine,
|
||||||
|
Self::NineP,
|
||||||
|
Self::Ten,
|
||||||
|
Self::TenP,
|
||||||
|
Self::Eleven,
|
||||||
|
Self::Twelve,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub const LEVEL_STRINGS: [&'static str; 17] = [
|
||||||
|
"?", "1", "2", "3", "4", "5", "6", "7", "7+", "8", "8+", "9", "9+", "10", "10+", "11", "12",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn to_index(self) -> usize {
|
||||||
|
self as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Level {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", Self::LEVEL_STRINGS[self.to_index()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<String> for Level {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||||
|
for (i, s) in Self::LEVEL_STRINGS.iter().enumerate() {
|
||||||
|
if value == **s {
|
||||||
|
return Ok(Self::LEVELS[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("Cannot convert {} to a level", value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
// {{{ Side
|
// {{{ Side
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum Side {
|
pub enum Side {
|
||||||
|
@ -113,7 +196,7 @@ pub struct Chart {
|
||||||
pub note_design: Option<String>,
|
pub note_design: Option<String>,
|
||||||
|
|
||||||
pub difficulty: Difficulty,
|
pub difficulty: Difficulty,
|
||||||
pub level: String, // TODO: this could become an enum
|
pub level: Level,
|
||||||
|
|
||||||
pub note_count: u32,
|
pub note_count: u32,
|
||||||
pub chart_constant: u32,
|
pub chart_constant: u32,
|
||||||
|
@ -253,7 +336,7 @@ impl SongCache {
|
||||||
song_id: chart.song_id as u32,
|
song_id: chart.song_id as u32,
|
||||||
shorthand: chart.shorthand,
|
shorthand: chart.shorthand,
|
||||||
difficulty: Difficulty::try_from(chart.difficulty)?,
|
difficulty: Difficulty::try_from(chart.difficulty)?,
|
||||||
level: chart.level,
|
level: Level::try_from(chart.level)?,
|
||||||
chart_constant: chart.chart_constant as u32,
|
chart_constant: chart.chart_constant as u32,
|
||||||
note_count: chart.note_count as u32,
|
note_count: chart.note_count as u32,
|
||||||
cached_jacket: None,
|
cached_jacket: None,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod achievement;
|
||||||
pub mod chart;
|
pub mod chart;
|
||||||
pub mod jacket;
|
pub mod jacket;
|
||||||
pub mod play;
|
pub mod play;
|
||||||
|
|
|
@ -4,7 +4,7 @@ use num::traits::Euclid;
|
||||||
use poise::serenity_prelude::{
|
use poise::serenity_prelude::{
|
||||||
Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp,
|
Attachment, AttachmentId, CreateAttachment, CreateEmbed, CreateEmbedAuthor, Timestamp,
|
||||||
};
|
};
|
||||||
use sqlx::{query_as, SqlitePool};
|
use sqlx::{query, query_as, SqlitePool};
|
||||||
|
|
||||||
use crate::arcaea::chart::{Chart, Song};
|
use crate::arcaea::chart::{Chart, Song};
|
||||||
use crate::context::{Error, UserContext};
|
use crate::context::{Error, UserContext};
|
||||||
|
@ -17,7 +17,6 @@ use super::score::{Score, ScoringSystem};
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CreatePlay {
|
pub struct CreatePlay {
|
||||||
chart_id: u32,
|
chart_id: u32,
|
||||||
user_id: u32,
|
|
||||||
discord_attachment_id: Option<AttachmentId>,
|
discord_attachment_id: Option<AttachmentId>,
|
||||||
|
|
||||||
// Actual score data
|
// Actual score data
|
||||||
|
@ -27,26 +26,18 @@ pub struct CreatePlay {
|
||||||
// Optional score details
|
// Optional score details
|
||||||
max_recall: Option<u32>,
|
max_recall: Option<u32>,
|
||||||
far_notes: Option<u32>,
|
far_notes: Option<u32>,
|
||||||
|
|
||||||
// Creation data
|
|
||||||
creation_ptt: Option<u32>,
|
|
||||||
creation_zeta_ptt: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CreatePlay {
|
impl CreatePlay {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn new(score: Score, chart: &Chart, user: &User) -> Self {
|
pub fn new(score: Score, chart: &Chart) -> Self {
|
||||||
Self {
|
Self {
|
||||||
chart_id: chart.id,
|
chart_id: chart.id,
|
||||||
user_id: user.id,
|
|
||||||
discord_attachment_id: None,
|
discord_attachment_id: None,
|
||||||
score,
|
score,
|
||||||
zeta_score: score.to_zeta(chart.note_count as u32),
|
zeta_score: score.to_zeta(chart.note_count as u32),
|
||||||
max_recall: None,
|
max_recall: None,
|
||||||
far_notes: None,
|
far_notes: None,
|
||||||
// TODO: populate these
|
|
||||||
creation_ptt: None,
|
|
||||||
creation_zeta_ptt: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,8 +60,10 @@ impl CreatePlay {
|
||||||
}
|
}
|
||||||
|
|
||||||
// {{{ Save
|
// {{{ Save
|
||||||
pub async fn save(self, ctx: &UserContext) -> Result<Play, Error> {
|
pub async fn save(self, ctx: &UserContext, user: &User) -> 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
|
||||||
let play = sqlx::query!(
|
let play = sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO plays(
|
INSERT INTO plays(
|
||||||
|
@ -80,7 +73,7 @@ impl CreatePlay {
|
||||||
VALUES(?,?,?,?,?,?,?)
|
VALUES(?,?,?,?,?,?,?)
|
||||||
RETURNING id, created_at
|
RETURNING id, created_at
|
||||||
",
|
",
|
||||||
self.user_id,
|
user.id,
|
||||||
self.chart_id,
|
self.chart_id,
|
||||||
attachment_id,
|
attachment_id,
|
||||||
self.score.0,
|
self.score.0,
|
||||||
|
@ -90,19 +83,55 @@ impl CreatePlay {
|
||||||
)
|
)
|
||||||
.fetch_one(&ctx.db)
|
.fetch_one(&ctx.db)
|
||||||
.await?;
|
.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)
|
||||||
|
.await?
|
||||||
|
.ok()
|
||||||
|
.map(|plays| compute_b30_ptt(ScoringSystem::EX, &plays));
|
||||||
|
|
||||||
|
query!(
|
||||||
|
"
|
||||||
|
UPDATE plays
|
||||||
|
SET
|
||||||
|
creation_ptt=?,
|
||||||
|
creation_zeta_ptt=?
|
||||||
|
WHERE
|
||||||
|
id=?
|
||||||
|
",
|
||||||
|
creation_ptt,
|
||||||
|
creation_zeta_ptt,
|
||||||
|
play.id
|
||||||
|
)
|
||||||
|
.execute(&ctx.db)
|
||||||
|
.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: self.chart_id,
|
||||||
user_id: self.user_id,
|
user_id: user.id,
|
||||||
discord_attachment_id: self.discord_attachment_id,
|
discord_attachment_id: self.discord_attachment_id,
|
||||||
score: self.score,
|
score: self.score,
|
||||||
zeta_score: self.zeta_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: self.creation_ptt,
|
creation_ptt,
|
||||||
creation_zeta_ptt: self.creation_zeta_ptt,
|
creation_zeta_ptt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
@ -140,8 +169,8 @@ impl DbPlay {
|
||||||
discord_attachment_id: self
|
discord_attachment_id: self
|
||||||
.discord_attachment_id
|
.discord_attachment_id
|
||||||
.and_then(|s| AttachmentId::from_str(&s).ok()),
|
.and_then(|s| AttachmentId::from_str(&s).ok()),
|
||||||
creation_ptt: self.creation_ptt.map(|r| r as u32),
|
creation_ptt: self.creation_ptt.map(|r| r as i32),
|
||||||
creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as u32),
|
creation_zeta_ptt: self.creation_zeta_ptt.map(|r| r as i32),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -167,11 +196,10 @@ pub struct Play {
|
||||||
// Creation data
|
// Creation data
|
||||||
pub created_at: chrono::NaiveDateTime,
|
pub created_at: chrono::NaiveDateTime,
|
||||||
|
|
||||||
#[allow(unused)]
|
#[allow(dead_code)]
|
||||||
pub creation_ptt: Option<u32>,
|
pub creation_ptt: Option<i32>,
|
||||||
|
#[allow(dead_code)]
|
||||||
#[allow(unused)]
|
pub creation_zeta_ptt: Option<i32>,
|
||||||
pub creation_zeta_ptt: Option<u32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Play {
|
impl Play {
|
||||||
|
|
|
@ -24,18 +24,18 @@ impl Default for ScoringSystem {
|
||||||
// {{{ Grade
|
// {{{ Grade
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum Grade {
|
pub enum Grade {
|
||||||
EXP,
|
|
||||||
EX,
|
|
||||||
AA,
|
|
||||||
A,
|
|
||||||
B,
|
|
||||||
C,
|
|
||||||
D,
|
D,
|
||||||
|
C,
|
||||||
|
B,
|
||||||
|
A,
|
||||||
|
AA,
|
||||||
|
EX,
|
||||||
|
EXP,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Grade {
|
impl Grade {
|
||||||
pub const GRADE_STRINGS: [&'static str; 7] = ["EX+", "EX", "AA", "A", "B", "C", "D"];
|
pub const GRADE_STRINGS: [&'static str; 7] = ["D", "C", "B", "A", "AA", "EX", "EX+"];
|
||||||
pub const GRADE_SHORTHANDS: [&'static str; 7] = ["exp", "ex", "aa", "a", "b", "c", "d"];
|
pub const GRADE_SHORTHANDS: [&'static str; 7] = ["d", "c", "b", "a", "aa", "ex", "exp"];
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn to_index(self) -> usize {
|
pub fn to_index(self) -> usize {
|
||||||
|
@ -268,6 +268,12 @@ impl Score {
|
||||||
Ok(buffer)
|
Ok(buffer)
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
// {{{ PM detection
|
||||||
|
#[inline]
|
||||||
|
pub fn is_pm(&self) -> bool {
|
||||||
|
self.0 >= 10_000_000
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Display for Score {
|
impl Display for Score {
|
||||||
|
|
|
@ -4,13 +4,48 @@ use sqlx::query;
|
||||||
use crate::{
|
use crate::{
|
||||||
arcaea::chart::Side,
|
arcaea::chart::Side,
|
||||||
context::{Context, Error},
|
context::{Context, Error},
|
||||||
|
get_user,
|
||||||
recognition::fuzzy_song_name::guess_song_and_chart,
|
recognition::fuzzy_song_name::guess_song_and_chart,
|
||||||
};
|
};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
// {{{ Chart
|
use chrono::DateTime;
|
||||||
|
use image::{ImageBuffer, Rgb};
|
||||||
|
use plotters::{
|
||||||
|
backend::{BitMapBackend, PixelFormat, RGBPixel},
|
||||||
|
chart::{ChartBuilder, LabelAreaPosition},
|
||||||
|
drawing::IntoDrawingArea,
|
||||||
|
element::Circle,
|
||||||
|
series::LineSeries,
|
||||||
|
style::{IntoFont, TextStyle, BLUE, WHITE},
|
||||||
|
};
|
||||||
|
use poise::CreateReply;
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
arcaea::{
|
||||||
|
play::DbPlay,
|
||||||
|
score::{Score, ScoringSystem},
|
||||||
|
},
|
||||||
|
user::discord_it_to_discord_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
// {{{ Top command
|
||||||
|
/// Chart-related stats
|
||||||
|
#[poise::command(
|
||||||
|
prefix_command,
|
||||||
|
slash_command,
|
||||||
|
subcommands("info", "best", "plot"),
|
||||||
|
subcommand_required
|
||||||
|
)]
|
||||||
|
pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Info
|
||||||
/// Show a chart given it's name
|
/// Show a chart given it's name
|
||||||
#[poise::command(prefix_command, slash_command)]
|
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||||
pub async fn chart(
|
async fn info(
|
||||||
ctx: Context<'_>,
|
ctx: Context<'_>,
|
||||||
#[rest]
|
#[rest]
|
||||||
#[description = "Name of chart to show (difficulty at the end)"]
|
#[description = "Name of chart to show (difficulty at the end)"]
|
||||||
|
@ -74,3 +109,186 @@ pub async fn chart(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
// {{{ Best score
|
||||||
|
/// Show the best score on a given chart
|
||||||
|
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||||
|
async fn best(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
#[rest]
|
||||||
|
#[description = "Name of chart to show (difficulty at the end)"]
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let user = get_user!(&ctx);
|
||||||
|
|
||||||
|
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
|
||||||
|
let play = query_as!(
|
||||||
|
DbPlay,
|
||||||
|
"
|
||||||
|
SELECT * FROM plays
|
||||||
|
WHERE user_id=?
|
||||||
|
AND chart_id=?
|
||||||
|
ORDER BY score DESC
|
||||||
|
",
|
||||||
|
user.id,
|
||||||
|
chart.id
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
format!(
|
||||||
|
"Could not find any scores for {} [{:?}]",
|
||||||
|
song.title, chart.difficulty
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.into_play();
|
||||||
|
|
||||||
|
let (embed, attachment) = play
|
||||||
|
.to_embed(
|
||||||
|
&ctx.data().db,
|
||||||
|
&user,
|
||||||
|
&song,
|
||||||
|
&chart,
|
||||||
|
0,
|
||||||
|
Some(&discord_it_to_discord_user(&ctx, &user.discord_id).await?),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ctx.channel_id()
|
||||||
|
.send_files(ctx.http(), attachment, CreateMessage::new().embed(embed))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
// {{{ Score plot
|
||||||
|
/// Show the best score on a given chart
|
||||||
|
#[poise::command(prefix_command, slash_command, user_cooldown = 10)]
|
||||||
|
async fn plot(
|
||||||
|
ctx: Context<'_>,
|
||||||
|
scoring_system: Option<ScoringSystem>,
|
||||||
|
#[rest]
|
||||||
|
#[description = "Name of chart to show (difficulty at the end)"]
|
||||||
|
name: String,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let user = get_user!(&ctx);
|
||||||
|
let scoring_system = scoring_system.unwrap_or_default();
|
||||||
|
|
||||||
|
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,
|
||||||
|
"
|
||||||
|
SELECT * FROM plays
|
||||||
|
WHERE user_id=?
|
||||||
|
AND chart_id=?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1000
|
||||||
|
",
|
||||||
|
user.id,
|
||||||
|
chart.id
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.data().db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if plays.len() == 0 {
|
||||||
|
ctx.reply(format!(
|
||||||
|
"No plays found on {} [{:?}]",
|
||||||
|
song.title, chart.difficulty
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let min_time = plays.iter().map(|p| p.created_at).min().unwrap();
|
||||||
|
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))
|
||||||
|
.min()
|
||||||
|
.unwrap()
|
||||||
|
.0 as i64;
|
||||||
|
|
||||||
|
if min_score > 9_900_000 {
|
||||||
|
min_score = 9_800_000;
|
||||||
|
} else if min_score > 9_800_000 {
|
||||||
|
min_score = 9_800_000;
|
||||||
|
} else if min_score > 9_500_000 {
|
||||||
|
min_score = 9_500_000;
|
||||||
|
} else {
|
||||||
|
min_score = 9_000_000
|
||||||
|
};
|
||||||
|
|
||||||
|
let max_score = 10_010_000;
|
||||||
|
let width = 1024;
|
||||||
|
let height = 768;
|
||||||
|
|
||||||
|
let mut buffer = vec![u8::MAX; RGBPixel::PIXEL_SIZE * (width * height) as usize];
|
||||||
|
|
||||||
|
{
|
||||||
|
let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area();
|
||||||
|
|
||||||
|
let mut chart = ChartBuilder::on(&root)
|
||||||
|
.margin(25)
|
||||||
|
.caption(
|
||||||
|
format!("{} [{:?}]", song.title, chart.difficulty),
|
||||||
|
("sans-serif", 40),
|
||||||
|
)
|
||||||
|
.set_label_area_size(LabelAreaPosition::Left, 100)
|
||||||
|
.set_label_area_size(LabelAreaPosition::Bottom, 40)
|
||||||
|
.build_cartesian_2d(
|
||||||
|
min_time.and_utc().timestamp_millis()..max_time.and_utc().timestamp_millis(),
|
||||||
|
min_score..max_score,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
chart
|
||||||
|
.configure_mesh()
|
||||||
|
.light_line_style(WHITE)
|
||||||
|
.y_label_formatter(&|s| format!("{}", Score(*s as u32)))
|
||||||
|
.y_desc("Score")
|
||||||
|
.x_label_formatter(&|d| {
|
||||||
|
format!(
|
||||||
|
"{}",
|
||||||
|
DateTime::from_timestamp_millis(*d).unwrap().date_naive()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.y_label_style(TextStyle::from(("sans-serif", 20).into_font()))
|
||||||
|
.x_label_style(TextStyle::from(("sans-serif", 20).into_font()))
|
||||||
|
.draw()?;
|
||||||
|
|
||||||
|
let mut points: Vec<_> = plays
|
||||||
|
.into_iter()
|
||||||
|
.map(|play| {
|
||||||
|
(
|
||||||
|
play.created_at.and_utc().timestamp_millis(),
|
||||||
|
play.into_play().score(scoring_system),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
points.sort();
|
||||||
|
points.dedup();
|
||||||
|
|
||||||
|
chart.draw_series(LineSeries::new(
|
||||||
|
points.iter().map(|(t, s)| (*t, s.0 as i64)),
|
||||||
|
&BLUE,
|
||||||
|
))?;
|
||||||
|
|
||||||
|
chart.draw_series(points.iter().map(|(t, s)| {
|
||||||
|
Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE))
|
||||||
|
}))?;
|
||||||
|
root.present()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let image: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_raw(width, height, buffer).unwrap();
|
||||||
|
|
||||||
|
let mut buffer = Vec::new();
|
||||||
|
let mut cursor = Cursor::new(&mut buffer);
|
||||||
|
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
||||||
|
|
||||||
|
let reply = CreateReply::default().attachment(CreateAttachment::bytes(buffer, "plot.png"));
|
||||||
|
ctx.send(reply).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
|
|
@ -19,6 +19,7 @@ pub async fn help(
|
||||||
command.as_deref(),
|
command.as_deref(),
|
||||||
poise::builtins::HelpConfiguration {
|
poise::builtins::HelpConfiguration {
|
||||||
extra_text_at_bottom: "For additional support, message @prescientmoon",
|
extra_text_at_bottom: "For additional support, message @prescientmoon",
|
||||||
|
show_subcommands: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -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, &user)
|
let play = CreatePlay::new(score, &chart)
|
||||||
.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())
|
.save(&ctx.data(), &user)
|
||||||
.await?;
|
.await?;
|
||||||
// }}}
|
// }}}
|
||||||
// }}}
|
// }}}
|
||||||
|
|
|
@ -1,26 +1,19 @@
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
use chrono::DateTime;
|
use image::{DynamicImage, ImageBuffer};
|
||||||
use image::{DynamicImage, ImageBuffer, Rgb};
|
|
||||||
use plotters::{
|
|
||||||
backend::{BitMapBackend, PixelFormat, RGBPixel},
|
|
||||||
chart::{ChartBuilder, LabelAreaPosition},
|
|
||||||
drawing::IntoDrawingArea,
|
|
||||||
element::Circle,
|
|
||||||
series::LineSeries,
|
|
||||||
style::{IntoFont, TextStyle, BLUE, WHITE},
|
|
||||||
};
|
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity_prelude::{CreateAttachment, CreateMessage},
|
serenity_prelude::{CreateAttachment, CreateEmbed},
|
||||||
CreateReply,
|
CreateReply,
|
||||||
};
|
};
|
||||||
use sqlx::query_as;
|
use sqlx::query;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
arcaea::{
|
arcaea::{
|
||||||
|
achievement::GoalStats,
|
||||||
|
chart::Level,
|
||||||
jacket::BITMAP_IMAGE_SIZE,
|
jacket::BITMAP_IMAGE_SIZE,
|
||||||
play::{compute_b30_ptt, get_best_plays, DbPlay},
|
play::{compute_b30_ptt, get_best_plays},
|
||||||
score::{Score, ScoringSystem},
|
score::ScoringSystem,
|
||||||
},
|
},
|
||||||
assert_is_pookie,
|
assert_is_pookie,
|
||||||
assets::{
|
assets::{
|
||||||
|
@ -32,9 +25,8 @@ use crate::{
|
||||||
context::{Context, Error},
|
context::{Context, Error},
|
||||||
get_user,
|
get_user,
|
||||||
logs::debug_image_log,
|
logs::debug_image_log,
|
||||||
recognition::fuzzy_song_name::guess_song_and_chart,
|
|
||||||
reply_errors,
|
reply_errors,
|
||||||
user::{discord_it_to_discord_user, User},
|
user::User,
|
||||||
};
|
};
|
||||||
|
|
||||||
// {{{ Stats
|
// {{{ Stats
|
||||||
|
@ -42,208 +34,13 @@ use crate::{
|
||||||
#[poise::command(
|
#[poise::command(
|
||||||
prefix_command,
|
prefix_command,
|
||||||
slash_command,
|
slash_command,
|
||||||
subcommands("chart", "b30", "bany"),
|
subcommands("meta", "b30", "bany"),
|
||||||
subcommand_required
|
subcommand_required
|
||||||
)]
|
)]
|
||||||
pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> {
|
pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Chart
|
|
||||||
/// Chart-related stats
|
|
||||||
#[poise::command(
|
|
||||||
prefix_command,
|
|
||||||
slash_command,
|
|
||||||
subcommands("best", "plot"),
|
|
||||||
subcommand_required
|
|
||||||
)]
|
|
||||||
pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
// }}}
|
|
||||||
// {{{ Best score
|
|
||||||
/// Show the best score on a given chart
|
|
||||||
#[poise::command(prefix_command, slash_command)]
|
|
||||||
pub async fn best(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
#[rest]
|
|
||||||
#[description = "Name of chart to show (difficulty at the end)"]
|
|
||||||
name: String,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let user = get_user!(&ctx);
|
|
||||||
|
|
||||||
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
|
|
||||||
let play = query_as!(
|
|
||||||
DbPlay,
|
|
||||||
"
|
|
||||||
SELECT * FROM plays
|
|
||||||
WHERE user_id=?
|
|
||||||
AND chart_id=?
|
|
||||||
ORDER BY score DESC
|
|
||||||
",
|
|
||||||
user.id,
|
|
||||||
chart.id
|
|
||||||
)
|
|
||||||
.fetch_one(&ctx.data().db)
|
|
||||||
.await
|
|
||||||
.map_err(|_| {
|
|
||||||
format!(
|
|
||||||
"Could not find any scores for {} [{:?}]",
|
|
||||||
song.title, chart.difficulty
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.into_play();
|
|
||||||
|
|
||||||
let (embed, attachment) = play
|
|
||||||
.to_embed(
|
|
||||||
&ctx.data().db,
|
|
||||||
&user,
|
|
||||||
&song,
|
|
||||||
&chart,
|
|
||||||
0,
|
|
||||||
Some(&discord_it_to_discord_user(&ctx, &user.discord_id).await?),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
ctx.channel_id()
|
|
||||||
.send_files(ctx.http(), attachment, CreateMessage::new().embed(embed))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
// }}}
|
|
||||||
// {{{ Score plot
|
|
||||||
/// Show the best score on a given chart
|
|
||||||
#[poise::command(prefix_command, slash_command)]
|
|
||||||
pub async fn plot(
|
|
||||||
ctx: Context<'_>,
|
|
||||||
scoring_system: Option<ScoringSystem>,
|
|
||||||
#[rest]
|
|
||||||
#[description = "Name of chart to show (difficulty at the end)"]
|
|
||||||
name: String,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
let user = get_user!(&ctx);
|
|
||||||
let scoring_system = scoring_system.unwrap_or_default();
|
|
||||||
|
|
||||||
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,
|
|
||||||
"
|
|
||||||
SELECT * FROM plays
|
|
||||||
WHERE user_id=?
|
|
||||||
AND chart_id=?
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT 1000
|
|
||||||
",
|
|
||||||
user.id,
|
|
||||||
chart.id
|
|
||||||
)
|
|
||||||
.fetch_all(&ctx.data().db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if plays.len() == 0 {
|
|
||||||
ctx.reply(format!(
|
|
||||||
"No plays found on {} [{:?}]",
|
|
||||||
song.title, chart.difficulty
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let min_time = plays.iter().map(|p| p.created_at).min().unwrap();
|
|
||||||
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))
|
|
||||||
.min()
|
|
||||||
.unwrap()
|
|
||||||
.0 as i64;
|
|
||||||
|
|
||||||
if min_score > 9_900_000 {
|
|
||||||
min_score = 9_800_000;
|
|
||||||
} else if min_score > 9_800_000 {
|
|
||||||
min_score = 9_800_000;
|
|
||||||
} else if min_score > 9_500_000 {
|
|
||||||
min_score = 9_500_000;
|
|
||||||
} else {
|
|
||||||
min_score = 9_000_000
|
|
||||||
};
|
|
||||||
|
|
||||||
let max_score = 10_010_000;
|
|
||||||
let width = 1024;
|
|
||||||
let height = 768;
|
|
||||||
|
|
||||||
let mut buffer = vec![u8::MAX; RGBPixel::PIXEL_SIZE * (width * height) as usize];
|
|
||||||
|
|
||||||
{
|
|
||||||
let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area();
|
|
||||||
|
|
||||||
let mut chart = ChartBuilder::on(&root)
|
|
||||||
.margin(25)
|
|
||||||
.caption(
|
|
||||||
format!("{} [{:?}]", song.title, chart.difficulty),
|
|
||||||
("sans-serif", 40),
|
|
||||||
)
|
|
||||||
.set_label_area_size(LabelAreaPosition::Left, 100)
|
|
||||||
.set_label_area_size(LabelAreaPosition::Bottom, 40)
|
|
||||||
.build_cartesian_2d(
|
|
||||||
min_time.and_utc().timestamp_millis()..max_time.and_utc().timestamp_millis(),
|
|
||||||
min_score..max_score,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
chart
|
|
||||||
.configure_mesh()
|
|
||||||
.light_line_style(WHITE)
|
|
||||||
.y_label_formatter(&|s| format!("{}", Score(*s as u32)))
|
|
||||||
.y_desc("Score")
|
|
||||||
.x_label_formatter(&|d| {
|
|
||||||
format!(
|
|
||||||
"{}",
|
|
||||||
DateTime::from_timestamp_millis(*d).unwrap().date_naive()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.y_label_style(TextStyle::from(("sans-serif", 20).into_font()))
|
|
||||||
.x_label_style(TextStyle::from(("sans-serif", 20).into_font()))
|
|
||||||
.draw()?;
|
|
||||||
|
|
||||||
let mut points: Vec<_> = plays
|
|
||||||
.into_iter()
|
|
||||||
.map(|play| {
|
|
||||||
(
|
|
||||||
play.created_at.and_utc().timestamp_millis(),
|
|
||||||
play.into_play().score(scoring_system),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
points.sort();
|
|
||||||
points.dedup();
|
|
||||||
|
|
||||||
chart.draw_series(LineSeries::new(
|
|
||||||
points.iter().map(|(t, s)| (*t, s.0 as i64)),
|
|
||||||
&BLUE,
|
|
||||||
))?;
|
|
||||||
|
|
||||||
chart.draw_series(points.iter().map(|(t, s)| {
|
|
||||||
Circle::new((*t, s.0 as i64), 3, plotters::style::Color::filled(&BLUE))
|
|
||||||
}))?;
|
|
||||||
root.present()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let image: ImageBuffer<Rgb<u8>, _> = ImageBuffer::from_raw(width, height, buffer).unwrap();
|
|
||||||
|
|
||||||
let mut buffer = Vec::new();
|
|
||||||
let mut cursor = Cursor::new(&mut buffer);
|
|
||||||
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
|
||||||
|
|
||||||
let reply = CreateReply::default().attachment(CreateAttachment::bytes(buffer, "plot.png"));
|
|
||||||
ctx.send(reply).await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
// }}}
|
|
||||||
// {{{ Render best plays
|
// {{{ Render best plays
|
||||||
async fn best_plays(
|
async fn best_plays(
|
||||||
ctx: &Context<'_>,
|
ctx: &Context<'_>,
|
||||||
|
@ -415,9 +212,10 @@ async fn best_plays(
|
||||||
drawer.blit_rbga(jacket_with_border, diff_bg_area.top_left(), diff_bg);
|
drawer.blit_rbga(jacket_with_border, diff_bg_area.top_left(), diff_bg);
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Display difficulty text
|
// {{{ Display difficulty text
|
||||||
let x_offset = if chart.level.ends_with("+") {
|
let level_text = Level::LEVEL_STRINGS[chart.level.to_index()];
|
||||||
|
let x_offset = if level_text.ends_with("+") {
|
||||||
3
|
3
|
||||||
} else if chart.level == "11" {
|
} else if chart.level == Level::Eleven {
|
||||||
-2
|
-2
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
|
@ -438,7 +236,7 @@ async fn best_plays(
|
||||||
stroke: None,
|
stroke: None,
|
||||||
drop_shadow: None,
|
drop_shadow: None,
|
||||||
},
|
},
|
||||||
&chart.level,
|
level_text,
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
// }}}
|
// }}}
|
||||||
|
@ -658,3 +456,70 @@ pub async fn bany(
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
// {{{ Meta
|
||||||
|
/// Show stats about the bot itself.
|
||||||
|
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||||
|
async fn meta(ctx: Context<'_>) -> Result<(), Error> {
|
||||||
|
let user = get_user!(&ctx);
|
||||||
|
let song_count = query!("SELECT count() as count FROM songs")
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?
|
||||||
|
.count;
|
||||||
|
|
||||||
|
let chart_count = query!("SELECT count() as count FROM charts")
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?
|
||||||
|
.count;
|
||||||
|
|
||||||
|
let users_count = query!("SELECT count() as count FROM users")
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?
|
||||||
|
.count;
|
||||||
|
|
||||||
|
let pookie_count = query!(
|
||||||
|
"
|
||||||
|
SELECT count() as count
|
||||||
|
FROM users
|
||||||
|
WHERE is_pookie=1
|
||||||
|
"
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?
|
||||||
|
.count;
|
||||||
|
|
||||||
|
let play_count = query!("SELECT count() as count FROM plays")
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?
|
||||||
|
.count;
|
||||||
|
|
||||||
|
let your_play_count = query!(
|
||||||
|
"
|
||||||
|
SELECT count() as count
|
||||||
|
FROM plays
|
||||||
|
WHERE user_id=?
|
||||||
|
",
|
||||||
|
user.id
|
||||||
|
)
|
||||||
|
.fetch_one(&ctx.data().db)
|
||||||
|
.await?
|
||||||
|
.count;
|
||||||
|
|
||||||
|
let embed = CreateEmbed::default()
|
||||||
|
.title("Bot statistics")
|
||||||
|
.field("Songs", format!("{song_count}"), true)
|
||||||
|
.field("Charts", format!("{chart_count}"), true)
|
||||||
|
.field("Users", format!("{users_count}"), true)
|
||||||
|
.field("Pookies", format!("{pookie_count}"), true)
|
||||||
|
.field("Plays", format!("{play_count}"), true)
|
||||||
|
.field("Your plays", format!("{your_play_count}"), true);
|
||||||
|
|
||||||
|
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{:?}",
|
||||||
|
GoalStats::make(ctx.data(), &user, ScoringSystem::Standard).await?
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
// }}}
|
||||||
|
|
|
@ -6,6 +6,7 @@ use crate::{
|
||||||
arcaea::{chart::SongCache, jacket::JacketCache},
|
arcaea::{chart::SongCache, jacket::JacketCache},
|
||||||
assets::{get_data_dir, EXO_FONT, GEOSANS_FONT, KAZESAWA_BOLD_FONT, KAZESAWA_FONT},
|
assets::{get_data_dir, EXO_FONT, GEOSANS_FONT, KAZESAWA_BOLD_FONT, KAZESAWA_FONT},
|
||||||
recognition::{hyperglass::CharMeasurements, ui::UIMeasurements},
|
recognition::{hyperglass::CharMeasurements, ui::UIMeasurements},
|
||||||
|
timed,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Types used by all command functions
|
// Types used by all command functions
|
||||||
|
@ -29,11 +30,12 @@ pub struct UserContext {
|
||||||
impl UserContext {
|
impl UserContext {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub async fn new(db: SqlitePool) -> Result<Self, Error> {
|
pub async fn new(db: SqlitePool) -> Result<Self, Error> {
|
||||||
|
timed!("create_context", {
|
||||||
fs::create_dir_all(get_data_dir())?;
|
fs::create_dir_all(get_data_dir())?;
|
||||||
|
|
||||||
let mut song_cache = SongCache::new(&db).await?;
|
let mut song_cache = timed!("make_song_cache", { SongCache::new(&db).await? });
|
||||||
let jacket_cache = JacketCache::new(&mut song_cache)?;
|
let jacket_cache = timed!("make_jacket_cache", { JacketCache::new(&mut song_cache)? });
|
||||||
let ui_measurements = UIMeasurements::read()?;
|
let ui_measurements = timed!("read_ui_measurements", { UIMeasurements::read()? });
|
||||||
|
|
||||||
// {{{ Font measurements
|
// {{{ Font measurements
|
||||||
static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ";
|
static WHITELIST: &str = "0123456789'abcdefghklmnopqrstuvwxyzABCDEFGHIJKLMNOPRSTUVWXYZ";
|
||||||
|
@ -60,5 +62,6 @@ impl UserContext {
|
||||||
kazesawa_measurements,
|
kazesawa_measurements,
|
||||||
kazesawa_bold_measurements,
|
kazesawa_bold_measurements,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -233,6 +233,7 @@ pub struct CharMeasurements {
|
||||||
impl CharMeasurements {
|
impl CharMeasurements {
|
||||||
// {{{ Creation
|
// {{{ Creation
|
||||||
pub fn from_text(face: &mut Face, string: &str, weight: Option<u32>) -> Result<Self, Error> {
|
pub fn from_text(face: &mut Face, string: &str, weight: Option<u32>) -> Result<Self, Error> {
|
||||||
|
timed!("measure_chars", {
|
||||||
// These are bad estimates lol
|
// These are bad estimates lol
|
||||||
let style = TextStyle {
|
let style = TextStyle {
|
||||||
stroke: None,
|
stroke: None,
|
||||||
|
@ -252,7 +253,8 @@ impl CharMeasurements {
|
||||||
);
|
);
|
||||||
|
|
||||||
canvas.text(padding, &mut [face], style, &string)?;
|
canvas.text(padding, &mut [face], style, &string)?;
|
||||||
let buffer = ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
|
let buffer =
|
||||||
|
ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
|
||||||
.ok_or_else(|| "Failed to turn buffer into canvas")?;
|
.ok_or_else(|| "Failed to turn buffer into canvas")?;
|
||||||
let image = DynamicImage::ImageRgb8(buffer);
|
let image = DynamicImage::ImageRgb8(buffer);
|
||||||
|
|
||||||
|
@ -294,6 +296,7 @@ impl CharMeasurements {
|
||||||
max_width,
|
max_width,
|
||||||
max_height,
|
max_height,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
// {{{ Recognition
|
// {{{ Recognition
|
||||||
|
|
Loading…
Reference in a new issue