use crate::User; use crate::client::Client; use crate::client::ClientEvent; use crate::client::Player; use crate::commands::Command; use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArgument, ParsedArguments}; use chrono::Utc; use std::sync::Arc; use async_trait::async_trait; use rand::Rng; use rand::prelude::IndexedRandom; use sqlx::Pool; use sqlx::Postgres; use sqlx::Row; #[derive(Clone, Copy, Debug)] pub enum FruitQuality { OneStar = 1, TwoStar = 2, ThreeStar = 3, FourStar = 4, FiveStar = 5, } impl FruitQuality { pub fn stars(&self) -> u8 { *self as u8 } pub fn name(&self) -> &'static str { match self { FruitQuality::OneStar => "⭐", FruitQuality::TwoStar => "⭐⭐", FruitQuality::ThreeStar => "⭐⭐⭐", FruitQuality::FourStar => "⭐⭐⭐⭐", FruitQuality::FiveStar => "⭐⭐⭐⭐⭐", } } } #[derive(Debug)] pub struct FruitType { pub name: &'static str, pub color_hex: &'static str, pub price: u32, } pub struct CropType { pub name: &'static str, pub color_hex: &'static str, pub price: u32, pub grow_time_minutes: u32, pub fruits: &'static [FruitType], pub base_fruit_count: u32, } pub const FRUIT_TYPES: [FruitType; 10] = [ FruitType { name: "Beetroot", color_hex: "#8B0000", price: 15, }, FruitType { name: "Carrot", color_hex: "#FF8C00", price: 12, }, FruitType { name: "Potato", color_hex: "#D2691E", price: 8, }, FruitType { name: "Tomato", color_hex: "#FF6347", price: 20, }, FruitType { name: "Lettuce", color_hex: "#90EE90", price: 18, }, FruitType { name: "Cucumber", color_hex: "#32CD32", price: 16, }, FruitType { name: "Apple", color_hex: "#FF0000", price: 25, }, FruitType { name: "Pear", color_hex: "#FFFF00", price: 22, }, FruitType { name: "Grape", color_hex: "#8B008B", price: 30, }, FruitType { name: "Wheat", color_hex: "#F5DEB3", price: 10, }, ]; static ROOT_FRUITS: [FruitType; 3] = [ FruitType { name: "Beetroot", color_hex: "#8B0000", price: 15, }, FruitType { name: "Carrot", color_hex: "#FF8C00", price: 12, }, FruitType { name: "Potato", color_hex: "#D2691E", price: 8, }, ]; static GARDEN_FRUITS: [FruitType; 3] = [ FruitType { name: "Tomato", color_hex: "#FF6347", price: 20, }, FruitType { name: "Lettuce", color_hex: "#90EE90", price: 18, }, FruitType { name: "Cucumber", color_hex: "#32CD32", price: 16, }, ]; static TREE_FRUITS: [FruitType; 3] = [ FruitType { name: "Apple", color_hex: "#FF0000", price: 25, }, FruitType { name: "Pear", color_hex: "#FFFF00", price: 22, }, FruitType { name: "Grape", color_hex: "#8B008B", price: 30, }, ]; static GRAIN_FRUITS: [FruitType; 1] = [FruitType { name: "Wheat", color_hex: "#F5DEB3", price: 10, }]; pub const CROP_TYPES: [CropType; 4] = [ CropType { name: "Root Vegetables", color_hex: "#8B4513", price: 50, grow_time_minutes: 8, fruits: &ROOT_FRUITS, base_fruit_count: 3, }, CropType { name: "Garden Vegetables", color_hex: "#228B22", price: 75, grow_time_minutes: 12, fruits: &GARDEN_FRUITS, base_fruit_count: 2, }, CropType { name: "Fruit Trees", color_hex: "#FF69B4", price: 150, grow_time_minutes: 15, fruits: &TREE_FRUITS, base_fruit_count: 4, }, CropType { name: "Grain Crops", color_hex: "#DAA520", price: 30, grow_time_minutes: 5, fruits: &GRAIN_FRUITS, base_fruit_count: 8, }, ]; pub struct FarmCommand { pool: Arc>, } impl FarmCommand { pub fn new(pool: Arc>) -> Self { Self { pool } } fn get_hoe_bonus(&self, items: &[String]) -> (f64, f64) { if items.iter().any(|i| i == "diamond_hoe") { (0.25, 0.30) } else if items.iter().any(|i| i == "iron_hoe") { (0.15, 0.20) } else if items.iter().any(|i| i == "stone_hoe") { (0.08, 0.10) } else if items.iter().any(|i| i == "wooden_hoe") { (0.03, 0.05) } else { (0.0, 0.0) } } fn calculate_fruit_quality(&self, quality_bonus: f64) -> FruitQuality { let base_roll = rand::rng().random_range(0.0..100.0); let adjusted_roll = base_roll + (quality_bonus * 100.0); if adjusted_roll >= 95.0 { FruitQuality::FiveStar } else if adjusted_roll >= 80.0 { FruitQuality::FourStar } else if adjusted_roll >= 60.0 { FruitQuality::ThreeStar } else if adjusted_roll >= 35.0 { FruitQuality::TwoStar } else { FruitQuality::OneStar } } pub fn fruit_value(fruit_type: &FruitType, quality: u8, count: u32) -> i32 { let base_value = fruit_type.price * count; let quality_multiplier = quality as f32 * 0.5 + 0.5; (base_value as f32 * quality_multiplier) as i32 } } #[async_trait] impl Command for FarmCommand { fn name(&self) -> &'static str { "farm" } fn category(&self) -> &'static str { "eco" } fn aliases(&self) -> &[&'static str] { &[] } fn description(&self) -> &'static str { "Plant crops and harvest fruits! Use 'let farm sell' to sell your fruits, or 'let farm harvest' to collect ready crops." } fn argument_spec(&self) -> &'static [ArgumentSpec] { &[ArgumentSpec { name: "action", arg_type: ArgumentType::Enum(&["sell", "all", "harvest"]), required: false, default: None, children: &[], }] } async fn constructed(&mut self, client: Client) { let notification_client = client.clone(); let notification_pool = self.pool.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60)); loop { interval.tick().await; use chrono::Utc; let ready_crops = sqlx::query("SELECT DISTINCT _id, type FROM cooldowns WHERE type LIKE 'crop-%' AND untill <= $1") .bind(Utc::now()) .fetch_all(notification_pool.as_ref()) .await; if let Ok(crops) = ready_crops { for crop in crops { let user_id: String = crop.get("_id"); let crop_type_name: String = crop.get("type"); if let Some(crop_name) = crop_type_name.strip_prefix("crop-") && let Ok(Some(mut user)) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1") .bind(&user_id) .fetch_optional(notification_pool.as_ref()) .await && let Some(crop_type) = CROP_TYPES .iter() .find(|c| c.name.to_lowercase().replace(' ', "_") == crop_name) { let (quality_bonus, count_bonus) = { if user.items.iter().any(|i| i == "diamond_hoe") { (0.25, 0.30) } else if user.items.iter().any(|i| i == "iron_hoe") { (0.15, 0.20) } else if user.items.iter().any(|i| i == "stone_hoe") { (0.08, 0.10) } else if user.items.iter().any(|i| i == "wooden_hoe") { (0.03, 0.05) } else { (0.0, 0.0) } }; let base_roll = rand::rng().random_range(0.0..100.0); let adjusted_roll = base_roll + (quality_bonus * 100.0); let quality = if adjusted_roll >= 95.0 { 5 } else if adjusted_roll >= 80.0 { 4 } else if adjusted_roll >= 60.0 { 3 } else if adjusted_roll >= 35.0 { 2 } else { 1 }; let stars = match quality { 1 => "⭐", 2 => "⭐⭐", 3 => "⭐⭐⭐", 4 => "⭐⭐⭐⭐", 5 => "⭐⭐⭐⭐⭐", _ => "⭐", }; let base_count = crop_type.base_fruit_count; let bonus_count = (base_count as f64 * count_bonus) as u32; let total_count = base_count + bonus_count + rand::rng().random_range(1..=5); let fruit = crop_type.fruits.choose(&mut rand::rng()).unwrap(); let fruit_id = format!( "fruit-{}-{}-{}", fruit.name.to_lowercase().replace(' ', "_"), quality, total_count ); user.items.push(fruit_id); let _ = sqlx::query("UPDATE users SET items = $1 WHERE _id = $2") .bind(&user.items) .bind(&user._id) .execute(notification_pool.as_ref()) .await; let _ = sqlx::query("DELETE FROM cooldowns WHERE _id = $1 AND type = $2") .bind(&user_id) .bind(&crop_type_name) .execute(notification_pool.as_ref()) .await; notification_client .dm( format!( "🌾 Your {} is ready! You got {} {} x{}", crop_type.name, stars, fruit.name, total_count ), user_id, ) .await; } } } } }); } async fn event(&mut self, _: Client, _: ClientEvent) {} async fn execute(&mut self, client: Client, _: Player, args: ParsedArguments, mut user: User) { let command = match args.get("action") { Some(ParsedArgument::Enum(s)) => s.as_str(), _ => "", }; if command.eq_ignore_ascii_case("all") { let mut bought_crops = Vec::new(); let mut total_cost = 0; for crop in &CROP_TYPES { if user.balance < crop.price as i32 { continue; } use chrono::Utc; let crop_id = format!("crop-{}", crop.name.to_lowercase().replace(' ', "_")); let existing_crop = sqlx::query_scalar::<_, Option>>( "SELECT untill FROM cooldowns WHERE _id = $1 AND type = $2", ) .bind(&user._id) .bind(&crop_id) .fetch_one(self.pool.as_ref()) .await .unwrap_or(None); if existing_crop.is_some() { continue; } user.balance -= crop.price as i32; total_cost += crop.price; use chrono::Duration as ChronoDuration; let ready_time = Utc::now() + ChronoDuration::minutes(crop.grow_time_minutes as i64); let _ = sqlx::query("INSERT INTO cooldowns (_id, type, untill) VALUES ($1, $2, $3)") .bind(&user._id) .bind(&crop_id) .bind(ready_time) .execute(self.pool.as_ref()) .await; bought_crops.push(format!( "{} ({} coins, {} min)", crop.name, crop.price, crop.grow_time_minutes )); } if bought_crops.is_empty() { client .message( "You couldn't buy any crops: not enough coins or crops already growing." .to_string(), ) .await; return; } let _ = sqlx::query("UPDATE users SET balance = $1 WHERE _id = $2") .bind(user.balance) .bind(&user._id) .execute(self.pool.as_ref()) .await; client .message(format!( "✅ You bought {} crops for a total of {} coins: {}", bought_crops.len(), total_cost, bought_crops.join(", ") )) .await; return; } if command == "sell" { let mut total_value: i32 = 0; let mut fruit_count: i32 = 0; let mut sold_fruits: Vec = Vec::new(); let mut new_items: Vec = Vec::new(); for item in user.items.iter() { if item.starts_with("fruit-") { let parts: Vec<&str> = item.split('-').collect(); if parts.len() >= 4 { let fruit_name = parts[1].replace('_', " "); let quality_str = parts[2]; let count_str = parts[3]; let quality: u8 = quality_str.parse().unwrap_or(1); let count: u32 = count_str.parse().unwrap_or(1); if let Some(fruit_type) = FRUIT_TYPES .iter() .find(|f| f.name.to_lowercase() == fruit_name.to_lowercase()) { let value = FarmCommand::fruit_value(fruit_type, quality, count); total_value += value; fruit_count += count as i32; let stars = match quality { 1 => "⭐", 2 => "⭐⭐", 3 => "⭐⭐⭐", 4 => "⭐⭐⭐⭐", 5 => "⭐⭐⭐⭐⭐", _ => "⭐", }; sold_fruits.push(format!( "{} {} x{} ({} coins)", stars, fruit_type.name, count, value )); } } } else { new_items.push(item.clone()); } } if fruit_count == 0 { client .message("You have no fruits to sell.".to_string()) .await; } else { user.items = new_items.into(); user.balance += total_value; let _ = sqlx::query("UPDATE users SET items = $1, balance = $2 WHERE _id = $3") .bind(&user.items) .bind(user.balance) .bind(&user._id) .execute(self.pool.as_ref()) .await; client .message(format!( "You sold {} fruits for a total of {} coins. {}", fruit_count, total_value, sold_fruits.join(", ") )) .await; } return; } if command == "harvest" { let ready_crops = sqlx::query_scalar::<_, String>( "SELECT type FROM cooldowns WHERE _id = $1 AND type LIKE 'crop-%' AND untill <= $2", ) .bind(&user._id) .bind(Utc::now()) .fetch_all(self.pool.as_ref()) .await .unwrap_or_default(); if ready_crops.is_empty() { client .message("You have no crops ready for harvest.".to_string()) .await; return; } let (quality_bonus, count_bonus) = self.get_hoe_bonus(&user.items); let mut harvested_items: Vec = Vec::new(); for crop_id in ready_crops { if let Some(crop_name) = crop_id.strip_prefix("crop-") && let Some(crop_type) = CROP_TYPES .iter() .find(|c| c.name.to_lowercase().replace(' ', "_") == crop_name) { let quality = self.calculate_fruit_quality(quality_bonus); let base_count = crop_type.base_fruit_count; let bonus_count = (base_count as f64 * count_bonus) as u32; let total_count = base_count + bonus_count + rand::rng().random_range(0..=2); let fruit = crop_type.fruits.choose(&mut rand::rng()).unwrap(); let fruit_id = format!( "fruit-{}-{}-{}", fruit.name.to_lowercase().replace(' ', "_"), quality.stars(), total_count ); user.items.push(fruit_id.clone()); harvested_items.push(format!( "{} {} x{}", quality.name(), fruit.name, total_count )); let _ = sqlx::query("DELETE FROM cooldowns WHERE _id = $1 AND type = $2") .bind(&user._id) .bind(&crop_id) .execute(self.pool.as_ref()) .await; } } let _ = sqlx::query("UPDATE users SET items = $1 WHERE _id = $2") .bind(&user.items) .bind(&user._id) .execute(self.pool.as_ref()) .await; client .message(format!( "Harvest complete! You got: {}", harvested_items.join(", ") )) .await; return; } let crop_name = command; let crop = CROP_TYPES .iter() .find(|c| c.name.eq_ignore_ascii_case(crop_name)); if let Some(crop) = crop { if user.balance < crop.price as i32 { client .message(format!( "You don't have enough coins to plant {}. You need {} coins, but you have {}.", crop.name, crop.price, user.balance )) .await; return; } let crop_id = format!("crop-{}", crop.name.to_lowercase().replace(' ', "_")); let existing_crop = sqlx::query_scalar::<_, Option>>( "SELECT untill FROM cooldowns WHERE _id = $1 AND type = $2", ) .bind(&user._id) .bind(&crop_id) .fetch_one(self.pool.as_ref()) .await; if let Ok(Some(until)) = existing_crop { let now = Utc::now(); if until > now { let minutes_left = (until - now).num_minutes(); client .message(format!( "You already have {} growing! It will be ready in {} minutes.", crop.name, minutes_left )) .await; return; } else { client .message(format!( "{} has fully grown, Use `let farm harvest` to harvest it.", crop.name )) .await; return; } } user.balance -= crop.price as i32; use chrono::Duration as ChronoDuration; let ready_time = Utc::now() + ChronoDuration::minutes(crop.grow_time_minutes as i64); let _ = sqlx::query("INSERT INTO cooldowns (_id, type, untill) VALUES ($1, $2, $3)") .bind(&user._id) .bind(&crop_id) .bind(ready_time) .execute(self.pool.as_ref()) .await; let _ = sqlx::query("UPDATE users SET balance = $1 WHERE _id = $2") .bind(user.balance) .bind(&user._id) .execute(self.pool.as_ref()) .await; client .message(format!( "You planted {} for {} coins! It will be ready in {} minutes. Use 'let farm harvest' to collect when ready.", crop.name, crop.price, crop.grow_time_minutes )) .await; } else { let available_crops = CROP_TYPES .iter() .map(|c| { format!( "{} ({} coins, {} min)", c.name, c.price, c.grow_time_minutes ) }) .collect::>() .join(", "); client .message(format!( "Crop not found. Available crops: {}", available_crops )) .await; } } }