copper/src/commands/eco/farm.rs

701 lines
23 KiB
Rust

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<Pool<Postgres>>,
}
impl FarmCommand {
pub fn new(pool: Arc<Pool<Postgres>>) -> 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<chrono::DateTime<chrono::Utc>>>(
"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<String> = Vec::new();
let mut new_items: Vec<String> = 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<String> = 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<chrono::DateTime<chrono::Utc>>>(
"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::<Vec<_>>()
.join(", ");
client
.message(format!(
"Crop not found. Available crops: {}",
available_crops
))
.await;
}
}
}