698 lines
23 KiB
Rust
698 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
|
|
.message(format!(
|
|
"🌾 Hey, @{} your {} is ready! You got {} {} x{}",
|
|
user_id, crop_type.name, stars, fruit.name, total_count
|
|
))
|
|
.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;
|
|
}
|
|
}
|
|
}
|