diff --git a/Cargo.lock b/Cargo.lock index ba03637..2893db5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,7 +1093,7 @@ dependencies = [ [[package]] name = "fren" -version = "2.4.1" +version = "2.5.0" dependencies = [ "axum 0.8.1", "base64 0.22.1", @@ -1118,9 +1118,11 @@ dependencies = [ "sha3", "songbird", "structopt", + "strum 0.27.2", "symphonia", "tera", "thiserror 2.0.12", + "thousands", "tokio", "tonic", "tracing-core", @@ -1464,7 +1466,7 @@ dependencies = [ "hex", "shorthand", "stable-vec", - "strum", + "strum 0.17.1", "thiserror 1.0.69", ] @@ -1998,7 +2000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3881,7 +3883,16 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "530efb820d53b712f4e347916c5e7ed20deb76a4f0457943b3182fb889b06d2c" dependencies = [ - "strum_macros", + "strum_macros 0.17.1", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", ] [[package]] @@ -3896,6 +3907,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.100", +] + [[package]] name = "subtle" version = "2.6.1" @@ -4228,6 +4251,12 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "thread_local" version = "1.1.8" @@ -5147,7 +5176,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 823b7f9..e8595ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fren" -version = "2.4.1" +version = "2.5.0" edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -33,6 +33,8 @@ log = "0.4.26" cta-api = { version = "0.5.0", registry = "ahines"} thiserror = "2.0.12" tracing-core = "0.1.33" +strum = { version = "0.27.2", features = ["derive"] } +thousands = "0.2.0" [dependencies.tokio] version = "1.35.1" diff --git a/src/discord/improvements.rs b/src/discord/improvements.rs new file mode 100644 index 0000000..3501c16 --- /dev/null +++ b/src/discord/improvements.rs @@ -0,0 +1,73 @@ +use crate::discord::Context; +use crate::error::Error; +use crate::models::improvements::{ImprovementType, Improvements}; +use crate::user::User; +use poise::serenity_prelude::MessageBuilder; +use strum::IntoEnumIterator; +use thousands::Separable; + +/// List out all the improvements available for the bot +#[poise::command(prefix_command, guild_only, category = "Improvements")] +pub async fn improvements(ctx: Context<'_>) -> Result<(), Error> { + let mut msg_builder = MessageBuilder::new(); + + msg_builder.push_line("# Fren Bot Improvements"); + msg_builder.push_line("Anything can be improved with enough Fren Coins!"); + msg_builder.push_line(""); + + for improvement_type in ImprovementType::iter() { + msg_builder.push("* "); + if let Some(improvement) = Improvements::get_improvement(&ctx.data().db, improvement_type)? + { + if improvement_type.has_levels() { + msg_builder.push(format!(" (✅ Level={}) ", improvement.level)); + } else { + msg_builder.push(" (✅) "); + } + } else { + msg_builder.push(format!( + "({} FC) ", + improvement_type.price().separate_with_commas() + )); + } + + msg_builder.push_line(format!( + "**{}**: {}", + improvement_type.name(), + improvement_type.description() + )); + } + + ctx.reply(msg_builder.build()).await?; + + Ok(()) +} + +/// Buy an improvement for the bot! +#[poise::command(prefix_command, guild_only, category = "Improvements")] +pub async fn buy_improvement( + ctx: Context<'_>, + #[description = "Improvement to buy"] + #[rest] + improvement_type: ImprovementType, +) -> Result<(), Error> { + if Improvements::get_improvement(&ctx.data().db, improvement_type)?.is_some() + && !improvement_type.has_levels() + { + ctx.reply("Sorry, that improvement has already been purchased.") + .await?; + return Ok(()); + } + + User::try_take_funds( + &ctx.data().db, + ctx.author().id, + improvement_type.price() as u32, + )?; + + Improvements::increment_improvement(&ctx.data().db, improvement_type)?; + + ctx.reply(improvement_type.post_buy_description()).await?; + + Ok(()) +} diff --git a/src/discord/mod.rs b/src/discord/mod.rs index f0dcade..178f93c 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -7,6 +7,7 @@ mod color; mod emoji_race; mod fren_coin; mod image; +pub mod improvements; mod joke; mod little_fren; mod movie; @@ -356,6 +357,8 @@ pub async fn run_bot(global_data: GlobalData) { image::green_screen(), image::overlay(), image::edit_img(), + improvements::improvements(), + improvements::buy_improvement(), movie::add_movie(), movie::list_movies(), movie::rate_movie(), diff --git a/src/error.rs b/src/error.rs index d03d826..b412dbb 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,7 @@ use crate::discord::voices::VoiceError; use crate::image_manipulation::ModifyImageArgError; use crate::models::gogurt_reserves::GogurtError; +use crate::models::improvements::ImprovementError; use crate::user; use magick_rust::MagickError; use serde::ser::StdError; @@ -28,6 +29,7 @@ pub enum Error { PipelineArgumentError(ModifyImageArgError), NoRandomFound, GogurtError(GogurtError), + ImprovementError(ImprovementError), } impl StdError for Error {} @@ -104,6 +106,12 @@ impl From for Error { } } +impl From for Error { + fn from(value: ImprovementError) -> Self { + Self::ImprovementError(value) + } +} + impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -125,6 +133,7 @@ impl Display for Error { Error::PipelineArgumentError(err) => write!(f, "{err}"), Error::NoRandomFound => write!(f, "No random found"), Error::GogurtError(err) => write!(f, "{err}"), + Error::ImprovementError(err) => write!(f, "{err}"), } } } diff --git a/src/event_listener/mod.rs b/src/event_listener/mod.rs index 75e4ac2..b9bac5f 100644 --- a/src/event_listener/mod.rs +++ b/src/event_listener/mod.rs @@ -1,4 +1,5 @@ use crate::config::GlobalData; +use crate::discord::get_role; use crate::error::Error; use crate::inventory::{ItemData, ItemType}; use crate::user::{User, UserRole}; @@ -12,7 +13,6 @@ use rand::{Rng, rng}; use serde::{Deserialize, Serialize}; use std::fmt::Debug; use std::sync::Arc; -use crate::discord::get_role; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type")] @@ -245,10 +245,17 @@ impl Listener { } Action::Ghoulify { hours } => { // slight hack to prevent ghoul spamming - let ghoul_role = get_role(&ctx.http, data.cfg.guild_id, &UserRole::Ghoul.to_string()).await?; - if let Ok(user) = data.cfg.guild_id.member(&ctx.http(), trigger_event.triggerer).await - && let Some(ghoul_role) = ghoul_role - && user.roles.contains(&ghoul_role) { + let ghoul_role = + get_role(&ctx.http, data.cfg.guild_id, &UserRole::Ghoul.to_string()) + .await?; + if let Ok(user) = data + .cfg + .guild_id + .member(&ctx.http(), trigger_event.triggerer) + .await + && let Some(ghoul_role) = ghoul_role + && user.roles.contains(&ghoul_role) + { break; } diff --git a/src/models/gogurt_reserves.rs b/src/models/gogurt_reserves.rs index 61a16d2..c1760d6 100644 --- a/src/models/gogurt_reserves.rs +++ b/src/models/gogurt_reserves.rs @@ -1,6 +1,7 @@ use crate::error::Error; +use crate::models::improvements::{ImprovementType, Improvements}; use crate::user::User; -use chrono::{DateTime, NaiveTime, TimeZone, Utc}; +use chrono::{DateTime, Days, NaiveTime, TimeZone, Utc}; use j_db::database::Database; use log::info; use poise::serenity_prelude::UserId; @@ -13,13 +14,13 @@ const OPENING_HOUR: u32 = 7; const UPDATE_HOUR: u32 = 12; const CLOSING_HOUR: u32 = 18; +const NIGHT_MARKET_CLOSE: u32 = 23; + #[derive(Debug, Error)] pub enum GogurtError { #[error("Wow, you really don't have enough gogurt. What are you 12?? Just got buy some poor.")] NotEnoughGogurt, - #[error( - "Sorry, gogurt can only be bought and sold between the hours of 7AM to 6PM Naperville Time." - )] + #[error("Sorry, gogurt can only be bought and sold when the market is open!")] OutsideOfGogurtTradingHours, } @@ -52,27 +53,79 @@ impl GogurtReserves { .unwrap_or(Self::default())) } + fn market_time(time: u32) -> NaiveTime { + NaiveTime::from_hms_opt(time, 0, 0).unwrap() + } + pub fn market_opening_time() -> NaiveTime { - NaiveTime::from_hms_opt(OPENING_HOUR, 0, 0).unwrap() + Self::market_time(OPENING_HOUR) } pub fn market_update_time() -> NaiveTime { - NaiveTime::from_hms_opt(UPDATE_HOUR, 0, 0).unwrap() + Self::market_time(UPDATE_HOUR) } - pub fn market_closing_time() -> NaiveTime { - NaiveTime::from_hms_opt(CLOSING_HOUR, 0, 0).unwrap() + pub fn normal_market_closing_time() -> NaiveTime { + Self::market_time(CLOSING_HOUR) } - pub fn check_if_in_trading_hours(time: &DateTime) -> bool { + pub fn night_market_closing_time() -> NaiveTime { + Self::market_time(NIGHT_MARKET_CLOSE) + } + + pub fn market_closing_time(db: &Database) -> Result { + let closing_time = if Self::has_night_market(db)? { + Self::night_market_closing_time() + } else { + Self::normal_market_closing_time() + }; + + Ok(closing_time) + } + + pub fn has_night_market(db: &Database) -> Result { + Ok(Improvements::get_improvement(db, ImprovementType::GogurtNightMarket)?.is_some()) + } + + pub fn get_next_market_update_time(db: &Database) -> Result, Error> { + let chicago_time = chrono_tz::America::Chicago.from_utc_datetime(&Utc::now().naive_utc()); + + let mut update_times = vec![Self::market_opening_time(), Self::market_update_time()]; + + if Self::has_night_market(db)? { + update_times.push(Self::normal_market_closing_time()) + } + + let last_update_of_the_day = *update_times.last().unwrap(); + let next_check = if chicago_time.time() > last_update_of_the_day { + chicago_time + .with_time(update_times[0]) + .unwrap() + .checked_add_days(Days::new(1)) + .unwrap() + } else { + let time = update_times + .into_iter() + .find(|t| &chicago_time.time() < t) + .unwrap(); + + chicago_time.with_time(time).unwrap() + }; + + let next_check = next_check.with_timezone(&Utc); + + Ok(next_check) + } + + pub fn check_if_in_trading_hours(db: &Database, time: &DateTime) -> Result { let chicago_time = chrono_tz::America::Chicago.from_utc_datetime(&time.naive_utc()); - chicago_time.time() >= Self::market_opening_time() - && chicago_time.time() < Self::market_closing_time() + Ok(chicago_time.time() >= Self::market_opening_time() + && chicago_time.time() < Self::market_closing_time(db)?) } pub fn add_contribution(db: &Database, user: UserId, fc_amount: u64) -> Result { - if !Self::check_if_in_trading_hours(&Utc::now()) { + if !Self::check_if_in_trading_hours(db, &Utc::now())? { return Err(GogurtError::OutsideOfGogurtTradingHours.into()); } @@ -97,7 +150,7 @@ impl GogurtReserves { user: UserId, pounds_of_gogurt: f64, ) -> Result { - if !Self::check_if_in_trading_hours(&Utc::now()) { + if !Self::check_if_in_trading_hours(db, &Utc::now())? { return Err(GogurtError::OutsideOfGogurtTradingHours.into()); } diff --git a/src/models/improvements.rs b/src/models/improvements.rs new file mode 100644 index 0000000..968fccc --- /dev/null +++ b/src/models/improvements.rs @@ -0,0 +1,140 @@ +use crate::error::Error; +use j_db::database::Database; +use j_db::model::JdbModel; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::{Debug, Display, Formatter}; +use std::str::FromStr; +use strum::EnumIter; + +#[derive(Debug)] +pub enum ImprovementError { + UnknownImprovement, +} + +impl Display for ImprovementError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + ImprovementError::UnknownImprovement => write!( + f, + "I don't know what that improvement is, sounds lame and cringe!" + ), + } + } +} + +impl std::error::Error for ImprovementError {} + +#[derive(Debug, Deserialize, Serialize, Clone, Hash, Eq, PartialEq, Copy, EnumIter)] +pub enum ImprovementType { + GogurtNightMarket, +} + +impl ImprovementType { + pub fn description(&self) -> String { + match self { + ImprovementType::GogurtNightMarket => { + "Keep the Gogurt Market open until 11 PM NST".to_string() + } + } + } + + pub fn price(&self) -> u64 { + match self { + ImprovementType::GogurtNightMarket => 200_000, + } + } + + pub fn has_levels(&self) -> bool { + false + } + + pub fn name(&self) -> String { + match self { + ImprovementType::GogurtNightMarket => "Gogurt Night Market".to_string(), + } + } + + pub fn post_buy_description(&self) -> String { + match self { + ImprovementType::GogurtNightMarket => "Congrats on investing in the night market. The first day of night trading will start tomorrow!".to_string() + } + } +} + +impl FromStr for ImprovementType { + type Err = ImprovementError; + + fn from_str(s: &str) -> Result { + let name = s.to_lowercase(); + + match name.as_str() { + "gogurt night market" => Ok(Self::GogurtNightMarket), + _ => Err(ImprovementError::UnknownImprovement), + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] +pub struct Improvement { + pub level: u32, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Improvements { + id: Option, + pub improvements: HashMap, +} + +impl Improvements { + pub fn get_improvement_config(db: &Database) -> Result { + Ok(db.filter(|_, _: &Self| true)?.next().unwrap_or(Self { + id: None, + improvements: HashMap::new(), + })) + } + + pub fn get_improvement( + db: &Database, + improvement_type: ImprovementType, + ) -> Result, Error> { + let improvements = Self::get_improvement_config(db)?; + + Ok(improvements.improvements.get(&improvement_type).cloned()) + } + + pub fn increment_improvement( + db: &Database, + improvement_type: ImprovementType, + ) -> Result { + let mut improvements = Self::get_improvement_config(db)?; + + let entry = improvements + .improvements + .entry(improvement_type) + .or_insert(Improvement { level: 1 }) + .clone(); + + db.insert(improvements)?; + + Ok(entry.level) + } +} + +impl JdbModel for Improvements { + fn id(&self) -> Option { + self.id + } + + fn set_id(&mut self, id: u64) { + self.id = Some(id) + } + + fn tree() -> String { + "Improvements".to_string() + } + + fn check_unique(&self, _: &Self) -> bool { + false + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index d0ac263..5340491 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod api_key; pub mod birthday; pub mod gogurt_reserves; +pub mod improvements; pub mod insult_compliment; pub mod lil_fren; pub mod managed_roles; diff --git a/src/models/task.rs b/src/models/task.rs index 1d1cf0f..f1bd14e 100644 --- a/src/models/task.rs +++ b/src/models/task.rs @@ -201,26 +201,11 @@ impl Task { } TaskType::UpdateGogurtRate => { GogurtReserves::update_rate(&data.db)?; - - let chicago_time = - chrono_tz::America::Chicago.from_utc_datetime(&Utc::now().naive_utc()); - - let market_open_time = GogurtReserves::market_opening_time(); - let midday_update_time = GogurtReserves::market_update_time(); - - let next_check = if chicago_time.time() >= midday_update_time { - chicago_time - .with_time(market_open_time) - .unwrap() - .checked_add_days(Days::new(1)) - .unwrap() - } else { - chicago_time.with_time(midday_update_time).unwrap() - }; - - let next_check = next_check.with_timezone(&Utc); - - Task::add_task(&data.db, TaskType::UpdateGogurtRate, next_check)?; + Task::add_task( + &data.db, + TaskType::UpdateGogurtRate, + GogurtReserves::get_next_market_update_time(&data.db)?, + )?; } }