Skip to content

Commit

Permalink
feat: daily GeoGrid leaderboard
Browse files Browse the repository at this point in the history
  • Loading branch information
maddiemort committed Oct 6, 2024
1 parent c5df5ba commit 63e1304
Show file tree
Hide file tree
Showing 2 changed files with 192 additions and 25 deletions.
98 changes: 98 additions & 0 deletions src/leaderboards.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use indoc::indoc;
use serenity::all::{GuildId, UserId};
use sqlx::{Error as SqlxError, FromRow, PgPool};
use thiserror::Error;
use tracing::{debug, error, info};

#[derive(Clone, Debug)]
pub struct Daily {
pub entries: Vec<DailyEntry>,
}

#[derive(Clone, Debug)]
pub struct DailyEntry {
pub user_id: UserId,
pub username: String,
pub correct: usize,
pub score: f32,
}

impl From<DailyEntryRow> for DailyEntry {
fn from(row: DailyEntryRow) -> Self {
Self {
user_id: UserId::new(row.user_id as u64),
username: row.username,
correct: row.correct as usize,
score: row.score,
}
}
}

#[derive(Clone, Debug, FromRow)]
struct DailyEntryRow {
user_id: i64,
username: String,
correct: i32,
score: f32,
}

#[derive(Debug, Error)]
pub enum CalculateDailyError {
#[error("failed to extract data from row: {0}")]
FromRow(#[source] SqlxError),

#[error("unexpected SQLx error: {0}")]
Unexpected(SqlxError),
}

impl Daily {
pub async fn calculate_for(
db_pool: &PgPool,
guild_id: GuildId,
day: usize,
) -> Result<Self, CalculateDailyError> {
let get_scores = sqlx::query(indoc! {"
SELECT
s.user_id,
u.username,
s.correct,
s.score
FROM
scores s
INNER JOIN users u USING (user_id)
WHERE
s.guild_id = $1
AND s.board = $2
AND s.board = s.day_added
ORDER BY score ASC;
"});
let entries = match get_scores
.bind(guild_id.get() as i64)
.bind(day as i32)
.fetch_all(db_pool)
.await
{
Ok(rows) => {
info!("fetched all scores");

rows.into_iter()
.map(|row| {
DailyEntryRow::from_row(&row)
.map(|row| {
#[cfg(debug_assertions)]
debug!(?row, "got leaderboard entry");
row.into()
})
.map_err(CalculateDailyError::FromRow)
})
.collect::<Result<Vec<_>, CalculateDailyError>>()?
}
Err(error) => {
error!(%error, "failed to fetch all scores");
return Err(CalculateDailyError::Unexpected(error));
}
};

Ok(Daily { entries })
}
}
119 changes: 94 additions & 25 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
use std::fmt::Write;

use serenity::{
all::{Command, CommandOptionType, Interaction, ResolvedOption, ResolvedValue},
all::{
Command, CommandInteraction, CommandOptionType, Interaction, Mention, ResolvedOption,
ResolvedValue,
},
async_trait,
builder::{
CreateCommand, CreateCommandOption, CreateInteractionResponse,
CreateInteractionResponseMessage,
CreateAllowedMentions, CreateCommand, CreateCommandOption, CreateEmbed, CreateEmbedFooter,
CreateInteractionResponse, CreateInteractionResponseMessage,
},
model::{channel::Message, gateway::Ready},
prelude::*,
};
use sqlx::PgPool;
use tap::Pipe;
use tracing::{debug, error, info, warn};

use crate::{persist::ScoreInsertionError, score::Score};
use crate::{leaderboards::Daily, persist::ScoreInsertionError, score::Score};

pub mod geogrid;
pub mod leaderboards;
pub mod persist;
pub mod score;

Expand Down Expand Up @@ -115,32 +122,94 @@ impl EventHandler for Bot {
}

async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
async fn process_command(
command: &CommandInteraction,
db_pool: &PgPool,
) -> CreateInteractionResponseMessage {
let options = command.data.options();
let Some(ResolvedOption {
name: "range",
value: ResolvedValue::String(range),
..
}) = options.first()
else {
return CreateInteractionResponseMessage::new()
.content("An unexpected error occurred");
};

let Some(guild_id) = command.guild_id else {
warn!("cannot continue without guild ID");
return CreateInteractionResponseMessage::new()
.content("This command can only be run in a server!");
};

if *range == "leaderboard_today" {
let board = geogrid::board_now();
let today = Daily::calculate_for(db_pool, guild_id, board).await;

let mut embed = CreateEmbed::new().title("Today's Leaderboard").field(
"board",
format!("{}", board),
true,
);

let Ok(today) = today else {
error!(
error = %today.unwrap_err(),
"failed to calculate daily leaderboard"
);
return CreateInteractionResponseMessage::new()
.content("An unexpected error occurred.");
};

let mut description = String::new();
for (i, entry) in today.entries.into_iter().enumerate() {
let medal = match i {
0 => " 🥇",
1 => " 🥈",
2 => " 🥉",
_ => "",
};

writeln!(
&mut description,
"{}. {} ({} pts, {} correct){}",
i + 1,
Mention::User(entry.user_id),
entry.score,
entry.correct,
medal,
)
.expect("should be able to write into String");
}

embed = embed
.description(description)
.footer(CreateEmbedFooter::new(
"Medals may change with more submissions! Run `/leaderboard` again to see \
updated scores.",
));

CreateInteractionResponseMessage::new()
.embed(embed)
.allowed_mentions(CreateAllowedMentions::new())
} else if *range == "leaderboard_all_time" {
CreateInteractionResponseMessage::new().content("Coming soon!")
} else {
CreateInteractionResponseMessage::new().content("An unexpected error occurred.")
}
}

if let Interaction::Command(command) = interaction {
info!(?command, "received command interaction");

let response = match command.data.name.as_str() {
"leaderboard" => match command.data.options().first() {
Some(ResolvedOption {
value: ResolvedValue::String(_range),
name: "range",
..
}) => CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new().content("Coming soon!"),
),
_ => CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("An unexpected error occurred"),
),
},
name => CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("Unrecognized command \"{}\"", name)),
),
};
let response = process_command(&command, &self.db_pool)
.await
.pipe(CreateInteractionResponse::Message);

match command.create_response(&ctx.http, response).await {
Ok(_) => info!("responded to interaction"),
Err(error) => error!(%error, "failed to respond to interaction"),
Ok(_) => info!("responded to command"),
Err(error) => error!(%error, "failed to respond to command"),
}
}
}
Expand Down

0 comments on commit 63e1304

Please sign in to comment.