From 1669a9cf8f8b8f14b1f764c32f9051398a11b03a Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sat, 2 Aug 2025 20:39:44 -0600 Subject: [PATCH] Add land mine --- src/config.rs | 21 +++++- src/discord/admin.rs | 46 +++++++++++++ src/discord/mod.rs | 17 +++-- src/discord/shop.rs | 134 +++++++++++++++++++++++++++----------- src/event_listener/mod.rs | 59 +++++++++++++---- src/inventory/mod.rs | 28 ++++---- 6 files changed, 233 insertions(+), 72 deletions(-) diff --git a/src/config.rs b/src/config.rs index 895134a..adda60f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -83,6 +83,7 @@ pub struct GlobalData { pub picox: AlbumManager, pub cta: Mutex, pub speak_lock: Mutex<()>, + pub listener_lock: Mutex<()>, } impl GlobalData { @@ -93,12 +94,12 @@ impl GlobalData { }); info!("Adding system listeners..."); - Listener::add_event( + Listener::add_listener( db, Listener::new( TriggerType::OnMessage { channel_id: None, - content: "bad bot".to_string(), + content: Some("bad bot".to_string()), }, vec![Action::React { emoji: "😭".to_string(), @@ -108,6 +109,21 @@ impl GlobalData { true, ), )?; + Listener::add_listener( + db, + Listener::new( + TriggerType::OnMessage { + channel_id: None, + content: None, + }, + vec![Action::UpdateFrenCoins { + fren_coin_diff: 100, + }], + 0.05, + Expiration::Never, + true, + ), + )?; Ok(()) } @@ -136,6 +152,7 @@ impl GlobalData { picox: AlbumManager::new(cfg.picox.api_base_url, &cfg.picox.token), cta: Mutex::new(CTAClient::new(cfg.cta_key)), speak_lock: Default::default(), + listener_lock: Default::default(), }) } } diff --git a/src/discord/admin.rs b/src/discord/admin.rs index 8aee8db..d7af309 100644 --- a/src/discord/admin.rs +++ b/src/discord/admin.rs @@ -1,5 +1,6 @@ use crate::discord::{Context, get_role}; use crate::error::Error; +use crate::event_listener::Listener; use crate::inventory::ItemType; use crate::models::api_key::Apikey; use crate::models::lil_fren::{ @@ -405,3 +406,48 @@ pub async fn op_give_money( Ok(()) } + +/// List all the listeners +#[poise::command(prefix_command, category = "Admin", check = "is_admin")] +pub async fn list_listeners(ctx: Context<'_>) -> Result<(), Error> { + let mut msg_builder = MessageBuilder::new(); + msg_builder.push_line("The following listeners are active:"); + for listener in ctx.data().db.filter(|_, _listener: &Listener| true)? { + msg_builder.push_line(format!( + "* id={} `{:?}`:", + listener.id().unwrap(), + listener.trigger + )); + msg_builder.push_line(format!("\t* Trigger Count: `{:?}`", listener.trigger_count)); + msg_builder.push_line(format!( + "\t* Trigger Chance: `{:?}`", + listener.trigger_chance + )); + msg_builder.push_line(format!( + "\t* System Listener: `{}`", + listener.system_listener + )); + msg_builder.push_line(format!("\t* Actions: `{:?}`", listener.actions)); + } + + ctx.reply(msg_builder.build()).await?; + + Ok(()) +} + +/// Remove listener +#[poise::command(prefix_command, category = "Admin", check = "is_admin")] +pub async fn remove_listener( + ctx: Context<'_>, + #[description = "ID of the listener to remove"] listener_id: u64, +) -> Result<(), Error> { + let listener = ctx.data().db.remove::(listener_id)?; + + ctx.reply(format!( + "Removed listener with id={}", + listener.id().unwrap() + )) + .await?; + + Ok(()) +} diff --git a/src/discord/mod.rs b/src/discord/mod.rs index da5932d..8e14228 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -123,8 +123,6 @@ async fn handle_message( } }; - give_coin(&data.db, new_message.author.id, 0.05, 10).await?; - if let Some(phrase) = SocialCreditPhrase::check_if_match(&data.db, &new_message.content)? { info!( "{} matched phrase '{}' for social credit checking", @@ -141,7 +139,7 @@ async fn handle_message( new_message.author.id, TriggerType::OnMessage { channel_id: Some(new_message.channel_id), - content: new_message.content.clone(), + content: Some(new_message.content.clone()), }, new_message.id, new_message.channel_id, @@ -325,6 +323,8 @@ pub async fn run_bot(global_data: GlobalData) { admin::reset_random_score(), admin::remove_random_result(), admin::op_give_money(), + admin::list_listeners(), + admin::remove_listener(), album::add_image(), album::list_albums(), birthday::add_birthday(), @@ -372,7 +372,16 @@ pub async fn run_bot(global_data: GlobalData) { shop::item_help(), shop::sell_item(), shop::shop(), - shop::use_item(), + shop::cancel_insurance(), + shop::the_concept_of_love(), + shop::good_fortune(), + shop::nft(), + shop::license_to_be_horny(), + shop::kill_gun(), + shop::cancel_ray(), + shop::helmet(), + shop::emp(), + shop::land_mine(), transit::cta_bets(), voices::list_voices(), voices::list_words(), diff --git a/src/discord/shop.rs b/src/discord/shop.rs index 625d5bf..1be510a 100644 --- a/src/discord/shop.rs +++ b/src/discord/shop.rs @@ -1,13 +1,12 @@ use crate::config::GlobalData; use crate::discord::Context; use crate::error::Error; +use crate::event_listener::{Action, Expiration, Listener, TriggerType}; use crate::image_manipulation::create_motivation_image; use crate::inventory::{InventoryError, ItemData, ItemType, Operation, Target}; use crate::user::{User, UserError}; -use poise::ChoiceParameter; -use poise::serenity_prelude::UserId; -use poise::serenity_prelude::all::parse_user_mention; use poise::serenity_prelude::utils::MessageBuilder; +use poise::serenity_prelude::{Channel, CreateMessage, UserId}; use rand::{Rng, rng}; use std::collections::hash_map::DefaultHasher; use std::hash::Hasher; @@ -91,48 +90,109 @@ pub async fn buy( Ok(()) } -/// Use an item and hope it doesn't use you -#[poise::command(prefix_command, category = "Shop", guild_only, aliases("use"))] -pub async fn use_item( +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn cancel_insurance(ctx: Context<'_>) -> Result<(), Error> { + use_item(ctx, ItemType::CancelInsurance, Target::Myself).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn the_concept_of_love(ctx: Context<'_>) -> Result<(), Error> { + use_item(ctx, ItemType::TheConceptOfLove, Target::Myself).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn good_fortune(ctx: Context<'_>, #[rest] _msg: String) -> Result<(), Error> { + use_item(ctx, ItemType::TheConceptOfLove, Target::Myself).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn nft(ctx: Context<'_>) -> Result<(), Error> { + use_item(ctx, ItemType::Nft, Target::Myself).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn license_to_be_horny(ctx: Context<'_>) -> Result<(), Error> { + use_item(ctx, ItemType::LicenseToBeHorny, Target::Myself).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn kill_gun( ctx: Context<'_>, - #[description = "item to use"] - #[rest] - item: String, + #[description = "target to kill"] target: poise::serenity_prelude::User, ) -> Result<(), Error> { - if item.is_empty() { - ctx.reply("You need to select one item to use.").await?; - return Ok(()); + use_item(ctx, ItemType::KillGun, Target::User(target.id)).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn cancel_ray( + ctx: Context<'_>, + #[description = "target to cancel"] target: poise::serenity_prelude::User, +) -> Result<(), Error> { + use_item(ctx, ItemType::CancelRay, Target::User(target.id)).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn helmet(ctx: Context<'_>) -> Result<(), Error> { + use_item(ctx, ItemType::Helmet, Target::Myself).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn emp(ctx: Context<'_>) -> Result<(), Error> { + use_item(ctx, ItemType::EMP, Target::Everyone).await?; + Ok(()) +} + +#[poise::command(prefix_command, category = "Shop", guild_only)] +pub async fn land_mine( + ctx: Context<'_>, + #[description = "Channel to land mine"] channel: Channel, +) -> Result<(), Error> { + let used_item = use_item(ctx, ItemType::LandMine, Target::Other).await?; + + if used_item { + Listener::add_listener(&ctx.data().db, { + Listener::new( + TriggerType::OnMessage { + channel_id: Some(channel.id()), + content: None, + }, + vec![Action::Kill { hours: ctx.data().cfg.effect_role_duration as u16}, Action::Speak {msg: "*click* **BOOM**, sorry kid you've run out of time. That was a landmine. You're dead.".to_string()}], + 0.95, + Expiration::NumberOfTriggers { triggers: 1 }, + false + ) + })?; + + ctx.author() + .id + .direct_message( + ctx, + CreateMessage::default().content("Bomb has been planted"), + ) + .await?; } - let msg_content = item.to_lowercase(); - let item = match msg_content.parse::() { - Ok(i) => i, - Err(_) => { - ctx.reply("I don't know what the heck that is tbh.").await?; - return Ok(()); - } - }; + Ok(()) +} - let target_users: Vec = match item.target() { +pub async fn use_item(ctx: Context<'_>, item: ItemType, target: Target) -> Result { + let target_users: Vec = match target { Target::Myself => { vec![ctx.author().id] } - Target::User => { - let item_name = format!("{} ", item.name().to_ascii_lowercase()); - let split = msg_content.split(&item_name); - let target = parse_user_mention(split.last().unwrap()).unwrap(); - let target_member = match ctx.guild_id().unwrap().member(ctx, target).await { - Ok(member) => member, - Err(_) => { - return Err(Error::CommandError( - "I have no clue who that is tbh".to_string(), - )); - } - }; - - vec![target_member.user.id] + Target::User(user_id) => { + vec![user_id] } Target::Everyone => ctx.guild().unwrap().members.keys().copied().collect(), + Target::Other => vec![], }; let item_data = match User::try_use_item(&ctx.data().db, ctx.author().id, item, false) { @@ -143,7 +203,7 @@ pub async fn use_item( ctx.reply("Looks like you don't have enough of that item to use it") .await?; } - return Ok(()); + return Ok(false); } }; @@ -159,7 +219,7 @@ pub async fn use_item( } } - Ok(()) + Ok(true) } /// Sell an item for profit diff --git a/src/event_listener/mod.rs b/src/event_listener/mod.rs index 8b78a3f..49788b7 100644 --- a/src/event_listener/mod.rs +++ b/src/event_listener/mod.rs @@ -18,7 +18,7 @@ use std::sync::Arc; pub enum TriggerType { OnMessage { channel_id: Option, - content: String, + content: Option, }, UseItem { item: ItemType, @@ -43,10 +43,17 @@ impl TriggerType { } else { true }; - event_msg - .to_ascii_lowercase() - .contains(&trigger.to_ascii_lowercase()) - && match_channel + + let match_msg = if let Some(trigger) = trigger { + let phrase = trigger.to_ascii_lowercase(); + let event_msg = event_msg.as_deref().unwrap().to_ascii_lowercase(); + + event_msg.contains(&phrase) + } else { + true + }; + + match_channel && match_msg } (TriggerType::UseItem { item: trigger }, TriggerType::UseItem { item: event_item }) => { trigger == event_item @@ -113,8 +120,8 @@ pub enum Action { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum Expiration { - NumberOfTriggers(u16), - Time(chrono::DateTime), + NumberOfTriggers { triggers: u16 }, + Time { time: chrono::DateTime }, Never, } @@ -126,6 +133,7 @@ pub struct Listener { pub trigger_chance: f64, pub expiration: Expiration, pub system_listener: bool, + pub trigger_count: u64, } impl Listener { @@ -143,6 +151,7 @@ impl Listener { trigger_chance, expiration, system_listener, + trigger_count: 0, } } @@ -151,7 +160,7 @@ impl Listener { } pub async fn handle_action( - &self, + &mut self, ctx: &Context, data: &Arc, trigger_event: &TriggerEvent, @@ -233,12 +242,25 @@ impl Listener { } } + self.trigger_count += 1; + + let expire = match self.expiration { + Expiration::NumberOfTriggers { triggers } => self.trigger_count >= triggers as u64, + Expiration::Time { time } => time > Utc::now(), + Expiration::Never => false, + }; + + if expire { + data.db.remove::(self.id.unwrap())?; + } else { + data.db.insert(self.clone())?; + } Ok(()) } - pub fn add_event(db: &Database, new_event: Self) -> Result { - debug!("Adding listener: {new_event:?}"); - Ok(db.insert(new_event)?) + pub fn add_listener(db: &Database, new_listener: Self) -> Result { + debug!("Adding listener: {new_listener:?}"); + Ok(db.insert(new_listener)?) } pub async fn process_trigger( @@ -246,11 +268,18 @@ impl Listener { data: &Arc, trigger_event: TriggerEvent, ) -> Result<(), Error> { - let triggered_listeners = data.db.filter(|_, listener: &Listener| { + // Ignore the bot for triggers + if trigger_event.triggerer == ctx.cache.current_user().id { + return Ok(()); + } + + let _ = data.listener_lock.lock().await; + + let mut triggered_listeners = data.db.filter(|_, listener: &Listener| { listener.check_if_triggers(&trigger_event.trigger_type) })?; - for listener in triggered_listeners { + for mut listener in &mut triggered_listeners { match listener.handle_action(ctx, data, &trigger_event).await { Ok(_) => { debug!("Processed event: {listener:?}"); @@ -261,6 +290,8 @@ impl Listener { } } + // Ensure the DB is synced to prevent re-triggering of events + data.db.db.flush_async().await.unwrap(); Ok(()) } } @@ -275,6 +306,6 @@ impl j_db::model::JdbModel for Listener { } fn tree() -> String { - "Events".to_string() + "Listener".to_string() } } diff --git a/src/inventory/mod.rs b/src/inventory/mod.rs index 10febec..be3b488 100644 --- a/src/inventory/mod.rs +++ b/src/inventory/mod.rs @@ -1,3 +1,4 @@ +use poise::serenity_prelude::UserId; use poise::serenity_prelude::utils::MessageBuilder; use rand::prelude::IndexedRandom; use rand::rng; @@ -62,13 +63,16 @@ pub enum ItemType { Helmet, #[name = "EMP"] EMP, + #[name = "EMP"] + LandMine, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum Target { Myself, - User, + User(UserId), Everyone, + Other, } impl ItemType { @@ -85,6 +89,7 @@ impl ItemType { ItemType::CancelRay => "Used to cancel people. `!use cancel ray @Austin`".to_string(), ItemType::Helmet => "Automatically used to block being killed".to_string(), ItemType::EMP => "Disables weapons and defenses".to_string(), + ItemType::LandMine => "A land mine you can setup for someone to step on".to_string(), } } @@ -127,6 +132,9 @@ impl ItemType { ItemType::EMP => { Some("Humanity has forgotten a time before electricity. A time before all our weapons, toys, gadgets. We no longer have to try and remember that time. That time has returned".to_string()) } + ItemType::LandMine => { + None + } } } @@ -149,20 +157,6 @@ impl ItemType { _ => Vec::new(), } } - - pub fn target(&self) -> Target { - match self { - ItemType::CancelInsurance => Target::Myself, - ItemType::TheConceptOfLove => Target::Myself, - ItemType::GoodFortune => Target::Myself, - ItemType::Nft => Target::Myself, - ItemType::LicenseToBeHorny => Target::Myself, - ItemType::KillGun => Target::User, - ItemType::CancelRay => Target::User, - ItemType::Helmet => Target::Myself, - ItemType::EMP => Target::Everyone, - } - } } #[derive(Debug, Clone, Hash, Eq, PartialEq, Copy)] @@ -197,6 +191,8 @@ impl FromStr for ItemType { Ok(ItemType::CancelRay) } else if item.starts_with("emp") { Ok(ItemType::EMP) + } else if item.starts_with("landmine") { + Ok(ItemType::LandMine) } else { Err(InventoryError::UnkownItem) } @@ -215,6 +211,7 @@ impl Display for ItemType { ItemType::Helmet => "Helmet".to_string(), ItemType::CancelRay => "Cancel Ray".to_string(), ItemType::EMP => "EMP".to_string(), + ItemType::LandMine => "Land Mine".to_string(), }; write!(f, "{name}") @@ -250,6 +247,7 @@ impl InventorySlot { ItemType::Helmet => 10_000, ItemType::CancelRay => 2_000, ItemType::EMP => 50_000, + ItemType::LandMine => 3_000, } }