first commit

This commit is contained in:
Soph :3 2025-09-09 10:15:15 +03:00
commit 0cb536b42b
38 changed files with 9044 additions and 0 deletions

View file

@ -0,0 +1,72 @@
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 std::sync::Arc;
use async_trait::async_trait;
use sqlx::Pool;
use sqlx::Postgres;
pub struct BalanceCommand {
pool: Arc<Pool<Postgres>>,
}
impl BalanceCommand {
pub fn new(pool: Arc<Pool<Postgres>>) -> Self {
Self { pool }
}
}
#[async_trait]
impl Command for BalanceCommand {
fn name(&self) -> &'static str {
"balance"
}
fn aliases(&self) -> &[&'static str] {
&["bal"]
}
fn category(&self) -> &'static str {
"eco"
}
fn description(&self) -> &'static str {
"View your balance."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "user_id",
arg_type: ArgumentType::String,
required: false,
default: None,
}]
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments) {
let id = match args.get("user_id") {
Some(ParsedArgument::String(s)) => s.as_str(),
_ => player._id.as_str(),
};
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1")
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.unwrap();
match user {
Some(user) => {
client
.message(format!("You have {} coins.", user.balance))
.await;
}
None => {
client.message("No user found.").await;
}
}
}
}

143
src/commands/eco/cf.rs Normal file
View file

@ -0,0 +1,143 @@
use crate::User;
use crate::client::{Client, ClientEvent, Player};
use crate::commands::Command;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArgument, ParsedArguments};
use async_trait::async_trait;
use rand::Rng;
use sqlx::Pool;
use sqlx::Postgres;
use std::sync::Arc;
use tokio::time::{Duration, sleep};
pub struct CoinflipCommand {
pool: Arc<Pool<Postgres>>,
}
impl CoinflipCommand {
pub fn new(pool: Arc<Pool<Postgres>>) -> Self {
Self { pool }
}
}
#[async_trait]
impl Command for CoinflipCommand {
fn name(&self) -> &'static str {
"coinflip"
}
fn aliases(&self) -> &[&'static str] {
&["cf"]
}
fn category(&self) -> &'static str {
"eco"
}
fn description(&self) -> &'static str {
"Flip a coin 50/50, optionally bet some coins."
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "bet_amount",
arg_type: ArgumentType::Integer,
required: false,
default: None,
}]
}
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments) {
let bet_amount: Option<i32> = match args.get("bet_amount") {
Some(ParsedArgument::Integer(i)) => Some(*i as i32),
_ => None,
};
let user_opt = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1")
.bind(&player._id)
.fetch_optional(self.pool.as_ref())
.await
.unwrap();
let mut user = match user_opt {
Some(u) => u,
None => {
client.message("No user found.").await;
return;
}
};
let choice = if rand::rng().random_bool(0.5) {
"Heads"
} else {
"Tails"
};
if let Some(amount) = bet_amount {
if user.balance < amount {
client
.message(format!(
"You don't have enough coins to bet {} coins. You have {}.",
amount, user.balance
))
.await;
return;
}
client
.message(format!(
"Coinflipping for {} coins.. I choose {}.",
amount, choice
))
.await;
sleep(Duration::from_millis(1750)).await;
let flip = if rand::rng().random_bool(0.5) {
"Heads"
} else {
"Tails"
};
if flip == choice {
let return_coins = (amount as f64 * 1.75).round() as i32;
user.balance += return_coins;
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 got {} coins.", flip, return_coins))
.await;
} else {
user.balance -= amount;
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 lost {} coins.", flip, amount))
.await;
}
} else {
client
.message(format!("Flipping a coin.. I choose {}.", choice))
.await;
sleep(Duration::from_millis(1750)).await;
let flip = if rand::rng().random_bool(0.5) {
"Heads"
} else {
"Tails"
};
let result = if flip == choice { "won" } else { "lost" };
client.message(format!("{}! I {}.", flip, result)).await;
}
}
}

704
src/commands/eco/farm.rs Normal file
View file

@ -0,0 +1,704 @@
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,
}]
}
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: Player, args: ParsedArguments) {
let mut user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1")
.bind(&player._id)
.fetch_optional(self.pool.as_ref())
.await
.unwrap()
.unwrap();
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;
}
}
}

416
src/commands/eco/fish.rs Normal file
View file

@ -0,0 +1,416 @@
use crate::User;
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::calculate_fish_value;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArgument, ParsedArguments};
use chrono::Duration as ChronoDuration;
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;
#[derive(Clone, Copy, Debug)]
pub enum FishRarity {
Common,
Uncommon,
Rare,
SuperRare,
}
pub struct FishType {
pub name: &'static str,
pub color_hex: &'static str,
pub price: u32,
pub rarity: FishRarity,
}
pub const FISH_TYPES: [FishType; 20] = [
FishType {
name: "Salmon",
color_hex: "#FA8072",
price: 50,
rarity: FishRarity::Common,
},
FishType {
name: "Trout",
color_hex: "#A2B5CD",
price: 40,
rarity: FishRarity::Common,
},
FishType {
name: "Bass",
color_hex: "#6B8E23",
price: 45,
rarity: FishRarity::Common,
},
FishType {
name: "Catfish",
color_hex: "#B0C4DE",
price: 60,
rarity: FishRarity::Uncommon,
},
FishType {
name: "Carp",
color_hex: "#C2B280",
price: 35,
rarity: FishRarity::Common,
},
FishType {
name: "Pike",
color_hex: "#556B2F",
price: 70,
rarity: FishRarity::Uncommon,
},
FishType {
name: "Perch",
color_hex: "#FFD700",
price: 30,
rarity: FishRarity::Common,
},
FishType {
name: "Sturgeon",
color_hex: "#708090",
price: 200,
rarity: FishRarity::Rare,
},
FishType {
name: "Bluegill",
color_hex: "#4682B4",
price: 25,
rarity: FishRarity::Common,
},
FishType {
name: "Crappie",
color_hex: "#D3D3D3",
price: 20,
rarity: FishRarity::Common,
},
FishType {
name: "Swordfish",
color_hex: "#191970",
price: 500,
rarity: FishRarity::SuperRare,
},
FishType {
name: "Marlin",
color_hex: "#4169E1",
price: 400,
rarity: FishRarity::Rare,
},
FishType {
name: "Tuna",
color_hex: "#4682B4",
price: 350,
rarity: FishRarity::Rare,
},
FishType {
name: "Mahi Mahi",
color_hex: "#00CED1",
price: 150,
rarity: FishRarity::Uncommon,
},
FishType {
name: "Halibut",
color_hex: "#F0E68C",
price: 120,
rarity: FishRarity::Uncommon,
},
FishType {
name: "Eel",
color_hex: "#2F4F4F",
price: 80,
rarity: FishRarity::Uncommon,
},
FishType {
name: "Opah",
color_hex: "#FF4500",
price: 600,
rarity: FishRarity::SuperRare,
},
FishType {
name: "Angelfish",
color_hex: "#FFB6C1",
price: 250,
rarity: FishRarity::Rare,
},
FishType {
name: "Clownfish",
color_hex: "#FFA500",
price: 180,
rarity: FishRarity::Uncommon,
},
FishType {
name: "Snapper",
color_hex: "#FF6347",
price: 90,
rarity: FishRarity::Uncommon,
},
];
pub struct FishCommand {
pool: Arc<Pool<Postgres>>,
}
impl FishCommand {
pub fn new(pool: Arc<Pool<Postgres>>) -> Self {
Self { pool }
}
}
#[async_trait]
impl Command for FishCommand {
fn name(&self) -> &'static str {
"fish"
}
fn category(&self) -> &'static str {
"eco"
}
fn description(&self) -> &'static str {
"Do some fishing. Try to catch one of many fish types! Rarities and prices included."
}
fn aliases(&self) -> &[&'static str] {
&[]
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "action",
arg_type: ArgumentType::String,
required: false,
default: None,
}]
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments) {
let mut user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1")
.bind(&player._id)
.fetch_optional(self.pool.as_ref())
.await
.unwrap()
.unwrap();
let action = match args.get("action") {
Some(ParsedArgument::String(s)) => s.as_str(),
_ => "",
};
if action == "sell" {
let mut total_value: i32 = 0;
let mut fish_count: i32 = 0;
let mut sold_fish: Vec<String> = Vec::new();
let mut new_items: Vec<String> = Vec::new();
for item in user.items.iter() {
if item.starts_with("fish-") {
let parts: Vec<&str> = item.split('-').collect();
if parts.len() >= 3 {
let fish_name = parts[1].replace('_', " ");
let weight_str = parts[2];
let weight: f64 = weight_str.parse().unwrap_or(0.0);
if let Some(fish_type) = FISH_TYPES
.iter()
.find(|f| f.name.to_lowercase() == fish_name.to_lowercase())
{
let value = calculate_fish_value(weight, fish_type.price);
total_value += value;
fish_count += 1;
sold_fish.push(format!(
"{} ({:.2} kg, {} coins)",
fish_type.name, weight, value
));
}
}
} else {
new_items.push(item.clone());
}
}
if fish_count == 0 {
client
.message("You have no fish 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 {} fish for a total of {} coins! {}",
fish_count,
total_value,
sold_fish.join(", ")
))
.await;
}
return;
}
let cooldown_query_opt = sqlx::query_scalar::<_, Option<chrono::DateTime<Utc>>>(
"SELECT untill FROM cooldowns WHERE _id = $1 AND type = $2",
)
.bind(&user._id)
.bind("fish")
.fetch_one(self.pool.as_ref())
.await;
let now = Utc::now();
let cooldown_exists = cooldown_query_opt.is_ok();
if cooldown_exists
&& let Some(until) = cooldown_query_opt.unwrap()
&& until > now
{
let secs_left = (until - now).num_seconds();
client
.message(format!(
"⏳ You are tired from fishing! Please wait {} seconds before fishing again.",
secs_left
))
.await;
return;
}
let mut rod_bonus = 0.0;
let mut bait_bonus = 0.0;
let mut bait_used = None;
fn count_item(items: &[String], id: &str) -> usize {
items.iter().filter(|i| i.starts_with(id)).count()
}
if user.items.iter().any(|i| i == "carbon_rod") {
rod_bonus = 0.15;
} else if user.items.iter().any(|i| i == "fiberglass_rod") {
rod_bonus = 0.07;
} else if user.items.iter().any(|i| i == "wooden_rod") {
rod_bonus = 0.0;
}
if count_item(&user.items, "large_bait") > 0 {
bait_bonus = 0.10;
bait_used = Some("large_bait");
} else if count_item(&user.items, "small_bait") > 0 {
bait_bonus = 0.04;
bait_used = Some("small_bait");
}
if let Some(bait_id) = bait_used
&& let Some(pos) = user.items.iter().position(|i| i.starts_with(bait_id))
{
user.items.remove(pos);
client
.message(format!("You used one {}.", bait_id.replace('_', " ")))
.await;
}
let common_fish = FISH_TYPES
.iter()
.filter(|f| matches!(f.rarity, FishRarity::Common))
.collect::<Vec<_>>();
let uncommon_fish = FISH_TYPES
.iter()
.filter(|f| matches!(f.rarity, FishRarity::Uncommon))
.collect::<Vec<_>>();
let rare_fish = FISH_TYPES
.iter()
.filter(|f| matches!(f.rarity, FishRarity::Rare))
.collect::<Vec<_>>();
let super_rare_fish = FISH_TYPES
.iter()
.filter(|f| matches!(f.rarity, FishRarity::SuperRare))
.collect::<Vec<_>>();
let mut common_chance = 60.0;
let mut uncommon_chance = 25.0;
let mut rare_chance = 13.0;
let mut super_rare_chance = 2.0;
let rare_bonus = rod_bonus + bait_bonus;
rare_chance += rare_bonus * 100.0 * 0.5;
super_rare_chance += rare_bonus * 100.0 * 0.5;
let total = common_chance + uncommon_chance + rare_chance + super_rare_chance;
common_chance *= 100.0 / total;
uncommon_chance *= 100.0 / total;
rare_chance *= 100.0 / total;
let roll = rand::rng().random_range(0.0..100.0);
let fish = if roll < common_chance {
common_fish.choose(&mut rand::rng()).unwrap()
} else if roll < common_chance + uncommon_chance {
uncommon_fish.choose(&mut rand::rng()).unwrap()
} else if roll < common_chance + uncommon_chance + rare_chance {
rare_fish.choose(&mut rand::rng()).unwrap()
} else {
super_rare_fish.choose(&mut rand::rng()).unwrap()
};
let timeout_secs = rand::rng().random_range(24..=48);
let cooldown_until = now + ChronoDuration::seconds(timeout_secs);
if cooldown_exists {
let _ = sqlx::query("UPDATE cooldowns SET untill = $1 WHERE _id = $2 AND type = $3")
.bind(cooldown_until)
.bind(&user._id)
.bind("fish")
.execute(self.pool.as_ref())
.await;
} else {
let _ = sqlx::query("INSERT INTO cooldowns (_id, type, untill) VALUES ($1, $2, $3)")
.bind(&user._id)
.bind("fish")
.bind(cooldown_until)
.execute(self.pool.as_ref())
.await;
}
let base_weight = rand::rng().random_range(0.1..=50.0);
let weight = base_weight * (1.0 + rod_bonus + bait_bonus);
let fish_id = format!(
"fish-{}-{:.2}",
fish.name.to_lowercase().replace(' ', "_"),
weight
);
user.items.push(fish_id.clone());
let _ = sqlx::query("UPDATE users SET items = $1 WHERE _id = $2")
.bind(&user.items)
.bind(&user._id)
.execute(self.pool.as_ref())
.await;
let value = calculate_fish_value(weight, fish.price);
client
.message(format!(
"🎣 You caught a {}! Color: {} Weight: {:.2} kg",
fish.name, fish.color_hex, weight
))
.await;
client
.message(format!("Rarity: {:?} Value: {} coins", fish.rarity, value))
.await;
let client_clone = client.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(timeout_secs as u64)).await;
client_clone
.message(format!("🎣 @{} can fish again now!", player._id))
.await;
});
}
}

153
src/commands/eco/inv.rs Normal file
View file

@ -0,0 +1,153 @@
use crate::User;
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::eco::fish::FISH_TYPES;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArgument, ParsedArguments};
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::Pool;
use sqlx::Postgres;
pub struct InventoryCommand {
pool: Arc<Pool<Postgres>>,
}
impl InventoryCommand {
pub fn new(pool: Arc<Pool<Postgres>>) -> Self {
Self { pool }
}
}
#[async_trait]
impl Command for InventoryCommand {
fn name(&self) -> &'static str {
"inventory"
}
fn category(&self) -> &'static str {
"eco"
}
fn aliases(&self) -> &[&'static str] {
&["inv"]
}
fn description(&self) -> &'static str {
"View your inventory."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "user_id",
arg_type: ArgumentType::String,
required: false,
default: None,
}]
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments) {
let id = match args.get("user_id") {
Some(ParsedArgument::String(s)) => s.as_str(),
_ => player._id.as_str(),
};
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1")
.bind(id)
.fetch_optional(self.pool.as_ref())
.await
.unwrap();
if let Some(user) = user {
if user.items.is_empty() {
client.message("Your inventory is empty.".to_string()).await;
return;
}
let mut fruits: Vec<(u8, String)> = Vec::new();
let mut fish: Vec<String> = Vec::new();
let mut others: HashMap<String, u32> = HashMap::new();
for item in user.items.iter() {
if item.starts_with("fruit-") {
let parts: Vec<&str> = item.split('-').collect();
if parts.len() >= 4 {
let mut fruit_name = parts[1].replace('_', " ");
let quality: u8 = parts[2].parse().unwrap_or(1);
let count: u32 = parts[3].parse().unwrap_or(1);
let stars = match quality {
1 => "",
2 => "⭐⭐",
3 => "⭐⭐⭐",
4 => "⭐⭐⭐⭐",
5 => "⭐⭐⭐⭐⭐",
_ => "",
};
fruits.push((
quality,
format!(
"{} {}{fruit_name} x{}",
stars,
fruit_name.remove(0).to_uppercase(),
count
),
));
}
} else if item.starts_with("fish-") {
let parts: Vec<&str> = item.split('-').collect();
if parts.len() >= 3 {
let fish_name = parts[1].replace('_', " ");
let weight: f64 = parts[2].parse().unwrap_or(0.0);
if let Some(fish_type) = FISH_TYPES
.iter()
.find(|f| f.name.eq_ignore_ascii_case(&fish_name))
{
fish.push(format!("{} ({:.2} kg)", fish_type.name, weight));
} else {
fish.push(format!("{} ({:.2} kg)", fish_name, weight));
}
}
} else {
let display_name = match item.as_str() {
"wooden_rod" => "Wooden Rod".to_string(),
"fiberglass_rod" => "Fiberglass Rod".to_string(),
"carbon_rod" => "Carbon Rod".to_string(),
"small_bait" => "Small Bait".to_string(),
"large_bait" => "Large Bait".to_string(),
"wooden_hoe" => "Wooden Hoe".to_string(),
"iron_hoe" => "Iron Hoe".to_string(),
"golden_hoe" => "Golden Hoe".to_string(),
"diamond_hoe" => "Diamond Hoe".to_string(),
_ => item.replace('_', " "),
};
*others.entry(display_name).or_insert(0) += 1;
}
}
fruits.sort_by(|a, b| b.0.cmp(&a.0));
let mut inventory_display = Vec::new();
inventory_display.extend(fruits.into_iter().map(|(_, s)| s));
inventory_display.extend(fish);
inventory_display.extend(others.into_iter().map(|(name, count)| {
if count > 1 {
format!("{} x{}", name, count)
} else {
name
}
}));
client
.message(format!("Inventory: {}", inventory_display.join(", ")))
.await;
} else {
client.message("No user found.".to_string()).await;
}
}
}

15
src/commands/eco/mod.rs Normal file
View file

@ -0,0 +1,15 @@
use crate::submods;
submods!(balance, fish, farm, shop, inv, cf);
pub fn calculate_fish_value(weight: f64, price: u32) -> i32 {
let min_weight = 0.1;
let max_weight = 50.0;
let min_multiplier = 1.0;
let max_multiplier = 2.5;
let normalized_weight = ((weight - min_weight) / (max_weight - min_weight)).clamp(0.0, 1.0);
let multiplier = min_multiplier + (max_multiplier - min_multiplier) * normalized_weight;
(price as f64 * multiplier).round() as i32
}

209
src/commands/eco/shop.rs Normal file
View file

@ -0,0 +1,209 @@
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 std::sync::Arc;
use async_trait::async_trait;
use sqlx::Pool;
use sqlx::Postgres;
pub struct ShopItem {
pub name: &'static str,
pub price: i32,
pub max: u8,
pub category: &'static str,
pub id: &'static str,
}
pub const SHOP_ITEMS: [ShopItem; 9] = [
ShopItem {
name: "Wooden Rod",
price: 100,
category: "Fishing Rod",
id: "wooden_rod",
max: 1,
},
ShopItem {
name: "Fiberglass Rod",
price: 500,
category: "Fishing Rod",
id: "fiberglass_rod",
max: 1,
},
ShopItem {
name: "Carbon Rod",
price: 2000,
category: "Fishing Rod",
id: "carbon_rod",
max: 1,
},
ShopItem {
name: "Small Bait",
price: 20,
category: "Bait",
id: "small_bait",
max: 100,
},
ShopItem {
name: "Large Bait",
price: 80,
category: "Bait",
id: "large_bait",
max: 100,
},
ShopItem {
name: "Wooden Hoe",
price: 80,
category: "Farming Tool",
id: "wooden_hoe",
max: 1,
},
ShopItem {
name: "Stone Hoe",
price: 200,
category: "Farming Tool",
id: "stone_hoe",
max: 1,
},
ShopItem {
name: "Iron Hoe",
price: 800,
category: "Farming Tool",
id: "iron_hoe",
max: 1,
},
ShopItem {
name: "Diamond Hoe",
price: 3000,
category: "Farming Tool",
id: "diamond_hoe",
max: 1,
},
];
pub struct ShopCommand {
pool: Arc<Pool<Postgres>>,
}
impl ShopCommand {
pub fn new(pool: Arc<Pool<Postgres>>) -> Self {
Self { pool }
}
}
#[async_trait]
impl Command for ShopCommand {
fn name(&self) -> &'static str {
"shop"
}
fn category(&self) -> &'static str {
"eco"
}
fn aliases(&self) -> &[&'static str] {
&[]
}
fn description(&self) -> &'static str {
"Do some shopping."
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[
ArgumentSpec {
name: "item_id",
arg_type: ArgumentType::String,
required: false,
default: None,
},
ArgumentSpec {
name: "quantity",
arg_type: ArgumentType::Integer,
required: false,
default: Some("1"),
},
]
}
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments) {
let mut user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE _id = $1")
.bind(&player._id)
.fetch_optional(self.pool.as_ref())
.await
.unwrap()
.unwrap();
let item_name = match args.get("item_id") {
Some(ParsedArgument::String(s)) => s.as_str(),
_ => "empty",
};
let quantity: u8 = match args.get("quantity") {
Some(ParsedArgument::Integer(i)) => *i as u8,
_ => 1,
};
let item = SHOP_ITEMS
.iter()
.find(|i| i.id.eq_ignore_ascii_case(item_name));
if let Some(item) = item {
let owned_count = user.items.iter().filter(|id| id == &item.id).count() as u8;
if owned_count >= item.max {
client
.message(&format!(
"You already own the maximum amount of {} ({}).",
item.name, item.max
))
.await;
return;
}
let quantity = quantity.min(item.max - owned_count);
let total_price = item.price * quantity as i32;
if user.balance >= total_price {
user.balance -= total_price;
for _ in 0..quantity {
user.items.push(item.id.to_string());
}
sqlx::query("UPDATE users SET balance = $1, items = $2 WHERE _id = $3")
.bind(user.balance as i64)
.bind(&user.items)
.bind(&user._id)
.execute(self.pool.as_ref())
.await
.unwrap();
client
.message(&format!(
"You purchased {}x {} ({}) for {} coins!",
quantity, item.name, item.category, total_price
))
.await;
} else {
client.message(&format!(
"You don't have enough coins to buy {}x {}. You need {} coins, but you have {}.",
quantity, item.name, total_price, user.balance
)).await;
}
} else {
let available_items = SHOP_ITEMS
.iter()
.map(|i| format!("{} ({})", i.name, i.id))
.collect::<Vec<_>>()
.join(", ");
client
.message(&format!(
"Item not found. Available items: {}",
available_items
))
.await;
}
}
}