1
Fork 0

Chart info command

Signed-off-by: prescientmoon <git@moonythm.dev>
This commit is contained in:
prescientmoon 2024-07-12 17:18:31 +02:00
parent 8339ce7054
commit 373e54c55e
Signed by: prescientmoon
SSH key fingerprint: SHA256:UUF9JT2s8Xfyv76b8ZuVL7XrmimH4o49p4b+iexbVH4
12 changed files with 345 additions and 85 deletions

View file

@ -1,11 +1,14 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use sqlx::{prelude::FromRow, SqlitePool};
use crate::context::Error;
// {{{ Difficuly
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, sqlx::Type, Serialize, Deserialize,
)]
pub enum Difficulty {
PST,
PRS,
@ -42,21 +45,58 @@ impl TryFrom<String> for Difficulty {
}
}
// }}}
// {{{ Side
#[derive(Debug, Clone, Copy)]
pub enum Side {
Light,
Conflict,
Silent,
}
impl Side {
pub const SIDES: [Self; 3] = [Self::Light, Self::Conflict, Self::Silent];
pub const SIDE_STRINGS: [&'static str; 3] = ["light", "conflict", "silent"];
#[inline]
pub fn to_index(self) -> usize {
self as usize
}
}
impl TryFrom<String> for Side {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
for (i, s) in Self::SIDE_STRINGS.iter().enumerate() {
if value == **s {
return Ok(Self::SIDES[i]);
}
}
Err(format!("Cannot convert {} to difficulty", value))
}
}
// }}}
// {{{ Song
#[derive(Debug, Clone, FromRow)]
pub struct Song {
pub id: u32,
pub title: String,
#[allow(dead_code)]
pub lowercase_title: String,
pub artist: String,
pub bpm: String,
pub pack: Option<String>,
pub side: Side,
}
// }}}
// {{{ Chart
#[derive(Debug, Clone, FromRow)]
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
pub struct Chart {
pub id: u32,
pub song_id: u32,
pub shorthand: Option<String>,
pub note_design: Option<String>,
pub difficulty: Difficulty,
pub level: String, // TODO: this could become an enum
@ -200,8 +240,12 @@ impl SongCache {
for song in songs {
let song = Song {
id: song.id as u32,
lowercase_title: song.title.to_lowercase(),
title: song.title,
artist: song.artist,
pack: song.pack,
bpm: song.bpm,
side: Side::try_from(song.side)?,
};
let song_id = song.id as usize;
@ -225,6 +269,7 @@ impl SongCache {
chart_constant: chart.chart_constant as u32,
note_count: chart.note_count as u32,
cached_jacket: None,
note_design: chart.note_design,
};
let index = chart.difficulty.to_index();

76
src/commands/chart.rs Normal file
View file

@ -0,0 +1,76 @@
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage};
use sqlx::query;
use crate::{
chart::Side,
context::{Context, Error},
score::guess_song_and_chart,
};
// {{{ Chart
/// Show a chart given it's name
#[poise::command(prefix_command, slash_command)]
pub async fn chart(
ctx: Context<'_>,
#[rest]
#[description = "Name of chart to show (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
let attachement_name = "chart.png";
let icon_attachement = match chart.cached_jacket {
Some(bytes) => Some(CreateAttachment::bytes(bytes, attachement_name)),
None => None,
};
let play_count = query!(
"
SELECT COUNT(*) as count
FROM plays
WHERE chart_id=?
",
chart.id
)
.fetch_one(&ctx.data().db)
.await?;
let mut embed = CreateEmbed::default()
.title(format!(
"{} [{:?} {}]",
&song.title, chart.difficulty, chart.level
))
.field("Note count", format!("{}", chart.note_count), true)
.field(
"Chart constant",
format!("{:.1}", chart.chart_constant as f32 / 100.0),
true,
)
.field("Total plays", format!("{}", play_count.count), true)
.field("BPM", &song.bpm, true)
.field("Side", Side::SIDE_STRINGS[song.side.to_index()], true)
.field("Artist", &song.title, true);
if let Some(note_design) = &chart.note_design {
embed = embed.field("Note design", note_design, true);
}
if let Some(pack) = &song.pack {
embed = embed.field("Pack", pack, true);
}
if icon_attachement.is_some() {
embed = embed.thumbnail(format!("attachment://{}", &attachement_name));
}
ctx.channel_id()
.send_files(
ctx.http(),
icon_attachement,
CreateMessage::new().embed(embed),
)
.await?;
Ok(())
}
// }}}

View file

@ -1,2 +1,27 @@
use crate::context::{Context, Error};
pub mod chart;
pub mod score;
pub mod stats;
// {{{ Help
/// Show this help menu
#[poise::command(prefix_command, track_edits, slash_command)]
pub async fn help(
ctx: Context<'_>,
#[description = "Specific command to show help about"]
#[autocomplete = "poise::builtins::autocomplete_command"]
command: Option<String>,
) -> Result<(), Error> {
poise::builtins::help(
ctx,
command.as_deref(),
poise::builtins::HelpConfiguration {
extra_text_at_bottom: "For additional support, message @prescientmoon",
..Default::default()
},
)
.await?;
Ok(())
}
// }}}

View file

@ -10,27 +10,6 @@ use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage};
use poise::{serenity_prelude as serenity, CreateReply};
use sqlx::query;
// {{{ Help
/// Show this help menu
#[poise::command(prefix_command, track_edits, slash_command)]
pub async fn help(
ctx: Context<'_>,
#[description = "Specific command to show help about"]
#[autocomplete = "poise::builtins::autocomplete_command"]
command: Option<String>,
) -> Result<(), Error> {
poise::builtins::help(
ctx,
command.as_deref(),
poise::builtins::HelpConfiguration {
extra_text_at_bottom: "For additional support, message @prescientmoon",
..Default::default()
},
)
.await?;
Ok(())
}
// }}}
// {{{ Score
/// Score management
#[poise::command(

View file

@ -17,9 +17,8 @@ use poise::{
use sqlx::query_as;
use crate::{
chart::Difficulty,
context::{Context, Error},
score::{guess_chart_name, DbPlay, Score},
score::{guess_song_and_chart, DbPlay, Score},
user::{discord_it_to_discord_user, User},
};
@ -65,22 +64,7 @@ pub async fn best(
}
};
let name = name.trim();
let (name, difficulty) = name
.strip_suffix("PST")
.zip(Some(Difficulty::PST))
.or_else(|| name.strip_suffix("[PST]").zip(Some(Difficulty::PST)))
.or_else(|| name.strip_suffix("PRS").zip(Some(Difficulty::PRS)))
.or_else(|| name.strip_suffix("[PRS]").zip(Some(Difficulty::PRS)))
.or_else(|| name.strip_suffix("FTR").zip(Some(Difficulty::FTR)))
.or_else(|| name.strip_suffix("[FTR]").zip(Some(Difficulty::FTR)))
.or_else(|| name.strip_suffix("ETR").zip(Some(Difficulty::ETR)))
.or_else(|| name.strip_suffix("[ETR]").zip(Some(Difficulty::ETR)))
.or_else(|| name.strip_suffix("BYD").zip(Some(Difficulty::BYD)))
.or_else(|| name.strip_suffix("[BYD]").zip(Some(Difficulty::BYD)))
.unwrap_or((&name, Difficulty::FTR));
let (song, chart) = guess_chart_name(name, &ctx.data().song_cache, Some(difficulty), true)?;
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
let play = query_as!(
DbPlay,
@ -137,22 +121,7 @@ pub async fn plot(
}
};
let name = name.trim();
let (name, difficulty) = name
.strip_suffix("PST")
.zip(Some(Difficulty::PST))
.or_else(|| name.strip_suffix("[PST]").zip(Some(Difficulty::PST)))
.or_else(|| name.strip_suffix("PRS").zip(Some(Difficulty::PRS)))
.or_else(|| name.strip_suffix("[PRS]").zip(Some(Difficulty::PRS)))
.or_else(|| name.strip_suffix("FTR").zip(Some(Difficulty::FTR)))
.or_else(|| name.strip_suffix("[FTR]").zip(Some(Difficulty::FTR)))
.or_else(|| name.strip_suffix("ETR").zip(Some(Difficulty::ETR)))
.or_else(|| name.strip_suffix("[ETR]").zip(Some(Difficulty::ETR)))
.or_else(|| name.strip_suffix("BYD").zip(Some(Difficulty::BYD)))
.or_else(|| name.strip_suffix("[BYD]").zip(Some(Difficulty::BYD)))
.unwrap_or((&name, Difficulty::FTR));
let (song, chart) = guess_chart_name(name, &ctx.data().song_cache, Some(difficulty), true)?;
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
let plays = query_as!(
DbPlay,

View file

@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::{fs, path::PathBuf};
use sqlx::SqlitePool;
@ -19,7 +19,10 @@ pub struct UserContext {
impl UserContext {
#[inline]
pub async fn new(data_dir: PathBuf, db: SqlitePool) -> Result<Self, Error> {
pub async fn new(data_dir: PathBuf, cache_dir: PathBuf, db: SqlitePool) -> Result<Self, Error> {
fs::create_dir_all(&cache_dir)?;
fs::create_dir_all(&data_dir)?;
let mut song_cache = SongCache::new(&db).await?;
let jacket_cache = JacketCache::new(&data_dir, &mut song_cache)?;

View file

@ -1,8 +1,10 @@
use std::{collections::HashSet, fs, path::PathBuf, str::FromStr};
use std::{fs, path::PathBuf, str::FromStr};
use image::{GenericImageView, Rgba};
use kd_tree::{KdMap, KdPoint};
use num::Integer;
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
use crate::{
chart::{Difficulty, SongCache},
@ -14,8 +16,10 @@ use crate::{
pub const SPLIT_FACTOR: u32 = 8;
pub const IMAGE_VEC_DIM: usize = (SPLIT_FACTOR * SPLIT_FACTOR * 3) as usize;
#[derive(Debug, Clone)]
#[serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageVec {
#[serde_as(as = "[_; IMAGE_VEC_DIM]")]
pub colors: [f32; IMAGE_VEC_DIM],
}
@ -73,9 +77,9 @@ impl KdPoint for ImageVec {
}
}
#[derive(Serialize, Deserialize)]
pub struct JacketCache {
// TODO: make this private
pub tree: KdMap<ImageVec, u32>,
tree: KdMap<ImageVec, u32>,
}
impl JacketCache {
@ -90,7 +94,7 @@ impl JacketCache {
fs::create_dir_all(&jacket_dir).expect("Could not create jacket dir");
let mut jackets: HashSet<(PathBuf, u32)> = HashSet::new();
let mut jackets = Vec::new();
let entries = fs::read_dir(data_dir.join("songs")).expect("Couldn't read songs directory");
for entry in entries {
let dir = entry?;
@ -120,7 +124,7 @@ impl JacketCache {
let (song, chart) = guess_chart_name(dir_name, &song_cache, difficulty, true)?;
jackets.insert((file.path(), song.id));
jackets.push((file.path(), song.id));
let contents = fs::read(file.path())?.leak();

View file

@ -30,6 +30,7 @@ async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
#[tokio::main]
async fn main() {
let data_dir = var("SHIMMERING_DATA_DIR").expect("Missing `SHIMMERING_DATA_DIR` env var");
let cache_dir = var("SHIMMERING_CACHE_DIR").expect("Missing `SHIMMERING_CACHE_DIR` env var");
let pool = SqlitePoolOptions::new()
.connect(&format!("sqlite://{}/db.sqlite", data_dir))
@ -39,9 +40,10 @@ async fn main() {
// {{{ Poise options
let options = poise::FrameworkOptions {
commands: vec![
commands::score::help(),
commands::help(),
commands::score::score(),
commands::stats::stats(),
commands::chart::chart(),
],
prefix_options: poise::PrefixFrameworkOptions {
stripped_dynamic_prefix: Some(|_ctx, message, _user_ctx| {
@ -76,11 +78,13 @@ async fn main() {
Box::pin(async move {
println!("Logged in as {}", _ready.user.name);
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
let ctx = UserContext::new(PathBuf::from_str(&data_dir)?, pool).await?;
let ctx = UserContext::new(
PathBuf::from_str(&data_dir)?,
PathBuf::from_str(&cache_dir)?,
pool,
)
.await?;
// for song in ctx.song_cache.lock().unwrap().songs() {
// song.lookup(Difficulty::BYD)
// }
Ok(ctx)
})
})

View file

@ -900,7 +900,38 @@ pub fn note_distribution_rects() -> (
// }}}
// }}}
// }}}
// {{{ Recognise chart name
// {{{ Recognise chart
fn strip_case_insensitive_suffix<'a>(string: &'a str, suffix: &str) -> Option<&'a str> {
let suffix = suffix.to_lowercase();
if string.to_lowercase().ends_with(&suffix) {
Some(&string[0..string.len() - suffix.len()])
} else {
None
}
}
pub fn guess_song_and_chart<'a>(
ctx: &'a UserContext,
name: &'a str,
) -> Result<(&'a Song, &'a Chart), Error> {
let name = name.trim();
let (name, difficulty) = name
.strip_suffix("PST")
.zip(Some(Difficulty::PST))
.or_else(|| strip_case_insensitive_suffix(name, "[PST]").zip(Some(Difficulty::PST)))
.or_else(|| strip_case_insensitive_suffix(name, "PRS").zip(Some(Difficulty::PRS)))
.or_else(|| strip_case_insensitive_suffix(name, "[PRS]").zip(Some(Difficulty::PRS)))
.or_else(|| strip_case_insensitive_suffix(name, "FTR").zip(Some(Difficulty::FTR)))
.or_else(|| strip_case_insensitive_suffix(name, "[FTR]").zip(Some(Difficulty::FTR)))
.or_else(|| strip_case_insensitive_suffix(name, "ETR").zip(Some(Difficulty::ETR)))
.or_else(|| strip_case_insensitive_suffix(name, "[ETR]").zip(Some(Difficulty::ETR)))
.or_else(|| strip_case_insensitive_suffix(name, "BYD").zip(Some(Difficulty::BYD)))
.or_else(|| strip_case_insensitive_suffix(name, "[BYD]").zip(Some(Difficulty::BYD)))
.unwrap_or((&name, Difficulty::FTR));
guess_chart_name(name, &ctx.song_cache, Some(difficulty), true)
}
/// Runs a specialized fuzzy-search through all charts in the game.
///
/// The `unsafe_heuristics` toggle increases the amount of resolvable queries, but might let in
@ -928,7 +959,7 @@ pub fn guess_chart_name<'a>(
item.charts().next()?
};
let song_title = song.title.to_lowercase();
let song_title = &song.lowercase_title;
distance_vec.clear();
let base_distance = edit_distance(&text, &song_title);