1
Fork 0

Implement a few math commands

This commit is contained in:
prescientmoon 2024-10-04 15:17:51 +02:00
parent f3edaf9e72
commit 2ac13510f2
Signed by: prescientmoon
SSH key fingerprint: SHA256:WFp/cO76nbarETAoQcQXuV+0h7XJsEsOCI0UsyPIy6U
19 changed files with 596 additions and 57 deletions

View file

@ -1,6 +1,5 @@
use std::path::Path;
// {{{ Imports
use std::{fmt::Display, num::NonZeroU16, path::PathBuf};
use std::{fmt::Display, num::NonZeroU16};
use anyhow::anyhow;
use image::{ImageBuffer, Rgb};
@ -26,6 +25,8 @@ impl Difficulty {
[Self::PST, Self::PRS, Self::FTR, Self::ETR, Self::BYD];
pub const DIFFICULTY_SHORTHANDS: [&'static str; 5] = ["PST", "PRS", "FTR", "ETR", "BYD"];
pub const DIFFICULTY_SHORTHANDS_IN_BRACKETS: [&'static str; 5] =
["[PST]", "[PRS]", "[FTR]", "[ETR]", "[BYD]"];
pub const DIFFICULTY_STRINGS: [&'static str; 5] =
["PAST", "PRESENT", "FUTURE", "ETERNAL", "BEYOND"];
@ -53,11 +54,7 @@ impl FromSql 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()
)
write!(f, "{}", Self::DIFFICULTY_SHORTHANDS[self.to_index()])
}
}
@ -192,6 +189,24 @@ pub struct Song {
pub pack: Option<String>,
pub side: Side,
}
impl Song {
/// Returns true if multiple songs are known to exist with the given title.
#[inline]
pub fn ambigous_name(&self) -> bool {
self.title == "Genesis" || self.title == "Quon"
}
}
impl Display for Song {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.ambigous_name() {
write!(f, "{} ({})", self.title, self.artist)
} else {
write!(f, "{}", self.title)
}
}
}
// }}}
// {{{ Chart
#[derive(Debug, Clone, Copy)]
@ -215,6 +230,10 @@ pub struct Chart {
#[serde(skip)]
pub cached_jacket: Option<Jacket>,
/// If `None`, the default jacket is used.
/// Otherwise, a difficulty-specific jacket exists.
pub jacket_source: Option<Difficulty>,
}
// }}}
// {{{ Cached song
@ -374,6 +393,7 @@ impl SongCache {
note_count: row.get("note_count")?,
note_design: row.get("note_design")?,
cached_jacket: None,
jacket_source: None,
})
})?;

View file

@ -106,6 +106,7 @@ impl JacketCache {
Vec::new()
} else {
let suffix = format!("_{BITMAP_IMAGE_SIZE}.jpg");
let songs_dir = get_asset_dir().join("songs/by_id");
let entries =
fs::read_dir(songs_dir).with_context(|| "Couldn't read songs directory")?;
@ -127,7 +128,12 @@ impl JacketCache {
for entry in entries {
let file = entry?;
let raw_name = file.file_name();
let name = raw_name.to_str().unwrap().strip_suffix(".jpg").unwrap();
let name = raw_name.to_str().unwrap();
if !name.ends_with(&suffix) {
continue;
}
let name = name.strip_suffix(&suffix).unwrap();
let difficulty = Difficulty::DIFFICULTY_SHORTHANDS
.iter()
@ -146,6 +152,7 @@ impl JacketCache {
let chart = song_cache
.lookup_by_difficulty_mut(song_id, difficulty)
.unwrap();
chart.jacket_source = Some(difficulty);
chart.cached_jacket = Some(Jacket {
raw: contents,
bitmap,
@ -153,11 +160,12 @@ impl JacketCache {
} else {
for chart_id in song_cache.lookup_song(song_id)?.charts() {
let chart = song_cache.lookup_chart_mut(chart_id)?;
if chart.cached_jacket.is_none() {
if chart.jacket_source.is_none() {
chart.cached_jacket = Some(Jacket {
raw: contents,
bitmap,
});
chart.jacket_source = None;
}
}
}

View file

@ -1,4 +1,4 @@
use num::Rational32;
use num::{Rational32, ToPrimitive};
pub type Rating = Rational32;
@ -13,7 +13,10 @@ pub fn rating_as_fixed(rating: Rating) -> i32 {
/// Saves a rating rational as a float with precision 2.
#[inline]
pub fn rating_as_float(rating: Rating) -> f32 {
rating_as_fixed(rating) as f32 / 100.0
let hundred = Rational32::from_integer(100);
let rounded = (rating * hundred).round();
(rounded / hundred).to_f32().unwrap()
}
/// The pseudo-inverse of `rating_as_fixed`.

View file

@ -36,7 +36,7 @@ pub fn run() -> Result<(), Error> {
let entries = fs::read_dir(&raw_songs_dir)
.with_context(|| "Couldn't read songs directory")?
.collect::<Result<Vec<_>, _>>()
.with_context(|| format!("Could not read member of `songs/raw`"))?;
.with_context(|| "Could not read member of `songs/raw`")?;
for (i, dir) in entries.iter().enumerate() {
let raw_dir_name = dir.file_name();
@ -48,10 +48,7 @@ pub fn run() -> Result<(), Error> {
}
print!("{}/{}: {dir_name}", i, entries.len());
if i % 5 == 0 {
stdout().flush()?;
}
// }}}
let entries = fs::read_dir(dir.path())
@ -126,13 +123,31 @@ pub fn run() -> Result<(), Error> {
jacket_vectors.push((song.id, ImageVec::from_image(&image)));
let image = image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Gaussian);
let image_out_path =
let small_image =
image.resize(BITMAP_IMAGE_SIZE, BITMAP_IMAGE_SIZE, FilterType::Gaussian);
{
let image_small_path =
out_dir.join(format!("{difficulty_string}_{BITMAP_IMAGE_SIZE}.jpg"));
small_image
.save(&image_small_path)
.with_context(|| format!("Could not save image to {image_small_path:?}"))?;
}
{
let image_full_path = out_dir.join(format!("{difficulty_string}_full.jpg"));
image
// .blur(27.5)
.save(&image_out_path)
.with_context(|| format!("Could not save image to {image_out_path:?}"))?;
.save(&image_full_path)
.with_context(|| format!("Could not save image to {image_full_path:?}"))?;
}
{
let blurred_out_path = out_dir.join(format!("{difficulty_string}_blurred.jpg"));
small_image
.blur(27.5)
.save(&blurred_out_path)
.with_context(|| format!("Could not save image to {blurred_out_path:?}"))?;
}
}
}
@ -152,9 +167,9 @@ pub fn run() -> Result<(), Error> {
{
println!("Encoded {} images", jacket_vectors.len());
let bytes = postcard::to_allocvec(&jacket_vectors)
.with_context(|| format!("Coult not encode jacket matrix"))?;
.with_context(|| "Coult not encode jacket matrix")?;
fs::write(songs_dir.join("recognition_matrix"), bytes)
.with_context(|| format!("Could not write jacket matrix"))?;
.with_context(|| "Could not write jacket matrix")?;
}
Ok(())

View file

@ -21,6 +21,7 @@ async fn main() {
commands::score::score(),
commands::stats::stats(),
commands::chart::chart(),
commands::calc::calc(),
],
prefix_options: poise::PrefixFrameworkOptions {
stripped_dynamic_prefix: Some(|_ctx, message, _user_ctx| {

View file

@ -21,7 +21,7 @@ async fn main() -> Result<(), Error> {
ipc.connect().map_err(|e| anyhow!("{}", e))?;
println!("Starting presence loop...");
for i in 0.. {
loop {
println!("Getting most recent score...");
let res = reqwest::get(format!("{}/plays/latest", server_url)).await;
@ -42,7 +42,6 @@ async fn main() -> Result<(), Error> {
"{}/jackets/by_chart_id/{}.png",
server_url, &triplet.chart.id
);
let jacket_url = "https://static.wikia.nocookie.net/iowiro/images/c/c2/Fracture_Ray.jpg/revision/latest?cb=20230928061927";
println!("Jacket url: {}", jacket_url);
let jacket_text = format!("{}{}", &triplet.song.title, &triplet.song.artist);
@ -68,6 +67,4 @@ async fn main() -> Result<(), Error> {
ipc.set_activity(activity).map_err(|e| anyhow!("{}", e))?;
tokio::time::sleep(Duration::from_secs(30)).await;
}
Ok(())
}

184
src/commands/calc.rs Normal file
View file

@ -0,0 +1,184 @@
// {{{ Imports
use num::{FromPrimitive, Rational32};
use crate::arcaea::play::{compute_b30_ptt, get_best_plays};
use crate::arcaea::rating::{rating_as_float, rating_from_fixed, Rating};
use crate::context::{Context, Error, TaggedError};
use crate::recognition::fuzzy_song_name::guess_song_and_chart;
use crate::user::User;
use crate::arcaea::score::{Score, ScoringSystem};
use super::discord::MessageContext;
// }}}
// {{{ Top command
/// Compute different things
#[poise::command(
prefix_command,
slash_command,
subcommands("expected", "rating"),
subcommand_required
)]
pub async fn calc(_ctx: Context<'_>) -> Result<(), Error> {
Ok(())
}
// }}}
// {{{ Expected
// {{{ Implementation
async fn expected_impl(
ctx: &mut impl MessageContext,
ptt: Option<Rational32>,
name: &str,
) -> Result<Score, TaggedError> {
let (song, chart) = guess_song_and_chart(ctx.data(), name)?;
let ptt = if let Some(ptt) = ptt {
ptt
} else {
let user = User::from_context(ctx)?;
compute_b30_ptt(
ScoringSystem::Standard,
&get_best_plays(ctx.data(), user.id, ScoringSystem::Standard, 30, 30, None)?,
)
};
let cc = rating_from_fixed(chart.chart_constant as i32);
let score = if ptt >= cc + 2 {
Rational32::from_integer(chart.note_count as i32 + 10_000_000)
} else if ptt >= cc + 1 {
Rational32::from_integer(9_800_000)
+ (ptt - cc - 1).reduced() * Rational32::from_integer(200_000)
} else {
Rational32::from_integer(9_500_000)
+ (ptt - cc).reduced() * Rational32::from_integer(300_000)
};
let score = Score(score.to_integer().max(0) as u32);
ctx.reply(&format!(
"The expected score for a player of potential {:.2} on {} [{}] is {}",
rating_as_float(ptt),
song,
chart.difficulty,
score
))
.await?;
Ok(score)
}
// }}}
// {{{ Tests
#[cfg(test)]
mod expected_tests {
use crate::{
commands::discord::mock::MockContext, context::testing::get_mock_context, golden_test,
};
use super::*;
#[tokio::test]
async fn consistent_with_rating() -> Result<(), Error> {
let (mut ctx, _guard) = get_mock_context().await?;
ctx.save_messages = false; // We don't want to waste time writing to a vec
for i in 0..1_000 {
let score = Score(i * 10_000);
let rating = score.play_rating(1140);
let res = expected_impl(&mut ctx, Some(rating), "Pentiment [BYD]")
.await
.map_err(|e| e.error)?;
assert_eq!(
score, res,
"Wrong expected score for starting score {score} and rating {rating}"
);
}
Ok(())
}
golden_test!(basic_usage, "commands/calc/expected/basic_usage");
async fn basic_usage(ctx: &mut MockContext) -> Result<(), TaggedError> {
expected_impl(
ctx,
Some(Rational32::from_f32(12.27).unwrap()),
"Vicious anti heorism",
)
.await?;
Ok(())
}
}
// }}}
// {{{ Discord wrapper
/// Computes the expected score for a player of some potential on a given chart.
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn expected(
mut ctx: Context<'_>,
#[description = "The potential to compute the expected score for"] ptt: Option<f32>,
#[rest]
#[description = "Name of chart (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let res = expected_impl(&mut ctx, ptt.and_then(Rational32::from_f32), &name).await;
ctx.handle_error(res).await?;
Ok(())
}
// }}}
// }}}
// {{{ Rating
// {{{ Implementation
async fn rating_impl(
ctx: &mut impl MessageContext,
score: Score,
name: &str,
) -> Result<Rating, TaggedError> {
let (song, chart) = guess_song_and_chart(ctx.data(), name)?;
let rating = score.play_rating(chart.chart_constant);
ctx.reply(&format!(
"The score {} on {} [{}] yields a rating of {:.2}",
score,
song,
chart.difficulty,
rating_as_float(rating),
))
.await?;
Ok(rating)
}
// }}}
// {{{ Tests
#[cfg(test)]
mod rating_tests {
use crate::{commands::discord::mock::MockContext, golden_test};
use super::*;
golden_test!(basic_usage, "commands/calc/rating/basic_usage");
async fn basic_usage(ctx: &mut MockContext) -> Result<(), TaggedError> {
rating_impl(ctx, Score(9_349_070), "Arcana Eden [PRS]").await?;
Ok(())
}
}
// }}}
// {{{ Discord wrapper
/// Computes the rating (potential) of a play on a given chart.
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
async fn rating(
mut ctx: Context<'_>,
score: u32,
#[rest]
#[description = "Name of chart (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let res = rating_impl(&mut ctx, Score(score), &name).await;
ctx.handle_error(res).await?;
Ok(())
}
// }}}
// }}}

View file

@ -1,5 +1,5 @@
use anyhow::anyhow;
// {{{ Imports
use anyhow::anyhow;
use poise::serenity_prelude::{CreateAttachment, CreateEmbed};
use crate::arcaea::{chart::Side, play::Play};
@ -138,7 +138,7 @@ mod info_tests {
async fn info(
mut ctx: Context<'_>,
#[rest]
#[description = "Name of chart to show (difficulty at the end)"]
#[description = "Name of chart (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let res = info_impl(&mut ctx, &name).await;
@ -251,7 +251,7 @@ mod best_tests {
async fn best(
mut ctx: Context<'_>,
#[rest]
#[description = "Name of chart to show (difficulty at the end)"]
#[description = "Name of chart (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let res = best_impl(&mut ctx, &name).await;
@ -403,7 +403,7 @@ async fn plot(
mut ctx: Context<'_>,
scoring_system: Option<ScoringSystem>,
#[rest]
#[description = "Name of chart to show (difficulty at the end)"]
#[description = "Name of chart (difficulty at the end)"]
name: String,
) -> Result<(), Error> {
let res = plot_impl(&mut ctx, scoring_system, name).await;

View file

@ -199,6 +199,9 @@ pub mod mock {
pub user_id: u64,
pub data: UserContext,
/// If true, messages will be saved in a vec.
pub save_messages: bool,
messages: Vec<ReplyEssence>,
}
@ -207,6 +210,7 @@ pub mod mock {
Self {
data,
user_id: 666,
save_messages: true,
messages: vec![],
}
}
@ -274,7 +278,10 @@ pub mod mock {
}
async fn send(&mut self, message: CreateReply) -> Result<(), Error> {
if self.save_messages {
self.messages.push(ReplyEssence::from_reply(message));
}
Ok(())
}

View file

@ -5,6 +5,7 @@ pub mod discord;
pub mod score;
pub mod stats;
pub mod utils;
pub mod calc;
// {{{ Help
/// Show this help menu

View file

@ -141,6 +141,10 @@ impl UserContext {
// {{{ Testing helpers
#[cfg(test)]
pub mod testing {
use tempfile::TempDir;
use crate::commands::discord::mock::MockContext;
use super::*;
pub async fn get_shared_context() -> &'static UserContext {
@ -165,6 +169,16 @@ pub mod testing {
);
}
pub async fn get_mock_context() -> Result<(MockContext, TempDir), Error> {
let mut data = (*get_shared_context().await).clone();
let dir = tempfile::tempdir()?;
data.db = connect_db(dir.path());
import_songs_and_jackets_from(dir.path());
let ctx = MockContext::new(data);
Ok((ctx, dir))
}
// rustfmt fucks up the formatting here,
// but the skip attribute doesn't seem to work well on macros 🤔
#[macro_export]
@ -184,12 +198,7 @@ pub mod testing {
($test_path:expr, $f:expr) => {{
use std::str::FromStr;
let mut data = (*$crate::context::testing::get_shared_context().await).clone();
let dir = tempfile::tempdir()?;
data.db = $crate::context::connect_db(dir.path());
$crate::context::testing::import_songs_and_jackets_from(dir.path());
let mut ctx = $crate::commands::discord::mock::MockContext::new(data);
let (mut ctx, _guard) = $crate::context::testing::get_mock_context().await?;
let res = $crate::user::User::create_from_context(&ctx);
ctx.handle_error(res).await?;

View file

@ -1,6 +1,8 @@
#![allow(async_fn_in_trait)]
#![allow(clippy::needless_range_loop)]
#![allow(clippy::redundant_closure)]
// This sometimes triggers for rationals, where it doesn't make sense
#![allow(clippy::int_plus_one)]
pub mod arcaea;
pub mod assets;

View file

@ -32,22 +32,23 @@ 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));
let mut name = name.trim();
let mut inferred_difficulty = None;
guess_chart_name(name, &ctx.song_cache, Some(difficulty), true)
for difficulty in Difficulty::DIFFICULTIES {
for shorthand in [
Difficulty::DIFFICULTY_SHORTHANDS[difficulty.to_index()],
Difficulty::DIFFICULTY_SHORTHANDS_IN_BRACKETS[difficulty.to_index()],
] {
if let Some(stripped) = strip_case_insensitive_suffix(name, shorthand) {
inferred_difficulty = Some(difficulty);
name = stripped;
break;
}
}
}
guess_chart_name(name, &ctx.song_cache, inferred_difficulty, true)
}
// }}}
// {{{ Guess chart by name
@ -74,11 +75,20 @@ pub fn guess_chart_name<'a>(
let mut close_enough: Vec<_> = cache
.charts()
.filter_map(|chart| {
if difficulty.map_or(false, |d| d != chart.difficulty) {
let cached_song = &cache.lookup_song(chart.song_id).ok()?;
let song = &cached_song.song;
let plausible_difficulty = match difficulty {
Some(difficulty) => difficulty == chart.difficulty,
None => {
let chart_count = cached_song.charts().count();
chart_count == 1 || chart.difficulty == Difficulty::FTR
}
};
if !plausible_difficulty {
return None;
}
let song = &cache.lookup_song(chart.song_id).ok()?.song;
let song_title = &song.lowercase_title;
distance_vec.clear();

View file

@ -0,0 +1,4 @@
reply = true
content = "The expected score for a player of potential 12.27 on Vicious [ANTi] Heroism [BYD] is 9'834'000"
embeds = []
attachments = []

View file

@ -0,0 +1,4 @@
reply = true
content = "The score 9'349'070 on Arcana Eden [PRS] yields a rating of 8.20"
embeds = []
attachments = []

View file

@ -0,0 +1,52 @@
reply = true
[[embeds]]
title = "Pentiment [FTR 10]"
type = "rich"
[embeds.thumbnail]
url = "attachment://chart.png"
[[embeds.fields]]
name = "Note count"
value = "1345"
inline = true
[[embeds.fields]]
name = "Chart constant"
value = "10.3"
inline = true
[[embeds.fields]]
name = "Total plays"
value = "0"
inline = true
[[embeds.fields]]
name = "BPM"
value = "200-222"
inline = true
[[embeds.fields]]
name = "Side"
value = "conflict"
inline = true
[[embeds.fields]]
name = "Artist"
value = "Pentiment"
inline = true
[[embeds.fields]]
name = "Note design"
value = "Paradox Blight"
inline = true
[[embeds.fields]]
name = "Pack"
value = "Final Verdict"
inline = true
[[attachments]]
filename = "chart.png"
hash = "sha256_5ffda660ce1c6ddd7c60bbb7f34443a7772e608f930768a147e423cc62b7e25d"

View file

@ -0,0 +1,52 @@
reply = true
[[embeds]]
title = "HELLOHELL [ETR 9]"
type = "rich"
[embeds.thumbnail]
url = "attachment://chart.png"
[[embeds.fields]]
name = "Note count"
value = "770"
inline = true
[[embeds.fields]]
name = "Chart constant"
value = "9.4"
inline = true
[[embeds.fields]]
name = "Total plays"
value = "0"
inline = true
[[embeds.fields]]
name = "BPM"
value = "155"
inline = true
[[embeds.fields]]
name = "Side"
value = "conflict"
inline = true
[[embeds.fields]]
name = "Artist"
value = "HELLOHELL"
inline = true
[[embeds.fields]]
name = "Note design"
value = "eién"
inline = true
[[embeds.fields]]
name = "Pack"
value = "World Extend 3: Illusions"
inline = true
[[attachments]]
filename = "chart.png"
hash = "sha256_e00a92ba1abbcf97c7b006447867914b9d95dc4dfb039e05260d71128b60eedb"

View file

@ -0,0 +1,57 @@
reply = true
[[embeds]]
title = "ALTER EGO [FTR 10]"
type = "rich"
[embeds.thumbnail]
url = "attachment://416-9926250-0.png"
[[embeds.fields]]
name = "Score"
value = "9'926'250"
inline = true
[[embeds.fields]]
name = "Rating"
value = "12.13"
inline = true
[[embeds.fields]]
name = "Grade"
value = "EX+"
inline = true
[[embeds.fields]]
name = "ξ-Score"
value = "9'693'042"
inline = true
[[embeds.fields]]
name = "ξ-Rating"
value = "11.14"
inline = true
[[embeds.fields]]
name = "ξ-Grade"
value = "AA"
inline = true
[[embeds.fields]]
name = "Status"
value = "C (-164/-12/-5)"
inline = true
[[embeds.fields]]
name = "Max recall"
value = "397"
inline = true
[[embeds.fields]]
name = "ID"
value = "1"
inline = true
[[attachments]]
filename = "416-9926250-0.png"
hash = "sha256_5f1febcdf44bc22bf7ef5bff0cee197a65a221277ebe9615d469002b4324ada3"

View file

@ -0,0 +1,113 @@
reply = true
[[embeds]]
title = "Antithese [FTR 8+]"
type = "rich"
[embeds.thumbnail]
url = "attachment://116-9983744-0.png"
[[embeds.fields]]
name = "Score"
value = "9'983'744"
inline = true
[[embeds.fields]]
name = "Rating"
value = "10.72"
inline = true
[[embeds.fields]]
name = "Grade"
value = "EX+"
inline = true
[[embeds.fields]]
name = "ξ-Score"
value = "9'920'182"
inline = true
[[embeds.fields]]
name = "ξ-Rating"
value = "10.40"
inline = true
[[embeds.fields]]
name = "ξ-Grade"
value = "EX+"
inline = true
[[embeds.fields]]
name = "Status"
value = "C (-27/-1/-1)"
inline = true
[[embeds.fields]]
name = "Max recall"
value = "479"
inline = true
[[embeds.fields]]
name = "ID"
value = "1"
inline = true
[[embeds]]
title = "GENOCIDER [FTR 10+]"
type = "rich"
[embeds.thumbnail]
url = "attachment://243-9724775-1.png"
[[embeds.fields]]
name = "Score"
value = "9'724'775"
inline = true
[[embeds.fields]]
name = "Rating"
value = "11.45"
inline = true
[[embeds.fields]]
name = "Grade"
value = "AA"
inline = true
[[embeds.fields]]
name = "ξ-Score"
value = "9'453'809"
inline = true
[[embeds.fields]]
name = "ξ-Rating"
value = "10.55"
inline = true
[[embeds.fields]]
name = "ξ-Grade"
value = "A"
inline = true
[[embeds.fields]]
name = "Status"
value = "C (-180/-40/-21)"
inline = true
[[embeds.fields]]
name = "Max recall"
value = "347"
inline = true
[[embeds.fields]]
name = "ID"
value = "2"
inline = true
[[attachments]]
filename = "116-9983744-0.png"
hash = "sha256_75b03ac3392d4bcb9d377396a36708aea1298dd463c08d5d62ca1e1414bfeaef"
[[attachments]]
filename = "243-9724775-1.png"
hash = "sha256_4a8ad7af5482104b3ec7c9a50091bb6f01df10a0cc758837cf47dcf7d73fd59d"