From 1db3e8b79ac339492f39174ab53e847cb621fbfe Mon Sep 17 00:00:00 2001 From: minneelyyyy Date: Tue, 10 Dec 2024 02:34:02 -0500 Subject: [PATCH] add items --- Cargo.toml | 2 +- src/commands/gambling/mod.rs | 97 ++++++++++++++++++++++++++- src/commands/gambling/shop.rs | 81 ++++++++++++++++++++++ src/commands/gambling/wager.rs | 48 ++++++++++++-- src/commands/mod.rs | 4 +- src/inventory.rs | 118 +++++++++++++++++++++++++++++++++ src/main.rs | 23 +++++++ 7 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 src/commands/gambling/shop.rs create mode 100644 src/inventory.rs diff --git a/Cargo.toml b/Cargo.toml index fad5b8f..dbfd274 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,5 +14,5 @@ regex = "1.11.1" sqlx = { version = "0.8", features = [ "runtime-tokio-native-tls", "postgres" ] } hex_color = "3" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0.133" +serde_json = { version = "1.0", features = ["raw_value"] } once_cell = "1.20.2" \ No newline at end of file diff --git a/src/commands/gambling/mod.rs b/src/commands/gambling/mod.rs index 1129b44..4418afd 100644 --- a/src/commands/gambling/mod.rs +++ b/src/commands/gambling/mod.rs @@ -4,10 +4,79 @@ pub mod give; pub mod wager; pub mod daily; pub mod leaderboard; +pub mod shop; -use crate::common::Error; -use poise::serenity_prelude::UserId; +use crate::{inventory::{self, Inventory}, common::{Context, Error}}; +use poise::serenity_prelude::{self as serenity, futures::StreamExt, UserId}; use sqlx::{Row, PgExecutor}; +use std::collections::HashMap; + +#[derive(Clone)] +pub enum Effect { + Multiplier(f64), + Chance(f64), +} + +#[derive(Clone)] +pub struct Item { + pub name: &'static str, + pub desc: &'static str, + pub effects: &'static [Effect], + pub id: u64, +} + +impl Item { + pub fn inv_item(self) -> inventory::Item { + inventory::Item { + id: 0, + name: self.name.to_string(), + game: ID as i64, + item: self.id as i64, + data: serde_json::json!({}), + } + } +} + +const ID: u64 = 440; + +mod items { + use super::{Item, Effect}; + + pub const DIRT: Item = Item { + name: "Pile of Dirt", + desc: "Returns a 1.01x multiplier on all earnings", + effects: &[Effect::Multiplier(1.01)], + id: id::DIRT, + }; + + pub const SAND: Item = Item { + name: "Pile of Sand", + desc: "Increase your odds of winning by 1%", + effects: &[Effect::Chance(0.51)], + id: id::SAND, + }; + + mod id { + pub const DIRT: u64 = 1; + pub const SAND: u64 = 2; + } + + pub fn get_item_by_id(id: u64) -> Option<&'static Item> { + match id { + id::DIRT => Some(&DIRT), + id::SAND => Some(&SAND), + _ => None + } + } + + pub fn get_item_by_name(name: &str) -> Option<&'static Item> { + match name { + "Pile of Dirt" => Some(&DIRT), + "Pile of Sand" => Some(&SAND), + _ => None + } + } +} pub async fn get_balance<'a, E>(id: UserId, db: E) -> Result where @@ -37,3 +106,27 @@ where Ok(()) } + +async fn autocomplete_inventory<'a>( + ctx: Context<'a>, + partial: &'a str, +) -> impl Iterator + use<'a> { + let db = &ctx.data().database; + + let inventory = Inventory::new(ctx.author().id, Some(ID)) + .items(db).await + .fold(HashMap::::new(), |mut acc, item| async { + let item = item.unwrap(); + let x = acc.get(&item.item); + acc.insert(item.item, x.map(|x| x + 1).unwrap_or(1)); + acc + }).await; + + inventory.into_iter() + .map(|(id, count)| (items::get_item_by_id(id as u64).unwrap(), count)) + .filter(move |(item, _)| item.name.contains(partial)) + .map(|(item, count)| serenity::AutocompleteChoice::new( + format!("{} - {} ({count}x)", item.desc, item.name), + item.name + )) +} diff --git a/src/commands/gambling/shop.rs b/src/commands/gambling/shop.rs new file mode 100644 index 0000000..f519053 --- /dev/null +++ b/src/commands/gambling/shop.rs @@ -0,0 +1,81 @@ +use crate::common::{Context, Error}; +use crate::inventory::Inventory; +use super::Item; +use once_cell::sync::Lazy; +use poise::serenity_prelude as serenity; +use std::collections::HashMap; + +static ITEMS: Lazy> = Lazy::new(|| { + HashMap::from([ + ("Pile of Dirt", (10, &super::items::DIRT)), + ("Pile of Sand", (10, &super::items::SAND)), + ]) +}); + +async fn autocomplete_shop<'a>( + ctx: Context<'_>, + partial: &'a str, +) -> impl Iterator + use<'a> { + let db = &ctx.data().database; + let balance = super::get_balance(ctx.author().id, db).await; + + ITEMS.values() + .filter(move |(_, item)| item.name.contains(partial)) + .map(move |(cost, item)| { + let balance = balance.as_ref().unwrap_or(cost); + + serenity::AutocompleteChoice::new( + if cost > balance { + format!("{} ({cost} tokens) - {} - Can't Afford", item.name, item.desc) + } else { + format!("{} ({cost} tokens) - {}", item.name, item.desc) + }, + item.name + ) + }) +} + +#[poise::command(slash_command, prefix_command)] +pub async fn buy(ctx: Context<'_>, + #[autocomplete = "autocomplete_shop"] + item: String, + #[min = 1] + count: Option) -> Result<(), Error> +{ + let count = count.unwrap_or(1); + + if count < 1 { + ctx.reply("Ok, did you REALLY expect me to fall for that for a third time? You've gotta find a new trick.").await?; + return Ok(()); + } + + let mut tx = ctx.data().database.begin().await?; + + if let Some((price, &ref item)) = ITEMS.get(item.as_str()) { + let author = ctx.author(); + let balance = super::get_balance(author.id, &mut *tx).await?; + + let total = *price * count; + + if total > balance { + ctx.reply(format!("You could not afford the items ({count}x **{}** cost(s) **{total}** tokens)", item.name)).await?; + return Ok(()) + } + + let inventory = Inventory::new(author.id, Some(super::ID)); + + for _ in 0..count { + inventory.give_item(&mut *tx, item.clone().inv_item()).await?; + } + + let balance = super::get_balance(author.id, &mut *tx).await?; + super::change_balance(author.id, balance - total, &mut *tx).await?; + + ctx.reply(format!("You have purchased {count}x {}.", item.name)).await?; + tx.commit().await?; + } else { + ctx.reply(format!("The item {item} is not available in this shop.")).await?; + } + + Ok(()) +} diff --git a/src/commands/gambling/wager.rs b/src/commands/gambling/wager.rs index 8548916..999345b 100644 --- a/src/commands/gambling/wager.rs +++ b/src/commands/gambling/wager.rs @@ -1,10 +1,14 @@ -use crate::common::{Context, Error}; +use crate::{common::{Context, Error}, inventory::Inventory}; +use super::Effect; +use rand::Rng; /// Put forward an amount of tokens to either lose or earn #[poise::command(slash_command, prefix_command)] pub async fn wager( ctx: Context<'_>, - amount: i32) -> Result<(), Error> + amount: i32, + #[autocomplete = "super::autocomplete_inventory"] + item: Option) -> Result<(), Error> { // #[min = 1] does not appear to work with prefix commands if amount < 1 { @@ -16,16 +20,50 @@ pub async fn wager( let mut wealth = super::get_balance(ctx.author().id, &mut *tx).await?; + let item = if let Some(item) = item { + let inventory = Inventory::new(ctx.author().id, Some(super::ID)); + + match super::items::get_item_by_name(&item) { + Some(item) => { + if let Some(item) = inventory.get_item_of_type(&mut *tx, item.id).await? { + inventory.remove_item(&mut *tx, item.id).await?; + } else { + ctx.reply(format!("You do not have a(n) {} to use.", item.name)).await?; + return Ok(()); + } + + Some(item) + } + None => { + ctx.reply("item {item} does not exist.").await?; + return Ok(()); + } + } + } else { + None + }; + if wealth < amount { ctx.reply(format!("You do not have enough tokens (**{}**) to wager this amount.", wealth)).await?; return Ok(()); } - if rand::random() { - wealth += amount; + let multiplier = item.clone().map(|item| item.effects.iter().fold(1.0, |acc, effect| match effect { + Effect::Multiplier(c) => *c, + _ => acc, + })).unwrap_or(1.0); + + let chance = item.map(|item| item.effects.iter().fold(0.5, |acc, effect| match effect { + Effect::Chance(c) => *c, + _ => acc, + })).unwrap_or(0.5); + + if rand::thread_rng().gen_bool(chance) { + let win = (amount as f64 * multiplier) as i32; + wealth += win; ctx.reply(format!("You just gained **{}** token(s)! You now have **{}**.", - amount, wealth)).await?; + win, wealth)).await?; } else { wealth -= amount; ctx.reply(format!("You've lost **{}** token(s), you now have **{}**.", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a1b5349..dad0e9b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,3 @@ -use crate::{Data, Error}; use poise::Command; mod ping; @@ -8,6 +7,8 @@ mod gambling; mod eval; mod self_roles; +use crate::common::{Data, Error}; + pub fn commands() -> Vec> { vec![ ping::ping(), @@ -18,6 +19,7 @@ pub fn commands() -> Vec> { gambling::wager::wager(), gambling::daily::daily(), gambling::leaderboard::leaderboard(), + gambling::shop::buy(), eval::eval(), self_roles::role(), ] diff --git a/src/inventory.rs b/src/inventory.rs new file mode 100644 index 0000000..9ce7b9d --- /dev/null +++ b/src/inventory.rs @@ -0,0 +1,118 @@ + +use crate::common::Error; + +use poise::serenity_prelude::{futures::Stream, UserId}; +use sqlx::PgExecutor; + +#[derive(Clone, sqlx::FromRow, Debug, PartialEq, Eq)] +pub struct Item { + pub id: i64, + pub name: String, + pub game: i64, + pub item: i64, + pub data: sqlx::types::JsonValue, +} + +pub struct Inventory { + user: UserId, + game: Option, +} + +impl Inventory { + pub fn new(user: UserId, game: Option) -> Self { + Self { + user, + game, + } + } + + pub async fn give_item<'a, E>(&self, db: E, item: Item) -> Result<(), Error> + where + E: PgExecutor<'a>, + { + sqlx::query( + r#" + INSERT INTO items (owner, game, item, data, name) + VALUES ($1, $2, $3, $4, $5) + "# + ) + .bind(self.user.get() as i64) + .bind(self.game.unwrap() as i64) + .bind(item.item) + .bind(item.data) + .bind(item.name) + .execute(db).await?; + + Ok(()) + } + + pub async fn get_item_of_type<'a, E>(&self, db: E, item: u64) -> Result, Error> + where + E: PgExecutor<'a>, + { + let x = sqlx::query_as( + r#" + SELECT id, name, game, item, data FROM items + where item = $1 + "# + ).bind(item as i64).fetch_one(db).await.ok(); + + Ok(x) + } + + pub async fn get_item_with_name<'a, E>(&self, db: E, name: &str) -> Result, Error> + where + E: PgExecutor<'a>, + { + let x = sqlx::query_as( + r#" + SELECT id, name, game, item, data FROM items + where name = $1 + "# + ).bind(name).fetch_one(db).await.ok(); + + Ok(x) + } + + pub async fn remove_item<'a, E>(&self, db: E, item: i64) -> Result<(), Error> + where + E: PgExecutor<'a>, + { + sqlx::query( + r#" + DELETE FROM items + WHERE id = $1 + "# + ).bind(item).execute(db).await?; + + Ok(()) + } + + pub async fn items<'a, E>(&self, db: E) -> impl Stream> + use<'a, E> + where + E: PgExecutor<'a> + 'a, + { + match self.game { + Some(game) => + sqlx::query_as( + r#" + SELECT id, name, game, item, data FROM items + WHERE owner = $1 AND game = $2 + "# + ) + .bind(self.user.get() as i64) + .bind(game as i64) + .fetch(db), + None => + sqlx::query_as( + r#" + SELECT id, name, game, item, data FROM items + WHERE owner = $1 + "# + ) + .bind(self.user.get() as i64) + .fetch(db) + } + + } +} diff --git a/src/main.rs b/src/main.rs index b7f90c9..b215299 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod commands; pub mod common; +pub mod inventory; use crate::common::{Context, Error, Data}; use std::collections::HashMap; @@ -89,6 +90,28 @@ async fn main() -> Result<(), Error> { "#, ).execute(&database).await?; + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS games ( + id BIGSERIAL PRIMARY KEY, + name CHAR[255] + ) + "# + ).execute(&database).await?; + + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS items ( + id BIGSERIAL PRIMARY KEY, + owner BIGINT NOT NULL, + game BIGINT NOT NULL, + item BIGINT NOT NULL, + data JSON NOT NULL, + name TEXT + ) + "# + ).execute(&database).await?; + println!("Bot is ready!"); Ok(Data {