1
Fork 0
shimmeringmoon/src/commands/stats.rs

523 lines
13 KiB
Rust

use std::io::Cursor;
use anyhow::anyhow;
use image::{DynamicImage, ImageBuffer};
use poise::{
serenity_prelude::{CreateAttachment, CreateEmbed},
CreateReply,
};
use crate::{
arcaea::{
achievement::GoalStats,
chart::Level,
jacket::BITMAP_IMAGE_SIZE,
play::{compute_b30_ptt, get_best_plays},
rating::rating_as_float,
score::ScoringSystem,
},
assert_is_pookie,
assets::{
get_difficulty_background, with_font, B30_BACKGROUND, COUNT_BACKGROUND, EXO_FONT,
GRADE_BACKGROUND, NAME_BACKGROUND, PTT_EMBLEM, SCORE_BACKGROUND, STATUS_BACKGROUND,
TOP_BACKGROUND,
},
bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect},
context::{Context, Error},
get_user,
logs::debug_image_log,
reply_errors, timed,
user::User,
};
// {{{ Stats
/// Stats display
#[poise::command(
prefix_command,
slash_command,
subcommands("meta", "b30", "bany"),
subcommand_required
)]
pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
// }}}
// {{{ Render best plays
async fn best_plays(
ctx: &mut Context<'_>,
user: &User,
scoring_system: ScoringSystem,
grid_size: (u32, u32),
require_full: bool,
) -> Result<(), Error> {
let user_ctx = ctx.data();
let plays = reply_errors!(
ctx,
timed!("get_best_plays", {
get_best_plays(
user_ctx,
user.id,
scoring_system,
if require_full {
grid_size.0 * grid_size.1
} else {
grid_size.0 * (grid_size.1.max(1) - 1) + 1
} as usize,
(grid_size.0 * grid_size.1) as usize,
None,
)?
})
);
// {{{ Layout
let mut layout = LayoutManager::default();
let jacket_area = layout.make_box(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE);
let jacket_with_border = layout.margin_uniform(jacket_area, 3);
let jacket_margin = 10;
let jacket_with_margin = layout.margin(
jacket_with_border,
jacket_margin,
jacket_margin,
2,
jacket_margin,
);
let top_left_area = layout.make_box(90, layout.height(jacket_with_margin));
let top_area = layout.glue_vertically(top_left_area, jacket_with_margin);
let bottom_area = layout.make_box(layout.width(top_area), 43);
let bottom_in_area = layout.margin_xy(bottom_area, -20, -7);
let item_area = layout.glue_horizontally(top_area, bottom_area);
let item_with_margin = layout.margin_xy(item_area, 22, 17);
let (item_grid, item_origins) =
layout.repeated_evenly(item_with_margin, (grid_size.0, grid_size.1));
let root = layout.margin_uniform(item_grid, 30);
// }}}
// {{{ Rendering prep
let width = layout.width(root);
let height = layout.height(root);
let canvas = BitmapCanvas::new(width, height);
let mut drawer = LayoutDrawer::new(layout, canvas);
// }}}
// {{{ Render background
let bg = &*B30_BACKGROUND;
let scale = (drawer.layout.width(root) as f32 / bg.width() as f32)
.max(drawer.layout.height(root) as f32 / bg.height() as f32)
.max(1.0)
.ceil() as u32;
drawer.blit_rbg_scaled_up(
root,
// Align the center of the image with the center of the root
Rect::from_image(bg).scaled(scale).align(
(Align::Center, Align::Center),
drawer.layout.lookup(root).center(),
),
bg.dimensions(),
bg.as_raw(),
scale,
);
// }}}
for (i, origin) in item_origins.enumerate() {
drawer
.layout
.edit_to_relative(item_with_margin, item_grid, origin.0, origin.1);
let top_bg = &*TOP_BACKGROUND;
drawer.blit_rbga(top_area, (0, 0), top_bg);
let (play, song, chart) = if let Some(item) = plays.get(i) {
item
} else {
break;
};
// {{{ Display index
let bg = &*COUNT_BACKGROUND;
let bg_center = Rect::from_image(bg).center();
// Draw background
drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg);
with_font(&EXO_FONT, |faces| {
drawer.text(
item_area,
(bg_center.0 - 12, bg_center.1 - 3 + jacket_margin),
faces,
crate::bitmap::TextStyle {
size: 25,
weight: Some(800),
color: Color::WHITE,
align: (Align::Center, Align::Center),
stroke: None,
drop_shadow: Some((Color::BLACK.alpha(0xaa), (2, 2))),
},
&format!("#{}", i + 1),
)
})?;
// }}}
// {{{ Display chart name
// Draw background
let bg = &*NAME_BACKGROUND;
drawer.blit_rbga(bottom_area, (0, 0), bg);
// Draw text
with_font(&EXO_FONT, |faces| {
let initial_size = 24;
let mut style = crate::bitmap::TextStyle {
size: initial_size,
weight: Some(800),
color: Color::WHITE,
align: (Align::Start, Align::Center),
stroke: Some((Color::BLACK, 1.5)),
drop_shadow: None,
};
while BitmapCanvas::plan_text_rendering((0, 0), faces, style, &song.title)?
.1
.width >= drawer.layout.width(bottom_in_area)
{
style.size -= 3;
style.stroke = Some((
Color::BLACK,
style.size as f32 / (initial_size as f32) * 1.5,
));
}
drawer.text(
bottom_in_area,
(0, drawer.layout.height(bottom_in_area) as i32 / 2),
faces,
style,
&song.title,
)
})?;
// }}}
// {{{ Display jacket
let jacket = chart.cached_jacket.as_ref().ok_or_else(|| {
anyhow!(
"Cannot find jacket for chart {} [{:?}]",
song.title,
chart.difficulty
)
})?;
drawer.fill(jacket_with_border, Color::from_rgb_int(0x271E35));
drawer.blit_rbg(jacket_area, (0, 0), jacket.bitmap);
// }}}
// {{{ Display difficulty background
let diff_bg = get_difficulty_background(chart.difficulty);
let diff_bg_area = Rect::from_image(diff_bg).align_whole(
(Align::Center, Align::Center),
(drawer.layout.width(jacket_with_border) as i32, 0),
);
drawer.blit_rbga(jacket_with_border, diff_bg_area.top_left(), diff_bg);
// }}}
// {{{ Display difficulty text
let level_text = Level::LEVEL_STRINGS[chart.level.to_index()];
let x_offset = if level_text.ends_with("+") {
3
} else if chart.level == Level::Eleven {
-2
} else {
0
};
let diff_area_center = diff_bg_area.center();
with_font(&EXO_FONT, |faces| {
drawer.text(
jacket_with_border,
(diff_area_center.0 + x_offset, diff_area_center.1),
faces,
crate::bitmap::TextStyle {
size: 25,
weight: Some(600),
color: Color::from_rgb_int(0xffffff),
align: (Align::Center, Align::Center),
stroke: None,
drop_shadow: None,
},
level_text,
)
})?;
// }}}
// {{{ Display score background
let score_bg = &*SCORE_BACKGROUND;
let score_bg_pos = Rect::from_image(score_bg).align(
(Align::End, Align::End),
(
drawer.layout.width(jacket_area) as i32,
drawer.layout.height(jacket_area) as i32,
),
);
drawer.blit_rbga(jacket_area, score_bg_pos, score_bg);
// }}}
// {{{ Display score text
with_font(&EXO_FONT, |faces| {
drawer.text(
jacket_area,
(
score_bg_pos.0 + 5,
score_bg_pos.1 + score_bg.height() as i32 / 2,
),
faces,
crate::bitmap::TextStyle {
size: 23,
weight: Some(800),
color: Color::WHITE,
align: (Align::Start, Align::Center),
stroke: Some((Color::BLACK, 1.5)),
drop_shadow: None,
},
&format!("{:0>10}", format!("{}", play.score(scoring_system))),
)
})?;
// }}}
// {{{ Display status background
let status_bg = &*STATUS_BACKGROUND;
let status_bg_area = Rect::from_image(status_bg).align_whole(
(Align::Center, Align::Center),
(
drawer.layout.width(jacket_area) as i32 + 3,
drawer.layout.height(jacket_area) as i32 + 1,
),
);
drawer.blit_rbga(jacket_area, status_bg_area.top_left(), status_bg);
// }}}
// {{{ Display status text
with_font(&EXO_FONT, |faces| {
let status = play.short_status(scoring_system, chart).ok_or_else(|| {
anyhow!(
"Could not get status for score {}",
play.score(scoring_system)
)
})?;
let x_offset = match status {
'P' => 2,
'M' => 2,
// TODO: ensure the F is rendered properly as well
_ => 0,
};
let center = status_bg_area.center();
drawer.text(
jacket_area,
(center.0 + x_offset, center.1),
faces,
crate::bitmap::TextStyle {
size: if status == 'M' { 30 } else { 36 },
weight: Some(if status == 'M' { 800 } else { 500 }),
color: Color::WHITE,
align: (Align::Center, Align::Center),
stroke: None,
drop_shadow: None,
},
&format!("{}", status),
)
})?;
// }}}
// {{{ Display grade background
let top_left_center = (drawer.layout.width(top_left_area) as i32 + jacket_margin) / 2;
let grade_bg = &*GRADE_BACKGROUND;
let grade_bg_area = Rect::from_image(grade_bg).align_whole(
(Align::Center, Align::Center),
(top_left_center, jacket_margin + 140),
);
drawer.blit_rbga(top_area, grade_bg_area.top_left(), grade_bg);
// }}}
// {{{ Display grade text
with_font(&EXO_FONT, |faces| {
let grade = play.score(scoring_system).grade();
let center = grade_bg_area.center();
drawer.text(
top_left_area,
(center.0, center.1),
faces,
crate::bitmap::TextStyle {
size: 30,
weight: Some(650),
color: Color::from_rgb_int(0x203C6B),
align: (Align::Center, Align::Center),
stroke: Some((Color::WHITE, 1.5)),
drop_shadow: None,
},
&format!("{}", grade),
)
})?;
// }}}
// {{{ Display rating text
with_font(&EXO_FONT, |faces| -> Result<(), Error> {
let mut style = crate::bitmap::TextStyle {
size: 12,
weight: Some(600),
color: Color::WHITE,
align: (Align::Center, Align::Center),
stroke: None,
drop_shadow: None,
};
drawer.text(
top_left_area,
(top_left_center, 73),
faces,
style,
"POTENTIAL",
)?;
style.size = 25;
style.weight = Some(700);
drawer.text(
top_left_area,
(top_left_center, 94),
faces,
style,
&format!(
"{:.2}",
play.play_rating_f32(scoring_system, chart.chart_constant)
),
)?;
Ok(())
})?;
// }}}
// {{{ Display ptt emblem
let ptt_emblem = &*PTT_EMBLEM;
drawer.blit_rbga(
top_left_area,
Rect::from_image(ptt_emblem)
.align((Align::Center, Align::Center), (top_left_center, 115)),
ptt_emblem,
);
// }}}
}
let mut out_buffer = Vec::new();
let mut image = DynamicImage::ImageRgb8(
ImageBuffer::from_raw(width, height, drawer.canvas.buffer.into_vec()).unwrap(),
);
debug_image_log(&image);
if image.height() > 4096 {
image = image.resize(4096, 4096, image::imageops::FilterType::Nearest);
}
let mut cursor = Cursor::new(&mut out_buffer);
image.write_to(&mut cursor, image::ImageFormat::WebP)?;
let reply = CreateReply::default()
.attachment(CreateAttachment::bytes(out_buffer, "b30.png"))
.content(format!(
"Your ptt is {:.2}",
rating_as_float(compute_b30_ptt(scoring_system, &plays))
));
ctx.send(reply).await?;
Ok(())
}
// }}}
// {{{ B30
/// Show the 30 best scores
#[poise::command(prefix_command, slash_command, user_cooldown = 30)]
pub async fn b30(mut ctx: Context<'_>, scoring_system: Option<ScoringSystem>) -> Result<(), Error> {
let user = get_user!(&mut ctx);
best_plays(
&mut ctx,
&user,
scoring_system.unwrap_or_default(),
(5, 6),
true,
)
.await
}
#[poise::command(prefix_command, slash_command, hide_in_help, global_cooldown = 5)]
pub async fn bany(
mut ctx: Context<'_>,
scoring_system: Option<ScoringSystem>,
width: u32,
height: u32,
) -> Result<(), Error> {
let user = get_user!(&mut ctx);
assert_is_pookie!(ctx, user);
best_plays(
&mut ctx,
&user,
scoring_system.unwrap_or_default(),
(width, height),
false,
)
.await
}
// }}}
// {{{ Meta
/// Show stats about the bot itself.
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn meta(mut ctx: Context<'_>) -> Result<(), Error> {
let user = get_user!(&mut ctx);
let conn = ctx.data().db.get()?;
let song_count: usize = conn
.prepare_cached("SELECT count() as count FROM songs")?
.query_row((), |row| row.get(0))?;
let chart_count: usize = conn
.prepare_cached("SELECT count() as count FROM charts")?
.query_row((), |row| row.get(0))?;
let users_count: usize = conn
.prepare_cached("SELECT count() as count FROM users")?
.query_row((), |row| row.get(0))?;
let pookie_count: usize = conn
.prepare_cached(
"
SELECT count() as count
FROM users
WHERE is_pookie=1
",
)?
.query_row((), |row| row.get(0))?;
let play_count: usize = conn
.prepare_cached("SELECT count() as count FROM plays")?
.query_row((), |row| row.get(0))?;
let your_play_count: usize = conn
.prepare_cached(
"
SELECT count() as count
FROM plays
WHERE user_id=?
",
)?
.query_row([user.id], |row| row.get(0))?;
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(())
}
// }}}