1
Fork 0

Start implementing achievement system

This commit is contained in:
prescientmoon 2024-08-17 23:00:16 +02:00
parent 5c062df309
commit 62949004f2
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
11 changed files with 952 additions and 336 deletions

408
src/arcaea/achievement.rs Normal file
View 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 }
}
// }}}
}
// }}}

View file

@ -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,

View file

@ -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;

View file

@ -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 {

View file

@ -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 {

View file

@ -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)"]
@ -26,10 +61,10 @@ pub async fn chart(
let play_count = query!( let play_count = query!(
" "
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM plays FROM plays
WHERE chart_id=? WHERE chart_id=?
", ",
chart.id chart.id
) )
.fetch_one(&ctx.data().db) .fetch_one(&ctx.data().db)
@ -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(())
}
// }}}

View file

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

View file

@ -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?;
// }}} // }}}
// }}} // }}}

View file

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

View file

@ -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,36 +30,38 @@ 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> {
fs::create_dir_all(get_data_dir())?; timed!("create_context", {
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";
let geosans_measurements = GEOSANS_FONT let geosans_measurements = GEOSANS_FONT
.with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?;
let kazesawa_measurements = KAZESAWA_FONT let kazesawa_measurements = KAZESAWA_FONT
.with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?;
let kazesawa_bold_measurements = KAZESAWA_BOLD_FONT let kazesawa_bold_measurements = KAZESAWA_BOLD_FONT
.with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?; .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, None))?;
let exo_measurements = EXO_FONT let exo_measurements = EXO_FONT
.with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, Some(700)))?; .with_borrow_mut(|font| CharMeasurements::from_text(font, WHITELIST, Some(700)))?;
// }}} // }}}
println!("Created user context"); println!("Created user context");
Ok(Self { Ok(Self {
db, db,
song_cache, song_cache,
jacket_cache, jacket_cache,
ui_measurements, ui_measurements,
geosans_measurements, geosans_measurements,
exo_measurements, exo_measurements,
kazesawa_measurements, kazesawa_measurements,
kazesawa_bold_measurements, kazesawa_bold_measurements,
})
}) })
} }
} }

View file

@ -233,66 +233,69 @@ 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> {
// These are bad estimates lol timed!("measure_chars", {
let style = TextStyle { // These are bad estimates lol
stroke: None, let style = TextStyle {
drop_shadow: None, stroke: None,
align: (Align::Start, Align::Start), drop_shadow: None,
size: 60, align: (Align::Start, Align::Start),
color: Color::BLACK, size: 60,
// TODO: do we want to use the weight hint for resilience? color: Color::BLACK,
weight, // TODO: do we want to use the weight hint for resilience?
}; weight,
let padding = (5, 5); };
let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?; let padding = (5, 5);
let planned = BitmapCanvas::plan_text_rendering(padding, &mut [face], style, &string)?;
let mut canvas = BitmapCanvas::new( let mut canvas = BitmapCanvas::new(
(planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32, (planned.0 .0) as u32 + planned.1.width + 2 * padding.0 as u32,
(planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32, (planned.0 .1) as u32 + planned.1.height + 2 * padding.0 as u32,
); );
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 =
.ok_or_else(|| "Failed to turn buffer into canvas")?; ImageBuffer::from_raw(canvas.width, canvas.height(), canvas.buffer.to_vec())
let image = DynamicImage::ImageRgb8(buffer); .ok_or_else(|| "Failed to turn buffer into canvas")?;
let image = DynamicImage::ImageRgb8(buffer);
debug_image_log(&image)?; debug_image_log(&image)?;
let components = ComponentsWithBounds::from_image(&image, 100)?; let components = ComponentsWithBounds::from_image(&image, 100)?;
// {{{ Compute max width/height // {{{ Compute max width/height
let max_width = components let max_width = components
.bounds .bounds
.iter() .iter()
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.x_max - b.x_min) .map(|b| b.x_max - b.x_min)
.max() .max()
.ok_or_else(|| "No connected components found")?; .ok_or_else(|| "No connected components found")?;
let max_height = components let max_height = components
.bounds .bounds
.iter() .iter()
.filter_map(|o| o.as_ref()) .filter_map(|o| o.as_ref())
.map(|b| b.y_max - b.y_min) .map(|b| b.y_max - b.y_min)
.max() .max()
.ok_or_else(|| "No connected components found")?; .ok_or_else(|| "No connected components found")?;
// }}} // }}}
let mut chars = Vec::with_capacity(string.len()); let mut chars = Vec::with_capacity(string.len());
for (i, char) in string.chars().enumerate() { for (i, char) in string.chars().enumerate() {
chars.push(( chars.push((
char, char,
ComponentVec::from_component( ComponentVec::from_component(
&components, &components,
(max_width, max_height), (max_width, max_height),
components.bounds_by_position[i] as u32 + 1, components.bounds_by_position[i] as u32 + 1,
)?, )?,
)) ))
} }
Ok(Self { Ok(Self {
chars, chars,
max_width, max_width,
max_height, max_height,
})
}) })
} }
// }}} // }}}