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

188
src/commands/argument.rs Normal file
View file

@ -0,0 +1,188 @@
use std::{collections::HashMap, fmt};
#[derive(Debug, Clone)]
pub enum ArgumentType {
String,
Integer,
Float,
Boolean,
Enum(&'static [&'static str]),
GreedyString,
}
impl fmt::Display for ArgumentType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArgumentType::String => write!(f, "String"),
ArgumentType::Integer => write!(f, "Integer"),
ArgumentType::Enum(variants) => write!(f, "Enum({:?})", variants),
ArgumentType::Float => write!(f, "Float"),
ArgumentType::Boolean => write!(f, "Boolean"),
ArgumentType::GreedyString => write!(f, "GreedyString"),
}
}
}
#[derive(Debug, Clone)]
pub struct ArgumentSpec {
pub name: &'static str,
pub arg_type: ArgumentType,
pub required: bool,
pub default: Option<&'static str>,
}
#[derive(Debug, Clone)]
pub enum ParsedArgument {
String(String),
Integer(i64),
Float(f64),
Boolean(bool),
Enum(String),
GreedyString(String),
}
impl fmt::Display for ParsedArgument {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParsedArgument::String(s) => write!(f, "{}", s),
ParsedArgument::Integer(i) => write!(f, "{}", i),
ParsedArgument::Float(fl) => write!(f, "{}", fl),
ParsedArgument::Boolean(b) => write!(f, "{}", b),
ParsedArgument::Enum(e) => write!(f, "{}", e),
ParsedArgument::GreedyString(s) => write!(f, "{}", s),
}
}
}
pub fn parse_argument(arg: &str, spec: &ArgumentSpec) -> Result<ParsedArgument, String> {
match &spec.arg_type {
ArgumentType::String => Ok(ParsedArgument::String(arg.to_string())),
ArgumentType::Integer => arg
.parse::<i64>()
.map(ParsedArgument::Integer)
.map_err(|_| format!("Expected integer for '{}', got '{}'", spec.name, arg)),
ArgumentType::Float => arg
.parse::<f64>()
.map(ParsedArgument::Float)
.map_err(|_| format!("Expected float for '{}', got '{}'", spec.name, arg)),
ArgumentType::Boolean => match arg.to_lowercase().as_str() {
"true" | "yes" | "1" => Ok(ParsedArgument::Boolean(true)),
"false" | "no" | "0" => Ok(ParsedArgument::Boolean(false)),
_ => Err(format!(
"Expected boolean for '{}', got '{}'",
spec.name, arg
)),
},
ArgumentType::Enum(variants) => {
if variants.contains(&arg) {
Ok(ParsedArgument::Enum(arg.to_string()))
} else {
Err(format!(
"Argument '{}' must be one of {:?}, got '{}'",
spec.name, variants, arg
))
}
}
ArgumentType::GreedyString => Ok(ParsedArgument::GreedyString(arg.to_string())),
}
}
#[derive(Debug, Clone)]
pub struct ParsedArguments {
pub raw: Vec<String>,
pub parsed: HashMap<&'static str, ParsedArgument>,
}
impl ParsedArguments {
pub fn get(&self, name: &str) -> Option<&ParsedArgument> {
self.parsed.get(name)
}
}
pub fn parse_arguments(
specs: &[ArgumentSpec],
raw_args: &[String],
) -> Result<ParsedArguments, String> {
let mut parsed = HashMap::new();
let raw_vec = raw_args.to_vec();
let mut i = 0;
while i < specs.len() {
let spec = &specs[i];
let raw_value = match &spec.arg_type {
ArgumentType::GreedyString => {
if i >= raw_args.len() {
spec.default.map(|s| s.to_string())
} else {
Some(raw_args[i..].join(" "))
}
}
_ => raw_args
.get(i)
.cloned()
.or_else(|| spec.default.map(|s| s.to_string())),
};
match raw_value {
Some(ref value) => {
let parsed_arg = match &spec.arg_type {
ArgumentType::String => ParsedArgument::String(value.clone()),
ArgumentType::Integer => value
.parse::<i64>()
.map(ParsedArgument::Integer)
.map_err(|_| {
format!(
"Argument '{}' must be an integer, got '{}'",
spec.name, value
)
})?,
ArgumentType::Float => value
.parse::<f64>()
.map(ParsedArgument::Float)
.map_err(|_| {
format!("Argument '{}' must be a float, got '{}'", spec.name, value)
})?,
ArgumentType::Boolean => match value.to_lowercase().as_str() {
"true" | "yes" | "1" => ParsedArgument::Boolean(true),
"false" | "no" | "0" => ParsedArgument::Boolean(false),
_ => {
return Err(format!(
"Argument '{}' must be a boolean, got '{}'",
spec.name, value
));
}
},
ArgumentType::Enum(variants) => {
if variants.contains(&value.as_str()) {
ParsedArgument::Enum(value.clone())
} else {
return Err(format!(
"Argument '{}' must be one of {:?}, got '{}'",
spec.name, variants, value
));
}
}
ArgumentType::GreedyString => ParsedArgument::GreedyString(value.clone()),
};
parsed.insert(spec.name, parsed_arg);
if let ArgumentType::GreedyString = spec.arg_type {
break;
}
}
None if spec.required => {
return Err(format!("Missing required argument '{}'", spec.name));
}
None => {}
}
i += 1;
}
Ok(ParsedArguments {
raw: raw_vec,
parsed,
})
}

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;
}
}
}

190
src/commands/midi/mod.rs Normal file
View file

@ -0,0 +1,190 @@
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
};
use flume::{Receiver, Sender};
use tokio::{sync::Mutex, task::JoinHandle};
use crate::{
client::Client,
midi_helper::{MidiEvent, play_midi},
submods,
};
submods!(play, playlist, queue, skip, stop);
pub fn number_to_midi() -> HashMap<u8, &'static str> {
let mut map = HashMap::new();
map.insert(1, "a-1");
map.insert(2, "as-1");
map.insert(3, "b-1");
map.insert(4, "c0");
map.insert(5, "cs0");
map.insert(6, "d0");
map.insert(7, "ds0");
map.insert(8, "e0");
map.insert(9, "f0");
map.insert(10, "fs0");
map.insert(11, "g0");
map.insert(12, "gs0");
map.insert(13, "a0");
map.insert(14, "as0");
map.insert(15, "b0");
map.insert(16, "c1");
map.insert(17, "cs1");
map.insert(18, "d1");
map.insert(19, "ds1");
map.insert(20, "e1");
map.insert(21, "f1");
map.insert(22, "fs1");
map.insert(23, "g1");
map.insert(24, "gs1");
map.insert(25, "a1");
map.insert(26, "as1");
map.insert(27, "b1");
map.insert(28, "c2");
map.insert(29, "cs2");
map.insert(30, "d2");
map.insert(31, "ds2");
map.insert(32, "e2");
map.insert(33, "f2");
map.insert(34, "fs2");
map.insert(35, "g2");
map.insert(36, "gs2");
map.insert(37, "a2");
map.insert(38, "as2");
map.insert(39, "b2");
map.insert(40, "c3");
map.insert(41, "cs3");
map.insert(42, "d3");
map.insert(43, "ds3");
map.insert(44, "e3");
map.insert(45, "f3");
map.insert(46, "fs3");
map.insert(47, "g3");
map.insert(48, "gs3");
map.insert(49, "a3");
map.insert(50, "as3");
map.insert(51, "b3");
map.insert(52, "c4");
map.insert(53, "cs4");
map.insert(54, "d4");
map.insert(55, "ds4");
map.insert(56, "e4");
map.insert(57, "f4");
map.insert(58, "fs4");
map.insert(59, "g4");
map.insert(60, "gs4");
map.insert(61, "a4");
map.insert(62, "as4");
map.insert(63, "b4");
map.insert(64, "c5");
map.insert(65, "cs5");
map.insert(66, "d5");
map.insert(67, "ds5");
map.insert(68, "e5");
map.insert(69, "f5");
map.insert(70, "fs5");
map.insert(71, "g5");
map.insert(72, "gs5");
map.insert(73, "a5");
map.insert(74, "as5");
map.insert(75, "b5");
map.insert(76, "c6");
map.insert(77, "cs6");
map.insert(78, "d6");
map.insert(79, "ds6");
map.insert(80, "e6");
map.insert(81, "f6");
map.insert(82, "fs6");
map.insert(83, "g6");
map.insert(84, "gs6");
map.insert(85, "a6");
map.insert(86, "as6");
map.insert(87, "b6");
map.insert(88, "c7");
map
}
pub struct MidiState {
pub midi_tx: Sender<MidiEvent>,
pub midi_rx: Receiver<MidiEvent>,
pub midi_handle: Option<JoinHandle<()>>,
pub queue: Arc<Mutex<VecDeque<(String, String)>>>,
}
impl Default for MidiState {
fn default() -> Self {
Self::new()
}
}
impl MidiState {
pub fn new() -> Self {
let (midi_tx, midi_rx) = flume::unbounded::<MidiEvent>();
Self {
midi_tx,
midi_rx,
midi_handle: None,
queue: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
pub fn simple_hash(s: &str) -> u64 {
let mut hash: u64 = 5381;
for b in s.bytes() {
hash = ((hash << 5).wrapping_add(hash)).wrapping_add(b as u64);
}
hash
}
pub async fn play_midi_file(
filename_to_play: String,
filename_beautiful: String,
only_queue: bool,
midi_state: Arc<Mutex<MidiState>>,
client: Client,
) {
let midi_tx;
let queue;
{
let midi_state = midi_state.lock().await;
midi_tx = midi_state.midi_tx.clone();
queue = midi_state.queue.clone();
}
let handle_filename_to_play = filename_to_play.clone();
let handle_filename_beautiful = filename_beautiful.clone();
let handle_client = client.clone();
let midi_handle = tokio::spawn(async move {
let next_mtx = midi_tx.clone();
if !only_queue {
let _ = play_midi(handle_filename_to_play.as_str(), midi_tx).await;
handle_client
.message(format!("{} ended.", handle_filename_beautiful))
.await;
}
loop {
let mut locked_queue = queue.lock().await;
if locked_queue.is_empty() {
drop(locked_queue);
break;
}
let midi = locked_queue.pop_front().unwrap();
let queue_len = locked_queue.len();
drop(locked_queue);
handle_client
.message(format!("Queue left: {}, playing {}.", queue_len, midi.0))
.await;
let _ = play_midi(midi.1.as_str(), next_mtx.clone()).await;
}
});
let mut midi_state = midi_state.lock().await;
midi_state.midi_handle = Some(midi_handle);
}

263
src/commands/midi/play.rs Normal file
View file

@ -0,0 +1,263 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Note;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::MidiState;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArguments};
use crate::commands::number_to_midi;
use crate::commands::play_midi_file;
use crate::commands::simple_hash;
use crate::midi_helper::MidiEvent;
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct PlayCommand {
midi_state: Arc<Mutex<MidiState>>,
}
impl PlayCommand {
pub fn new(midi_state: Arc<Mutex<MidiState>>) -> Self {
Self { midi_state }
}
}
#[async_trait]
impl Command for PlayCommand {
fn name(&self) -> &'static str {
"play"
}
fn aliases(&self) -> &[&'static str] {
&["p"]
}
fn category(&self) -> &'static str {
"midi"
}
fn description(&self) -> &'static str {
"plays a midi file from a local path, URL, or files.sad.ovh"
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "file",
arg_type: ArgumentType::String,
required: true,
default: None,
}]
}
async fn constructed(&mut self, client: Client) {
let ntm = number_to_midi();
let midi_rx = {
let midi_state = self.midi_state.lock().await;
midi_state.midi_rx.clone()
};
tokio::spawn(async move {
while let Ok(event) = midi_rx.recv_async().await {
match event {
MidiEvent::NoteOn { key, velocity } => {
client
.add_note_buffer(Note {
n: ntm.get(&key).unwrap_or(&"").to_string(),
v: Some(velocity as f64 / 127.0),
d: None,
s: None,
})
.await;
}
MidiEvent::NoteOff { key } => {
client
.add_note_buffer(Note {
n: ntm.get(&key).unwrap_or(&"").to_string(),
v: None,
d: None,
s: Some(1),
})
.await;
}
MidiEvent::Info {
num_tracks,
time_div: _,
events_count,
note_count,
total_ticks: _,
minutes,
seconds,
millis,
parse_time,
} => {
client
.message(format!(
"Tracks: `{}` Events: `{}` Total Duration: `{:02}:{:02}.{:03}` Note Count: `{}` Parse time: `{:.2?}`",
num_tracks, events_count, minutes, seconds, millis, note_count, parse_time
))
.await;
}
}
}
});
}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, args: ParsedArguments) {
let file_arg = match args.get("file") {
Some(crate::commands::argument::ParsedArgument::String(s)) => s,
_ => "",
};
let joined_args = file_arg.to_string();
let mut filename_to_play: String = "".to_string();
let mut filename_beautiful: String = "".to_string();
if joined_args.starts_with("https://") {
let hashed = simple_hash(joined_args.as_str());
filename_to_play = format!("midis/{}.mid", hashed);
filename_beautiful = joined_args.clone();
let file_exists = tokio::fs::try_exists(&filename_to_play)
.await
.unwrap_or(false);
if !file_exists {
match reqwest::get(&joined_args).await {
Ok(resp) => {
if resp.status().is_success() {
match resp.bytes().await {
Ok(bytes) => {
match tokio::fs::write(&filename_to_play, &bytes).await {
Ok(_) => {
client
.message(format!(
"Downloaded midi from {}, into: {}",
joined_args, filename_to_play
))
.await;
}
Err(e) => {
client
.message(format!("Failed to write file: {}", e))
.await;
return;
}
}
}
Err(e) => {
client
.message(format!("Failed to read response bytes: {}", e))
.await;
return;
}
}
} else {
client
.message(format!("Failed to download file: HTTP {}", resp.status()))
.await;
return;
}
}
Err(e) => {
client
.message(format!("Failed to download file: {}", e))
.await;
return;
}
}
}
} else if joined_args.starts_with("files:") {
if let Some(caps) = joined_args.strip_prefix("files:") {
let first_capture = caps.trim();
if first_capture.ends_with('/') {
client
.message("Use the 'playlist' command to play a directory as a playlist.")
.await;
return;
} else {
let hashed = simple_hash(first_capture);
filename_to_play = format!("midis/{}.mid", hashed);
filename_beautiful = first_capture.to_string();
let url = format!("https://files.sad.ovh/public/midis/{}", first_capture);
let file_exists = tokio::fs::try_exists(&filename_to_play)
.await
.unwrap_or(false);
if !file_exists {
match reqwest::get(&url).await {
Ok(resp) => {
if resp.status().is_success() {
match resp.bytes().await {
Ok(bytes) => {
match tokio::fs::write(&filename_to_play, &bytes).await
{
Ok(_) => {
client
.message(format!(
"Downloaded {} from files.sad.ovh.",
filename_beautiful
))
.await;
}
Err(e) => {
client
.message(format!(
"Failed to write file: {}",
e
))
.await;
return;
}
}
}
Err(e) => {
client
.message(format!(
"Failed to read response bytes: {}",
e
))
.await;
return;
}
}
} else {
client
.message(format!(
"Failed to download file: HTTP {}",
resp.status()
))
.await;
return;
}
}
Err(e) => {
client
.message(format!("Failed to download file: {}", e))
.await;
return;
}
}
}
}
}
} else {
filename_to_play = joined_args.clone();
filename_beautiful = joined_args.clone();
}
play_midi_file(
filename_to_play,
filename_beautiful.clone(),
false,
self.midi_state.clone(),
client.clone(),
)
.await;
client
.message(format!("Started playing {}.", filename_beautiful))
.await;
}
}

View file

@ -0,0 +1,362 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::MidiState;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArgument, ParsedArguments};
use crate::commands::simple_hash;
use crate::play_midi_file;
use rand::seq::SliceRandom;
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct PlaylistCommand {
midi_state: Arc<Mutex<MidiState>>,
}
impl PlaylistCommand {
pub fn new(midi_state: Arc<Mutex<MidiState>>) -> Self {
Self { midi_state }
}
}
#[async_trait]
impl Command for PlaylistCommand {
fn name(&self) -> &'static str {
"playlist"
}
fn category(&self) -> &'static str {
"midi"
}
fn aliases(&self) -> &[&'static str] {
&[]
}
fn description(&self) -> &'static str {
"Loads and plays a playlist of midis."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "playlist",
arg_type: ArgumentType::String,
required: true,
default: None,
}]
}
async fn constructed(&mut self, _client: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, args: ParsedArguments) {
let joined_args = match args.get("playlist") {
Some(ParsedArgument::String(s)) => s.as_str(),
_ => "",
};
let mut filename_to_play: String = "".to_string();
let mut filename_beautiful: String = "".to_string();
if joined_args == "playlist_1" {
const TRACKS: &[&str] = &[
"A/autumn_leaves2-G85.mid",
"B/blue_bossa-kenny-dorham_dz.mid",
"J/Jobim_Desafinado.mid",
"J/Jobim_Wave.mid",
"J/Jobim_corcovado.mid",
"I/ipanema.mid",
"J/Jobim_Meditacao2.mid",
"S/so_what.mid",
"T/take_five2-Gb176-davebrubeck.mid",
"M/My_Funny_Valentine.mid",
"A/all_the_things_you_are-2_dm.mid",
"J/Johnny_Mathis_Misty.mid",
"H/HOWARD.Fly me to the moon.mid",
"R/Ray Charles - Georgia On My Mind.mid",
"B/blue_in_green.mid",
"S/Stella-By-Starlight-1.mid",
"F/FITZGERALD.Summertime K.mid",
"M/Moon River.mid",
"A/autumn_in_new_york2-Bb84.mid",
"J/Jobim_One_Note_Samba.mid",
];
for track in TRACKS {
let hashed = simple_hash(track);
let filename = format!("midis/{}.mid", hashed);
let url = format!("https://files.sad.ovh/public/midis/{}", track);
let file_exists = tokio::fs::try_exists(&filename).await.unwrap_or(false);
if !file_exists {
match reqwest::get(&url).await {
Ok(resp) => {
if resp.status().is_success() {
match resp.bytes().await {
Ok(bytes) => match tokio::fs::write(&filename, &bytes).await {
Ok(_) => {
client
.message(format!(
"Downloaded midi from files.sad.ovh: {}",
track
))
.await;
}
Err(e) => {
client
.message(format!("Failed to write file: {}", e))
.await;
}
},
Err(e) => {
client
.message(format!(
"Failed to read response bytes: {}",
e
))
.await;
}
}
} else {
client
.message(format!(
"Failed to download file: HTTP {} for {}",
resp.status(),
track
))
.await;
}
}
Err(e) => {
client
.message(format!("Failed to download file: {} for {}", e, track))
.await;
}
}
}
}
let mut tracks: Vec<&str> = TRACKS.to_vec();
tracks.shuffle(&mut rand::rng());
if let Some(first) = tracks.first() {
let hashed = simple_hash(first);
filename_to_play = format!("midis/{}.mid", hashed);
filename_beautiful = first.to_string();
let midi_state = self.midi_state.clone();
let locked_state = midi_state.lock().await;
let mut locked_queue = locked_state.queue.lock().await;
for track in tracks.iter().skip(1) {
let hashed = simple_hash(track);
let filename = format!("midis/{}.mid", hashed);
locked_queue.push_back((track.to_string(), filename));
}
drop(locked_queue);
drop(locked_state);
client
.message(format!(
"Playlist loaded. Playing '{}', queued {} more.",
filename_beautiful,
tracks.len() - 1
))
.await;
} else {
client.message("No tracks found in playlist.").await;
return;
}
} else if joined_args.starts_with("files:")
&& let Some(caps) = joined_args.strip_prefix("files:")
{
let first_capture = caps.trim();
if first_capture.ends_with('/') {
let url = format!("https://files.sad.ovh/public/midis/{}?ls", first_capture);
match reqwest::get(&url).await {
Ok(resp) => {
if resp.status().is_success() {
match resp.text().await {
Ok(text) => {
let parsed: serde_json::Value =
match serde_json::from_str(&text) {
Ok(val) => val,
Err(e) => {
client
.message(format!(
"Failed to parse directory listing JSON: {}",
e
))
.await;
return;
}
};
let files = parsed.get("files").and_then(|f| f.as_array());
if let Some(files) = files {
if files.is_empty() {
client.message("No files found in directory.").await;
return;
}
let mut file_entries: Vec<(String, String)> = Vec::new();
for file in files {
let href = file.get("href").and_then(|h| h.as_str());
if let Some(href) = href {
let file_path =
format!("{}{}", first_capture, href);
let hashed = simple_hash(&file_path);
let filename = format!("midis/{}.mid", hashed);
let url_file = format!(
"https://files.sad.ovh/public/midis/{}",
file_path
);
let file_exists = tokio::fs::try_exists(&filename)
.await
.unwrap_or(false);
if !file_exists {
match reqwest::get(&url_file).await {
Ok(resp_file) => {
if resp_file.status().is_success() {
match resp_file.bytes().await {
Ok(bytes) => {
match tokio::fs::write(
&filename, &bytes,
)
.await
{
Ok(_) => {
client
.message(format!(
"Downloaded {} from files.sad.ovh.",
file_path
))
.await;
}
Err(e) => {
client
.message(format!("Failed to write file: {}", e))
.await;
return;
}
}
}
Err(e) => {
client
.message(format!(
"Failed to read response bytes: {}",
e
))
.await;
return;
}
}
} else {
client
.message(format!(
"Failed to download file: HTTP {}",
resp_file.status()
))
.await;
return;
}
}
Err(e) => {
client
.message(format!(
"Failed to download file: {}",
e
))
.await;
return;
}
}
}
file_entries.push((file_path.clone(), filename));
}
}
if file_entries.is_empty() {
client
.message("No valid files found in directory.")
.await;
return;
}
filename_beautiful = file_entries[0].0.clone();
filename_to_play = file_entries[0].1.clone();
let midi_state = self.midi_state.clone();
let locked_state = midi_state.lock().await;
let queued_count = file_entries.len() - 1;
{
let mut locked_queue = locked_state.queue.lock().await;
for entry in file_entries.iter().skip(1) {
locked_queue
.push_back((entry.0.clone(), entry.1.clone()));
}
}
client
.message(format!(
"Directory loaded. Playing '{}', queued {} more.",
filename_beautiful, queued_count
))
.await;
} else {
client
.message("No files found in directory listing.")
.await;
return;
}
}
Err(e) => {
client
.message(format!(
"Failed to read directory listing response: {}",
e
))
.await;
return;
}
}
} else {
client
.message(format!(
"Failed to get directory listing: HTTP {}",
resp.status()
))
.await;
return;
}
}
Err(e) => {
client
.message(format!("Failed to get directory listing: {}", e))
.await;
return;
}
}
} else {
client
.message("Use the 'play' command to play a single file.")
.await;
return;
}
}
play_midi_file(
filename_to_play,
filename_beautiful.clone(),
false,
self.midi_state.clone(),
client.clone(),
)
.await;
client
.message(format!("Started playing {}.", filename_beautiful))
.await;
}
}

View file

@ -0,0 +1,62 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::MidiState;
use crate::commands::argument::{ArgumentSpec, ParsedArguments};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct QueueCommand {
midi_state: Arc<Mutex<MidiState>>,
}
impl QueueCommand {
pub fn new(midi_state: Arc<Mutex<MidiState>>) -> Self {
Self { midi_state }
}
}
#[async_trait]
impl Command for QueueCommand {
fn name(&self) -> &'static str {
"queue"
}
fn category(&self) -> &'static str {
"midi"
}
fn aliases(&self) -> &[&'static str] {
&["q"]
}
fn description(&self) -> &'static str {
"Shows the current midi queue."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[]
}
async fn constructed(&mut self, _client: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, _args: ParsedArguments) {
let midi_state = self.midi_state.lock().await;
let locked_queue = midi_state.queue.lock().await;
let queue_len = locked_queue.len();
if queue_len == 0 {
client.message("Queue is empty.").await;
} else {
let midis: Vec<String> = locked_queue.iter().cloned().map(|z| z.0).collect();
let midis_list = midis.join(", ");
client
.message(format!(
"Queue length: {}. Queued midis: {}",
queue_len, midis_list
))
.await;
}
drop(locked_queue);
}
}

66
src/commands/midi/skip.rs Normal file
View file

@ -0,0 +1,66 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::MidiState;
use crate::commands::argument::{ArgumentSpec, ParsedArguments};
use crate::commands::play_midi_file;
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct SkipCommand {
midi_state: Arc<Mutex<MidiState>>,
}
impl SkipCommand {
pub fn new(midi_state: Arc<Mutex<MidiState>>) -> Self {
Self { midi_state }
}
}
#[async_trait]
impl Command for SkipCommand {
fn name(&self) -> &'static str {
"skip"
}
fn category(&self) -> &'static str {
"midi"
}
fn aliases(&self) -> &[&'static str] {
&["s"]
}
fn description(&self) -> &'static str {
"Skips the current midi and plays the next in the queue."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[]
}
async fn constructed(&mut self, _client: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, _args: ParsedArguments) {
let mut midi_state = self.midi_state.lock().await;
if let Some(handle) = midi_state.midi_handle.as_ref()
&& !handle.is_finished()
{
handle.abort();
midi_state.midi_handle = None;
client.message("Skipped current midi.").await;
drop(midi_state);
play_midi_file(
"".to_string(),
"".to_string(),
true,
self.midi_state.clone(),
client,
)
.await;
return;
}
client.message("Nothing is playing to skip.").await;
}
}

57
src/commands/midi/stop.rs Normal file
View file

@ -0,0 +1,57 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::{ArgumentSpec, Command, MidiState, ParsedArguments};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct StopCommand {
midi_state: Arc<Mutex<MidiState>>,
}
impl StopCommand {
pub fn new(midi_state: Arc<Mutex<MidiState>>) -> Self {
Self { midi_state }
}
}
#[async_trait]
impl Command for StopCommand {
fn name(&self) -> &'static str {
"stop"
}
fn category(&self) -> &'static str {
"midi"
}
fn aliases(&self) -> &[&'static str] {
&["x"]
}
fn description(&self) -> &'static str {
"Stops the current midi playback and clears the queue."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[]
}
async fn constructed(&mut self, _client: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, _args: ParsedArguments) {
let mut midi_state = self.midi_state.lock().await;
if let Some(handle) = midi_state.midi_handle.as_ref()
&& !handle.is_finished()
{
handle.abort();
midi_state.midi_handle = None;
let mut locked_queue = midi_state.queue.lock().await;
locked_queue.clear();
drop(locked_queue);
client.message("Stopped playing (forcefully).").await;
return;
}
client.message("Nothing is playing.").await;
}
}

87
src/commands/mod.rs Normal file
View file

@ -0,0 +1,87 @@
use crate::{
client::{Client, ClientEvent, Player},
commands::argument::{ArgumentSpec, ParsedArguments},
};
use async_trait::async_trait;
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
#[macro_export]
macro_rules! submods {
($($name:ident),*) => {
$(
pub mod $name;
pub use $name::*;
)*
};
}
submods!(eco, midi, system);
pub mod argument;
pub struct Arguments {
pub args: Vec<String>,
}
impl Arguments {
pub fn new(args: Vec<String>) -> Self {
Self { args }
}
pub fn get(&self, index: usize) -> Option<&str> {
self.args.get(index).map(|s| s.as_str())
}
}
#[async_trait]
pub trait Command: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &'static str;
fn category(&self) -> &'static str;
fn aliases(&self) -> &[&'static str];
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[]
}
async fn event(&mut self, client: Client, event: ClientEvent);
async fn constructed(&mut self, client: Client);
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments);
}
pub type CommandArc = Arc<Mutex<dyn Command + Send + Sync>>;
pub type CommandValues<'a> = std::collections::hash_map::Values<'a, String, CommandArc>;
#[derive(Clone)]
pub struct CommandRegistry {
commands: HashMap<String, CommandArc>,
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new()
}
}
impl CommandRegistry {
pub fn new() -> Self {
Self {
commands: HashMap::new(),
}
}
pub fn values(&self) -> CommandValues<'_> {
self.commands.values()
}
pub async fn register<C: Command + 'static>(&mut self, mut cmd: C, c: Client) {
cmd.constructed(c).await; // await async setup
self.commands.insert(
cmd.name().to_string(),
Arc::new(Mutex::new(cmd)) as Arc<Mutex<dyn Command + Send + Sync>>,
);
}
pub fn get(&self, name: &str) -> Option<Arc<Mutex<dyn Command + Send + Sync>>> {
self.commands.get(name).cloned()
}
}

View file

@ -0,0 +1,60 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::argument::ArgumentType;
use crate::commands::argument::ParsedArgument;
use crate::commands::argument::{ArgumentSpec, ParsedArguments};
use async_trait::async_trait;
pub struct AboutCommand;
#[async_trait]
impl Command for AboutCommand {
fn name(&self) -> &'static str {
"about"
}
fn category(&self) -> &'static str {
"system"
}
fn aliases(&self) -> &[&'static str] {
&[]
}
fn description(&self) -> &'static str {
"View information about this bot."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "section",
arg_type: ArgumentType::Enum(&["midi", "arguments"]),
required: false,
default: None,
}]
}
async fn constructed(&mut self, _client: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, args: ParsedArguments) {
let section = match args.get("section") {
Some(ParsedArgument::Enum(s)) => s.as_str(),
_ => "",
};
match section {
"" => {
client.message("Copper is a MPP bot written in Rust. It supports very high NPS midis, and a very exhaustive economy system. View `about midi` and `about arguments` for more information. - Sophie, created 08/23/2025, 1:10 AM").await;
}
"midi" => {
client.message("The midi player in this bot is written by Austin (Nitsua). Currently closed source, it's memory usage is 1:1 of the midis size, only in loading the midi. The midi is required to load fully to get the size of it, but after that it uses basically no ram. I've played many midis while using less than 1MB of ram.").await;
}
"arguments" => {
client.message("The argument system here is very complicated. All commands that have arguments have a ArgumentSpec list, which I can add arguments into. These have `required`, defaults and argument types. There are 5 argument types. Floats, integers, enums, strings and greedyStrings.").await;
}
_ => {}
};
}
}

View file

@ -0,0 +1,162 @@
use std::f64::consts::PI;
use std::sync::Arc;
use std::time::Duration;
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 async_trait::async_trait;
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::sleep;
pub struct FollowCommand {
orbit_center: Arc<Mutex<(f64, f64)>>,
animation: Arc<Mutex<String>>,
player: String,
handle: Option<JoinHandle<()>>,
}
impl Default for FollowCommand {
fn default() -> Self {
Self::new()
}
}
impl FollowCommand {
pub fn new() -> Self {
Self {
orbit_center: Arc::new(Mutex::new((50.0, 50.0))),
animation: Arc::new(Mutex::new("default".to_string())),
player: "".to_string(),
handle: None,
}
}
}
#[async_trait]
impl Command for FollowCommand {
fn name(&self) -> &'static str {
"follow"
}
fn aliases(&self) -> &[&'static str] {
&["f"]
}
fn category(&self) -> &'static str {
"system"
}
fn description(&self) -> &'static str {
"Follow a person."
}
async fn event(&mut self, _: Client, event: ClientEvent) {
if let ClientEvent::Mouse { x, y, id } = event
&& id == self.player
{
let mut lock = self.orbit_center.lock().await;
*lock = (x, y);
}
}
async fn constructed(&mut self, client: Client) {
let cloned_orbit_center = self.orbit_center.clone();
let cloned_animation = self.animation.clone();
self.handle = Some(tokio::spawn(async move {
// non_snake_case is set here because this is code ported from js
// and to make the whole process easier, i keep botLength in non-snake-case
#[allow(non_snake_case)]
let botLength = 5.0;
let speed = 1.0;
let mut theta: f64 = 0.0;
let mut last = tokio::time::Instant::now();
loop {
let now = tokio::time::Instant::now();
let dt = now.duration_since(last).as_secs_f64();
last = now;
theta += speed * dt;
let mut x = 0.0;
let mut y = 0.0;
let i = 5.0;
let (cx, cy) = { *cloned_orbit_center.lock().await };
let animation = { cloned_animation.lock().await.clone() };
if animation == "default" {
let t = ((PI * 2.0) / botLength) * i - theta;
//let t1 = ((PI * 3.0) / botLength) * i + theta;
let x1 = cx + (f64::cos(2.0 * t) * botLength) / 2.0;
let y1 = cy + (f64::sin(2.0 * t) * botLength) / 2.0;
x = x1 + (f64::cos(3.0 * t) * botLength) / 2.0;
y = y1 + (f64::sin(3.0 * t) * botLength) / 2.0;
}
let _ = client.move_to(x, y).await;
sleep(Duration::from_millis(50)).await; // ~60 fps
}
}));
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[
ArgumentSpec {
name: "target",
arg_type: ArgumentType::String,
required: false,
default: None,
},
ArgumentSpec {
name: "animation",
arg_type: ArgumentType::Enum(&["default"]),
required: false,
default: None,
},
]
}
async fn execute(&mut self, client: Client, player: Player, args: ParsedArguments) {
let target = match args.get("target") {
Some(ParsedArgument::String(s)) => s.as_str(),
_ => player._id.as_str(),
};
let animation = match args.get("animation") {
Some(ParsedArgument::Enum(s)) => s.clone(),
_ => "default".to_string(),
};
if target == "off" {
if self.handle.is_none() {
client.message("Follow handle is already gone.").await;
return;
}
client
.message(
"Dismantled the follow handle. Rerun `follow` to enable following once again.",
)
.await;
self.handle.as_mut().unwrap().abort();
self.handle = None;
return;
}
{
let mut lock = self.animation.lock().await;
*lock = animation;
}
self.player = target.to_string();
if self.handle.is_none() {
self.constructed(client.clone()).await;
}
client
.message(format!("Now following {}.", self.player))
.await;
}
}

111
src/commands/system/help.rs Normal file
View file

@ -0,0 +1,111 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::CommandRegistry;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArguments};
use async_trait::async_trait;
use std::collections::BTreeMap;
pub struct HelpCommand {
registry: CommandRegistry,
}
impl HelpCommand {
pub fn new(registry: CommandRegistry) -> Self {
Self { registry }
}
}
#[async_trait]
impl Command for HelpCommand {
fn name(&self) -> &'static str {
"help"
}
fn aliases(&self) -> &[&'static str] {
&["?"]
}
fn category(&self) -> &'static str {
"system"
}
fn description(&self) -> &'static str {
"View all commands."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "command",
arg_type: ArgumentType::String,
required: false,
default: None,
}]
}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn constructed(&mut self, _: Client) {}
async fn execute(&mut self, client: Client, _: Player, args: ParsedArguments) {
let cmd_name = match args.get("command") {
Some(crate::commands::argument::ParsedArgument::String(s)) if !s.is_empty() => s,
_ => "",
};
if !cmd_name.is_empty()
&& let Some(cmd_mutex) = self.registry.commands.get(cmd_name)
{
let cmd = cmd_mutex.lock().await;
let message = {
let mut arg_string: Vec<String> = Vec::new();
for arg in cmd.argument_spec() {
let argument_brackets = {
if arg.required {
("(", "*)")
} else {
("(", ")")
}
};
/*let default_text = {
if arg.default.is_some() {
format!("= {}", arg.default.unwrap())
} else {
"".to_string()
}
};*/
arg_string.push(format!(
"{}{}{}",
argument_brackets.0, arg.name, argument_brackets.1
));
}
format!(
"`{} {}` - {} *Required",
cmd_name,
arg_string.join(" ").trim(),
cmd.description()
)
};
client.message(message).await;
return;
}
let mut categories: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (name, cmd_mutex) in self.registry.commands.iter() {
let cmd = cmd_mutex.lock().await;
categories
.entry(cmd.category().to_string())
.or_default()
.push(name.clone());
}
for (category, cmds) in categories.iter() {
let cmds_list = cmds
.iter()
.map(|c| format!("`{}`", c))
.collect::<Vec<_>>()
.join(", ");
client.message(format!("{}: {}", category, cmds_list)).await;
}
}
}

View file

@ -0,0 +1,53 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::argument::{ArgumentSpec, ArgumentType, ParsedArguments};
use async_trait::async_trait;
pub struct LaunchCommand;
#[async_trait]
impl Command for LaunchCommand {
fn name(&self) -> &'static str {
"launch"
}
fn aliases(&self) -> &[&'static str] {
&["goto"]
}
fn category(&self) -> &'static str {
"system"
}
fn description(&self) -> &'static str {
"Launches the bot to somewhere else."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[ArgumentSpec {
name: "channel",
arg_type: ArgumentType::String,
required: true,
default: None,
}]
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, mut client: Client, player: Player, args: ParsedArguments) {
if player._id != "3bff3f33e6dc0410fdc61d13" {
client
.message("You are not `3bff3f33e6dc0410fdc61d13`.")
.await;
}
let channel = match args.get("channel") {
Some(crate::commands::argument::ParsedArgument::String(s)) => s,
_ => "",
};
client.message(format!("Going to `{}`.", channel)).await;
client.channel = channel.to_string();
client
.send_command(serde_json::json!({"m": "ch", "_id": client.channel}))
.await;
}
}

View file

@ -0,0 +1,3 @@
use crate::submods;
submods!(follow, help, launch, test, translate, about);

View file

@ -0,0 +1,45 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::argument::{ArgumentSpec, ParsedArguments};
use async_trait::async_trait;
pub struct TestCommand {}
impl Default for TestCommand {
fn default() -> Self {
Self::new()
}
}
impl TestCommand {
pub fn new() -> Self {
Self {}
}
}
#[async_trait]
impl Command for TestCommand {
fn name(&self) -> &'static str {
"test"
}
fn category(&self) -> &'static str {
"system"
}
fn description(&self) -> &'static str {
"Ping."
}
fn aliases(&self) -> &[&'static str] {
&[]
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[]
}
async fn constructed(&mut self, _: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, _: ParsedArguments) {
client.message("Ping.").await;
}
}

View file

@ -0,0 +1,158 @@
use crate::client::Client;
use crate::client::ClientEvent;
use crate::client::Player;
use crate::commands::Command;
use crate::commands::argument::ArgumentType;
use crate::commands::argument::ParsedArgument;
use crate::commands::argument::{ArgumentSpec, ParsedArguments};
// Libretranslate implementation.
use async_trait::async_trait;
use reqwest::Client as ReqwestClient;
use serde::Deserialize;
use serde::Serialize;
#[derive(Serialize)]
struct TranslateRequest<'a> {
q: &'a str,
source: &'a str,
target: &'a str,
format: &'a str,
}
#[derive(Deserialize)]
struct TranslateResponse {
#[serde(rename = "translatedText")]
translated_text: String,
}
pub struct LibreTranslate {
client: ReqwestClient,
url: String,
}
impl LibreTranslate {
pub fn new(base_url: &str) -> Self {
LibreTranslate {
client: ReqwestClient::new(),
url: base_url.to_string(),
}
}
pub async fn translate(
&self,
text: &str,
source_lang: &str,
target_lang: &str,
) -> Result<String, Box<String>> {
let request_body = TranslateRequest {
q: text,
source: source_lang,
target: target_lang,
format: "text",
};
let response = self
.client
.post(format!("{}/translate", self.url))
.header("Content-Type", "application/json")
.body(serde_json::to_string(&request_body).unwrap_or("".to_string()))
.send()
.await;
if let Ok(ok_response) = response {
let result: TranslateResponse =
serde_json::from_str(ok_response.text().await.unwrap().as_str()).unwrap();
Ok(result.translated_text)
} else {
Err(format!(
"API request failed with status: {}",
response.err().unwrap().status().unwrap()
)
.into())
}
}
}
// Libretranslate implementation end.
pub struct TranslateCommand;
#[async_trait]
impl Command for TranslateCommand {
fn name(&self) -> &'static str {
"translate"
}
fn category(&self) -> &'static str {
"system"
}
fn aliases(&self) -> &[&'static str] {
&["trans"]
}
fn description(&self) -> &'static str {
"Translate from a language to a language."
}
fn argument_spec(&self) -> &'static [ArgumentSpec] {
&[
ArgumentSpec {
name: "to_language",
arg_type: ArgumentType::Enum(&["ru", "lv", "en", "ko"]),
required: true,
default: None,
},
ArgumentSpec {
name: "from_language",
arg_type: ArgumentType::Enum(&["ru", "lv", "en", "ko", "auto"]),
required: true,
default: None,
},
ArgumentSpec {
name: "text",
arg_type: ArgumentType::GreedyString,
required: true,
default: None,
},
]
}
async fn constructed(&mut self, _client: Client) {}
async fn event(&mut self, _: Client, _: ClientEvent) {}
async fn execute(&mut self, client: Client, _: Player, args: ParsedArguments) {
let to_language = match args.get("to_language") {
Some(ParsedArgument::Enum(s)) => s.as_str(),
_ => "",
};
let from_language = match args.get("from_language") {
Some(ParsedArgument::Enum(s)) => s.as_str(),
_ => "",
};
let text = match args.get("text") {
Some(ParsedArgument::GreedyString(s)) => s.as_str(),
_ => "",
};
client.message("Okay, translating your message.").await;
let text = text.to_string();
let to_language = to_language.to_string();
let from_language = from_language.to_string();
let tokio_client = client.clone();
tokio::spawn(async move {
let translate = LibreTranslate::new("https://lt.sad.ovh");
let translated = translate
.translate(&text, &from_language, &to_language)
.await;
tokio_client
.message(format!(
"`{}` in `{}` is: `{}`",
text,
to_language,
translated.unwrap()
))
.await;
});
}
}