diff --git a/Cargo.toml b/Cargo.toml index 169f1eb..268ac46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,5 @@ edition = "2021" [dependencies] tokio = { version = "*", features = ["full"] } -serenity = { version = "*", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] } -sqlx = { version = "*", features = ["runtime-tokio-rustls", "sqlite"] } +serenity = { version = "*", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "cache", "utils"] } sea-orm = { version = "*", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } -ctrlc = "*" \ No newline at end of file diff --git a/run/database/script.sql b/run/database/script.sql new file mode 100644 index 0000000..532af18 --- /dev/null +++ b/run/database/script.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id INTEGER NOT NULL, + bn_name TEXT, + bn_tag TEXT, + tank REAL NOT NULL DEFAULT 2500.0, + dps REAL NOT NULL DEFAULT 2500.0, + support REAL NOT NULL DEFAULT 2500.0, + flex INTEGER NOT NULL DEFAULT true, + primary_role INTEGER NOT NULL DEFAULT -1, + secondary_role INTEGER NOT NULL DEFAULT -1, + tertiary_role INTEGER NOT NULL DEFAULT -1, + UNIQUE (discord_id) +); + +CREATE TABLE IF NOT EXISTS lobbies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id INTEGER NOT NULL, + main_voice_id INTEGER NOT NULL, + red_team_voice_id INTEGER NOT NULL, + blue_team_voice_id INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS players_discord_id_idx ON players (discord_id); +CREATE INDEX IF NOT EXISTS lobbies_guild_id_idx ON lobbies (guild_id); +CREATE INDEX IF NOT EXISTS lobbies_main_voice_id_idx ON lobbies (main_voice_id); +CREATE INDEX IF NOT EXISTS lobbies_red_team_voice_id_idx ON lobbies (red_team_voice_id); +CREATE INDEX IF NOT EXISTS lobbies_blue_team_voice_id_idx ON lobbies (blue_team_voice_id); diff --git a/src/bot/commands/lobby.rs b/src/bot/commands/lobby.rs new file mode 100644 index 0000000..b0c710f --- /dev/null +++ b/src/bot/commands/lobby.rs @@ -0,0 +1,164 @@ +use serenity::builder::CreateApplicationCommand; +use serenity::client::Context; +use serenity::model::application::interaction::{ + application_command::ApplicationCommandInteraction, + InteractionResponseType +}; +use serenity::async_trait; +use serenity::http::CacheHttp; +use serenity::model::application::command::CommandOptionType; +use serenity::model::channel::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; +use serenity::model::id::{ChannelId, RoleId, UserId}; +use serenity::model::Permissions; +use crate::bot::commands::MixerCommand; +use crate::database::DatabaseContainer; + +#[derive(Clone)] +pub struct LobbyCommand; + +#[async_trait] +impl MixerCommand for LobbyCommand { + fn name(&self) -> String { + "lobby".to_string() + } + + fn create(&self, command: &mut CreateApplicationCommand) { + command.name(self.name()).description("Create or start a lobby") + .create_option(|option| { + option.name("create") + .description("Create a lobby") + .kind(CommandOptionType::SubCommand) + }) + .create_option(|option| { + option.name("start") + .description("Start a lobby") + .kind(CommandOptionType::SubCommand) + }) + .default_member_permissions(Permissions::MOVE_MEMBERS) + .dm_permission(false); + } + + async fn execute(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + match interaction.data.options.get(0).unwrap().name.as_str() { + "create" => self.create_lobby(ctx, interaction).await, + "start" => self.start_lobby(ctx, interaction).await, + _ => Ok(()) + } + } +} + +impl LobbyCommand { + async fn create_lobby(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + let guild_id = interaction.guild_id.unwrap(); + let member = guild_id.member(ctx.http(), interaction.user.id).await?; + let mut has_permission = false; + if let Ok(perms) = member.permissions(&ctx.cache) { + if perms.manage_channels() { + has_permission = true; + } + } + + if !has_permission { + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("You don't have permission to create a lobby!") + }) + }).await?; + return Ok(()) + } + + let data = ctx.data.read().await; + let db = data.get::().unwrap().read().await; + + let main_voice = interaction.guild_id.unwrap().create_channel(&ctx.http, |c| { + c.name("Mix Lobby").kind(ChannelType::Voice) + }).await?; + + let permissions = vec![ + PermissionOverwrite { + allow: Permissions::empty(), + deny: Permissions::VIEW_CHANNEL, + kind: PermissionOverwriteType::Role(RoleId::from(interaction.guild_id.unwrap().0)) + } + ]; + let red_voice = interaction.guild_id.unwrap().create_channel(&ctx.http, |c| { + c.name("Red").kind(ChannelType::Voice).user_limit(5).permissions(permissions.clone()) + }).await?; + let blue_voice = interaction.guild_id.unwrap().create_channel(&ctx.http, |c| { + c.name("Blue").kind(ChannelType::Voice).user_limit(5).permissions(permissions) + }).await?; + + db.insert_guild_lobby(interaction.guild_id.unwrap(), main_voice.id, red_voice.id, blue_voice.id).await; + + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("Successfully created a new mix lobby!") + }) + }).await?; + + Ok(()) + } + + async fn start_lobby(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + let data = ctx.data.read().await; + let db = data.get::().unwrap().write().await; + + let guild_id = interaction.guild_id.unwrap(); + let member = guild_id.member(ctx.http(), interaction.user.id).await?; + + let mut is_in_lobby = false; + let mut channel_id = None; + for (id, channel) in guild_id.channels(ctx.http()).await? { + if channel.kind != ChannelType::Voice { + continue; + } + let members = channel.members(&ctx.cache).await?; + if members.iter().any(|m| m.user.id == member.user.id) && db.get_lobby_by_channel(guild_id, id).await.is_some() { + is_in_lobby = true; + channel_id = Some(id); + break; + } + } + + if !is_in_lobby { + interaction.create_interaction_response(ctx.http(), |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("You are not in the mix lobby!").ephemeral(true) + }) + }).await?; + + return Ok(()); + } + + let lobby = db.get_lobby_by_channel(guild_id, channel_id.unwrap()).await.unwrap(); + + let main_channel = ChannelId::from(lobby.main_voice_id as u64).to_channel(ctx.http()).await.unwrap().guild().unwrap(); + let red_channel = ChannelId::from(lobby.red_team_voice_id as u64).to_channel(ctx.http()).await.unwrap().guild().unwrap(); + let blue_channel = ChannelId::from(lobby.blue_team_voice_id as u64).to_channel(ctx.http()).await.unwrap().guild().unwrap(); + + for member in red_channel.members(ctx.cache().unwrap()).await? { + member.move_to_voice_channel(ctx.http(), main_channel.id).await?; + } + for member in blue_channel.members(ctx.cache().unwrap()).await? { + member.move_to_voice_channel(ctx.http(), main_channel.id).await?; + } + + let members = main_channel.members(ctx.cache().unwrap()).await?; + let users = members.iter().map(|m| m.user.id).collect::>(); + let players = db.get_players(users).await; + + println!("{:?}", players); + + interaction.create_interaction_response(ctx.http(), |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("Successfully started the mix lobby!").ephemeral(true) + }) + }).await?; + + Ok(()) + } +} \ No newline at end of file diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs index ac65e8e..21fe8dd 100644 --- a/src/bot/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -1,13 +1,17 @@ pub mod ping; +pub mod lobby; +pub mod rank; +pub mod preference; use serenity::builder::CreateApplicationCommand; use serenity::client::Context; use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; use serenity::async_trait; + #[async_trait] pub trait MixerCommand: Sync + Send { fn name(&self) -> String; fn create(&self, command: &mut CreateApplicationCommand); async fn execute(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()>; -} \ No newline at end of file +} diff --git a/src/bot/commands/ping.rs b/src/bot/commands/ping.rs index 715ab48..853a752 100644 --- a/src/bot/commands/ping.rs +++ b/src/bot/commands/ping.rs @@ -5,13 +5,14 @@ use serenity::model::application::interaction::{ InteractionResponseType }; use serenity::async_trait; +use serenity::http::CacheHttp; use crate::bot::commands::MixerCommand; #[derive(Clone)] -pub struct Ping; +pub struct PingCommand; #[async_trait] -impl MixerCommand for Ping { +impl MixerCommand for PingCommand { fn name(&self) -> String { "ping".to_string() } @@ -22,14 +23,25 @@ impl MixerCommand for Ping { async fn execute(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { let content = "Pong!"; - interaction.create_interaction_response(&ctx.http, |response| { + interaction.create_interaction_response(&ctx.http(), |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { - message.content(content) + message.content(content).ephemeral(true) }) }).await?; + let follow1 = interaction.create_followup_message(&ctx.http(), |followup| { + followup.content("followup1").ephemeral(true) + }).await?; + let follow2 = interaction.create_followup_message(&ctx.http(), |followup| { + followup.content("followup2").ephemeral(true) + }).await?; + + interaction.delete_followup_message(&ctx.http(), follow1).await?; + interaction.delete_followup_message(&ctx.http(), follow2).await?; + println!("{:#?}", interaction.get_interaction_response(&ctx.http).await?); + // interaction. println!("Interacted"); diff --git a/src/bot/commands/preference.rs b/src/bot/commands/preference.rs new file mode 100644 index 0000000..a238db6 --- /dev/null +++ b/src/bot/commands/preference.rs @@ -0,0 +1,126 @@ +use std::str::FromStr; +use serenity::builder::CreateApplicationCommand; +use serenity::client::Context; +use serenity::model::application::interaction::{ + application_command::ApplicationCommandInteraction, + InteractionResponseType +}; +use serenity::async_trait; +use serenity::http::CacheHttp; +use serenity::model::Permissions; +use serenity::model::prelude::command::CommandOptionType; +use serenity::model::prelude::interaction::application_command::CommandDataOptionValue::User; +use crate::bot::commands::MixerCommand; +use crate::database::DatabaseContainer; +use crate::mixer::role::Role; + +#[derive(Clone)] +pub struct PreferenceCommand; + +#[async_trait] +impl MixerCommand for PreferenceCommand { + fn name(&self) -> String { + "preference".to_string() + } + + fn create(&self, command: &mut CreateApplicationCommand) { + command.name(self.name()).description("Hello world!") + .create_option(|option| { + option.name("set").description("Set role preference for user") + .kind(CommandOptionType::SubCommandGroup) + .create_sub_option(|option| { + option.name("flex").description("Set role preference for user") + .kind(CommandOptionType::SubCommand) + .create_sub_option(|option| { + option.name("user").description("User to set preference for") + .kind(CommandOptionType::User) + .required(true) + }) + }) + .create_sub_option(|option| { + option.name("complex").description("Set role preference for user") + .kind(CommandOptionType::SubCommand) + .create_sub_option(|option| { + option.name("user").description("User to set preference for") + .kind(CommandOptionType::User) + .required(true) + }) + .create_sub_option(|option| { + option.name("first").description("First role preference") + .kind(CommandOptionType::String) + .required(true) + .add_string_choice("Tank", "tank") + .add_string_choice("DPS", "dps") + .add_string_choice("Support", "support") + .add_string_choice("None", "none") + }) + .create_sub_option(|option| { + option.name("second").description("Second role preference") + .kind(CommandOptionType::String) + .required(true) + .add_string_choice("Tank", "tank") + .add_string_choice("DPS", "dps") + .add_string_choice("Support", "support") + .add_string_choice("None", "none") + }) + .create_sub_option(|option| { + option.name("third").description("Third role preference") + .kind(CommandOptionType::String) + .required(true) + .add_string_choice("Tank", "tank") + .add_string_choice("DPS", "dps") + .add_string_choice("Support", "support") + .add_string_choice("None", "none") + }) + }) + }) + .default_member_permissions(Permissions::ADMINISTRATOR) + .dm_permission(false); + } + + async fn execute(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + let user = match interaction.data.options.get(0).unwrap().options.get(0).unwrap().options.get(0).unwrap().resolved.as_ref().unwrap() { + User(user, _) => user, + _ => { + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content(format!("User not found")).ephemeral(true) + }) + }).await?; + return Ok(()); + } + }; + + match interaction.data.options.get(0).unwrap().name.as_str() { + "set" => { + let data = ctx.data.read().await; + let db = data.get::().unwrap().read().await; + + match interaction.data.options.get(0).unwrap().options.get(0).unwrap().name.as_str() { + "flex" => { + db.update_player_preference(user.id, true, Role::None, Role::None, Role::None).await; + }, + "complex" => { + let role1 = Role::from_str(interaction.data.options.get(0).unwrap().options.get(0).unwrap().options.get(1).unwrap().value.as_ref().unwrap().as_str().unwrap()).unwrap(); + let role2 = Role::from_str(interaction.data.options.get(0).unwrap().options.get(0).unwrap().options.get(2).unwrap().value.as_ref().unwrap().as_str().unwrap()).unwrap(); + let role3 = Role::from_str(interaction.data.options.get(0).unwrap().options.get(0).unwrap().options.get(3).unwrap().value.as_ref().unwrap().as_str().unwrap()).unwrap(); + + db.update_player_preference(user.id, false, role1, role2, role3).await; + }, + _ => {} + } + + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content(format!("Preference set for {}", user.name)).ephemeral(true) + }) + }).await?; + }, + _ => {} + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/bot/commands/rank.rs b/src/bot/commands/rank.rs new file mode 100644 index 0000000..7b5f664 --- /dev/null +++ b/src/bot/commands/rank.rs @@ -0,0 +1,100 @@ +use std::str::FromStr; +use serenity::builder::CreateApplicationCommand; +use serenity::client::Context; +use serenity::model::application::interaction::{application_command::ApplicationCommandInteraction, InteractionResponseType}; +use serenity::async_trait; +use serenity::model::application::interaction::application_command::CommandDataOptionValue::User; +use serenity::model::Permissions; +use serenity::model::prelude::command::CommandOptionType; +use crate::bot::commands::MixerCommand; +use crate::database::DatabaseContainer; +use crate::mixer::role::Role; + +#[derive(Clone)] +pub struct RankCommand; + +#[async_trait] +impl MixerCommand for RankCommand { + fn name(&self) -> String { + "rank".to_string() + } + + fn create(&self, command: &mut CreateApplicationCommand) { + command.name(self.name()).description("Manage user ranks") + .create_option(|option| { + option.name("set") + .description("Set a user's rank") + .kind(CommandOptionType::SubCommand) + .create_sub_option(|option| { + option.name("user") + .description("The user to set the rank for") + .kind(CommandOptionType::User) + .required(true) + }) + .create_sub_option(|option| { + option.name("role") + .description("The role to set the rank for") + .kind(CommandOptionType::String) + .required(true) + .add_string_choice("tank", "tank") + .add_string_choice("dps", "dps") + .add_string_choice("support", "support") + }) + .create_sub_option(|option| { + option.name("rank") + .description("The rank to set") + .kind(CommandOptionType::Integer) + .required(true) + }) + }) + .default_member_permissions(Permissions::ADMINISTRATOR) + .dm_permission(false); + } + + async fn execute(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + match interaction.data.options.get(0).unwrap().name.as_str() { + "set" => { + let user = match interaction.data.options.get(0).unwrap().options.get(0).unwrap().resolved.as_ref().unwrap() { + User(user, _) => user, + _ => { + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content(format!("User not found")).ephemeral(true) + }) + }).await?; + return Ok(()); + } + }; + + let role = Role::from_str(interaction.data.options.get(0).unwrap().options.get(1).unwrap().value.as_ref().unwrap().as_str().unwrap()).unwrap(); + let rank = interaction.data.options.get(0).unwrap().options.get(2).unwrap().value.as_ref().unwrap().as_u64().unwrap(); + + if rank < 1 || rank > 5000 { + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content(format!("Rank must be between 1 and 5000")).ephemeral(true) + }) + }).await?; + return Ok(()); + } + + { + let data = ctx.data.read().await; + let db = data.get::().unwrap().read().await; + db.update_player_rank(user.id, role, rank as f32).await; + } + + interaction.create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content(format!("Setting rank for user {:?} to {:?} {:?}", user.id, role, rank)) + }) + }).await?; + Ok(()) + }, + _ => Ok(()) + } + } +} \ No newline at end of file diff --git a/src/bot/handler.rs b/src/bot/handler.rs deleted file mode 100644 index 5a19099..0000000 --- a/src/bot/handler.rs +++ /dev/null @@ -1,41 +0,0 @@ -use serenity::client::{Context, EventHandler}; -use serenity::model::gateway::Ready; -use serenity::async_trait; -use serenity::model::application::command::Command; -use serenity::model::application::interaction::Interaction; -use crate::bot::MixerBotContainer; - -pub struct Handler; - -#[async_trait] -impl EventHandler for Handler { - async fn ready(&self, ctx: Context, data_about_bot: Ready) { - println!("{} is connected!", data_about_bot.user.name); - - let data = ctx.data.read().await; - let bot_commands = &data.get::().unwrap().read().await.commands; - Command::set_global_application_commands(&ctx.http, |commands| { - for cmd in bot_commands.values() { - commands.create_application_command(|command| { - cmd.create(command); - command - }); - println!("Registered command \"{}\"", cmd.name()) - } - commands - }).await.unwrap(); - } - - async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - let data = ctx.data.read().await; - let bot_commands = &data.get::().unwrap().read().await.commands; - - match interaction { - Interaction::ApplicationCommand(command) => - if let Some(mixer_command) = bot_commands.get(&command.data.name) { - mixer_command.execute(&ctx, command).await.unwrap() - } - _ => {} - } - } -} \ No newline at end of file diff --git a/src/bot/handlers/command_handler.rs b/src/bot/handlers/command_handler.rs new file mode 100644 index 0000000..ff94f54 --- /dev/null +++ b/src/bot/handlers/command_handler.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; +use std::sync::Arc; +use serenity::builder::CreateApplicationCommands; +use serenity::client::Context; +use serenity::model::application::interaction::application_command::ApplicationCommandInteraction; +use serenity::prelude::TypeMapKey; +use crate::bot::commands::MixerCommand; + +pub struct MixerCommandHandler { + commands: HashMap>, +} + +impl MixerCommandHandler { + pub fn new(commands: HashMap>) -> Self { + Self { + commands + } + } + + pub fn create_all(&self, create_commands: &mut CreateApplicationCommands) { + self.commands.values().for_each(|command| { + create_commands.create_application_command(|create_command| { + command.create(create_command); + create_command + }); + println!("Registered command \"{}\"", command.name()) + }); + } + + pub async fn handle_command(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + if let Some(command) = self.commands.get(&interaction.data.name) { + return command.execute(&ctx, interaction).await + } + + Ok(()) + } +} + +pub struct MixerCommandHandlerContainer; + +impl TypeMapKey for MixerCommandHandlerContainer { + type Value = Arc; +} \ No newline at end of file diff --git a/src/bot/handlers/event_handler.rs b/src/bot/handlers/event_handler.rs new file mode 100644 index 0000000..a8b2511 --- /dev/null +++ b/src/bot/handlers/event_handler.rs @@ -0,0 +1,57 @@ +use serenity::client::{Context, EventHandler}; +use serenity::model::gateway::Ready; +use serenity::async_trait; +use serenity::model::application::command::Command; +use serenity::model::application::interaction::Interaction; +use serenity::model::id::ChannelId; +use serenity::model::prelude::{GuildId, VoiceState}; +use crate::database::DatabaseContainer; +use crate::bot::handlers::command_handler::MixerCommandHandlerContainer; + +pub struct Handler; + +#[async_trait] +impl EventHandler for Handler { + async fn ready(&self, ctx: Context, data_about_bot: Ready) { + println!("{} is connected!", data_about_bot.user.name); + let data = ctx.data.read().await; + let command_handler = data.get::().unwrap(); + + Command::set_global_application_commands(&ctx.http, |commands| { + command_handler.create_all(commands); + commands + }).await.unwrap(); + } + + async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { + let data = ctx.data.read().await; + let db = data.get::().unwrap().read().await; + + let user_id = new.user_id; + let new_channel_id = new.channel_id.unwrap_or(ChannelId(0)); + let guild_id = new.guild_id.unwrap_or(GuildId(0)); + + match db.get_lobby_by_channel(guild_id, new_channel_id).await { + Some(_) => { + if !db.has_player(user_id).await { + db.insert_player(user_id).await; + } + + println!("{} joined lobby", user_id); + } + None => {} + } + } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + let data = ctx.data.read().await; + let command_handler = data.get::().unwrap(); + + match interaction { + Interaction::ApplicationCommand(command) => { + command_handler.handle_command(&ctx, command).await.unwrap(); + } + _ => {} + } + } +} \ No newline at end of file diff --git a/src/bot/handlers/mod.rs b/src/bot/handlers/mod.rs new file mode 100644 index 0000000..71eb3aa --- /dev/null +++ b/src/bot/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod event_handler; +pub mod command_handler; \ No newline at end of file diff --git a/src/bot/interactions/mod.rs b/src/bot/interactions/mod.rs new file mode 100644 index 0000000..2189817 --- /dev/null +++ b/src/bot/interactions/mod.rs @@ -0,0 +1,10 @@ +use serenity::client::Context; +use serenity::async_trait; +use serenity::model::application::interaction::message_component::MessageComponentInteraction; + +#[async_trait] +pub trait MixerInteraction: Sync + Send { + fn custom_id(&self) -> String; + // fn create(&self, command: &mut CreateApplicationCommand); + async fn execute(&self, ctx: &Context, interaction: MessageComponentInteraction) -> serenity::Result<()>; +} \ No newline at end of file diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 74ba4b5..1238193 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,28 +1,26 @@ pub mod commands; -mod handler; +pub mod interactions; +mod handlers; use std::collections::HashMap; use std::sync::Arc; use serenity::{CacheAndHttp, Client}; -use serenity::client::bridge::gateway::ShardManager; use serenity::prelude::{GatewayIntents, TypeMap, TypeMapKey}; -use tokio::sync::{Mutex, RwLock}; +use tokio::sync::RwLock; use crate::bot::commands::MixerCommand; -use crate::bot::handler::Handler; +use crate::database::{MixerDatabase, DatabaseContainer}; +use crate::bot::handlers::command_handler::{MixerCommandHandler, MixerCommandHandlerContainer}; +use crate::bot::handlers::event_handler::Handler; pub struct MixerBot { token: String, - commands: HashMap>, + commands: Option>>, + // lobbies: Vec } -struct ShardManagerContainer; struct MixerBotContainer; -impl TypeMapKey for ShardManagerContainer { - type Value = Arc>; -} - impl TypeMapKey for MixerBotContainer { type Value = Arc>; } @@ -32,19 +30,24 @@ impl MixerBot { pub fn new(token: String) -> Self { Self { token, - commands: HashMap::new(), + commands: Some(HashMap::new()), + // lobbies: vec![] } } - pub async fn start(self) -> serenity::Result<()> { - let mut client = Client::builder(&self.token, GatewayIntents::empty()).event_handler(Handler).await?; + pub async fn start(mut self) -> serenity::Result<()> { + let mut client = Client::builder(&self.token, GatewayIntents::all()).event_handler(Handler).await?; - let bot; { let mut data = client.data.write().await; - data.insert::(client.shard_manager.clone()); + data.insert::(Arc::new(MixerCommandHandler::new(self.commands.unwrap()))); + self.commands = None; + data.insert::(Arc::new(RwLock::new(self))); - bot = data.get::().unwrap().clone(); + + let db = MixerDatabase::new("sqlite://database/data.db?mode=rwc").await; + db.init("./database/script.sql").await; + data.insert::(Arc::new(RwLock::new(db))); } let shard_manager = client.shard_manager.clone(); @@ -53,24 +56,26 @@ impl MixerBot { tokio::spawn(async move { tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler"); + let data_ = data.clone(); + let data_ = data_.read().await; + let bot = data_.get::().unwrap(); bot.write().await.shutdown(data, cache_and_http).await; shard_manager.lock().await.shutdown_all().await; }); - client.start().await?; Ok(()) } - pub fn add_command(&mut self, command: Box) -> &mut Self { - self.commands.insert(command.name(), command); + pub fn add_command(&mut self, command: T) -> &mut Self { + self.commands.as_mut().unwrap().insert(command.name(), Box::new(command)); self } - pub async fn shutdown(&self, data: Arc>, cache_and_http: Arc) { - println!("{:#?}", cache_and_http.http); + pub async fn shutdown(&mut self, data: Arc>, cache_and_http: Arc) { + println!("Bot has been shutdown."); } -} \ No newline at end of file +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..8cad696 --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,164 @@ +pub mod models; + +use std::sync::Arc; +use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, Database, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter}; +use sea_orm::ActiveValue::Set; +use serenity::model::id::{ChannelId, UserId}; +use serenity::model::prelude::GuildId; +use serenity::prelude::TypeMapKey; +use tokio::fs; +use tokio::sync::RwLock; +use crate::mixer::role::Role; + +pub struct MixerDatabase { + connection: DatabaseConnection +} + +impl MixerDatabase { + pub async fn new(url: &str) -> Self { + Self { + connection: Database::connect(url).await.expect("Could not connect to database") + } + } + + pub async fn init(&self, script_path: &str) { + let script = fs::read_to_string(script_path).await.expect("Provided script path for creating database does not exist"); + + self.connection.execute_unprepared(&script).await.expect("Invalid database creating script provided"); + } + + pub fn get_connection(&self) -> DatabaseConnection { + self.connection.clone() + } + + //CREATE TABLE IF NOT EXISTS players ( + // id INTEGER PRIMARY KEY AUTOINCREMENT, + // discord_id INTEGER NOT NULL, + // bn_name TEXT, + // bn_tag TEXT, + // tank REAL NOT NULL, + // dps REAL NOT NULL, + // support REAL NOT NULL, + // UNIQUE (discord_id) + // ); + pub async fn insert_player(&self, id: UserId) { + let player = models::player::ActiveModel { + discord_id: Set(id.0 as i64), + bn_name: Set("".to_string()), + bn_tag: Set("".to_string()), + ..Default::default() + }; + + player.insert(&self.connection) + .await + .expect("Could not insert player into database"); + } + + pub async fn update_player_rank(&self, id: UserId, role: Role, rank: f32) { + if !self.has_player(id).await { + self.insert_player(id).await; + } + + let mut player = self.get_player(id).await.unwrap().into_active_model(); + + match role { + Role::Tank => player.tank = Set(rank), + Role::Dps => player.dps = Set(rank), + Role::Support => player.support = Set(rank), + Role::None => return + } + + player.update(&self.connection) + .await + .expect("Could not update player rank in database"); + } + + pub async fn update_player_preference(&self, id: UserId, flex: bool, primary: Role, secondary: Role, tertiary: Role) { + if !self.has_player(id).await { + self.insert_player(id).await; + } + + let mut player = self.get_player(id).await.unwrap().into_active_model(); + + player.flex = Set(flex); + player.primary_role = Set(primary.into()); + player.secondary_role = Set(secondary.into()); + player.tertiary_role = Set(tertiary.into()); + + player.update(&self.connection) + .await + .expect("Could not update player preference in database"); + } + + pub async fn get_player(&self, id: UserId) -> Option { + models::player::Entity::find() + .filter(models::player::Column::DiscordId.eq(id.0)) + .one(&self.connection) + .await + .expect("Could not get player from database") + } + + pub async fn get_players(&self, ids: Vec) -> Vec { + models::player::Entity::find() + .filter(models::player::Column::DiscordId.is_in(ids.iter().map(|id| id.0).collect::>())) + .all(&self.connection) + .await + .expect("Could not get players from database") + } + + pub async fn has_player(&self, id: UserId) -> bool { + models::player::Entity::find() + .filter(models::player::Column::DiscordId.eq(id.0)) + .one(&self.connection) + .await + .expect("Could not get player from database") + .is_some() + } + + pub async fn get_all_guild_lobbies(&self, guild_id: GuildId) -> Vec { + models::lobby::Entity::find() + .filter(models::lobby::Column::GuildId.eq(guild_id.0)) + .all(&self.connection) + .await + .expect("Could not get lobbies from database") + } + + pub async fn get_guild_lobby(&self, lobby_id: i32) -> Option { + models::lobby::Entity::find_by_id(lobby_id) + .one(&self.connection) + .await + .expect("Could not get lobby from database") + } + + pub async fn insert_guild_lobby(&self, guild_id: GuildId, main_voice_id: ChannelId, red_team_voice_id: ChannelId, blue_team_voice_id: ChannelId) { + let lobby = models::lobby::ActiveModel { + guild_id: Set(guild_id.0 as i64), + main_voice_id: Set(main_voice_id.0 as i64), + red_team_voice_id: Set(red_team_voice_id.0 as i64), + blue_team_voice_id: Set(blue_team_voice_id.0 as i64), + ..Default::default() + }; + + lobby.insert(&self.connection) + .await + .expect("Could not insert lobby into database"); + } + + pub async fn get_lobby_by_channel(&self, guild_id: GuildId, channel_id: ChannelId) -> Option { + models::lobby::Entity::find() + .filter(models::lobby::Column::GuildId.eq(guild_id.0).and( + models::lobby::Column::MainVoiceId.eq(channel_id.0 as i64) + .or(models::lobby::Column::RedTeamVoiceId.eq(channel_id.0 as i64)) + .or(models::lobby::Column::BlueTeamVoiceId.eq(channel_id.0 as i64)) + )) + .one(&self.connection) + .await + .expect("Could not get lobby from database") + } +} + +pub struct DatabaseContainer; + +impl TypeMapKey for DatabaseContainer { + type Value = Arc>; +} \ No newline at end of file diff --git a/src/database/models/lobby.rs b/src/database/models/lobby.rs new file mode 100644 index 0000000..fb8985b --- /dev/null +++ b/src/database/models/lobby.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "lobbies")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + pub guild_id: i64, + pub main_voice_id: i64, + pub red_team_voice_id: i64, + pub blue_team_voice_id: i64, +} + +#[derive(Debug, EnumIter, DeriveRelation)] +pub enum Relation { +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs new file mode 100644 index 0000000..04c116d --- /dev/null +++ b/src/database/models/mod.rs @@ -0,0 +1,2 @@ +pub mod lobby; +pub mod player; \ No newline at end of file diff --git a/src/database/models/player.rs b/src/database/models/player.rs new file mode 100644 index 0000000..c3c51b8 --- /dev/null +++ b/src/database/models/player.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; + +#[derive(Debug, Clone, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "players")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + + pub discord_id: i64, + #[sea_orm(column_type = "Text")] + pub bn_name: String, + #[sea_orm(column_type = "Text")] + pub bn_tag: String, + #[sea_orm(default_value = 2500.0)] + pub tank: f32, + #[sea_orm(default_value = 2500.0)] + pub dps: f32, + #[sea_orm(default_value = 2500.0)] + pub support: f32, + + #[sea_orm(default_value = true)] + pub flex: bool, + #[sea_orm(default_value = -1)] + pub primary_role: i32, + #[sea_orm(default_value = -1)] + pub secondary_role: i32, + #[sea_orm(default_value = -1)] + pub tertiary_role: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { +} + +impl ActiveModelBehavior for ActiveModel {} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 737dfa1..e1d8c32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,11 @@ mod bot; +mod mixer; +mod database; -use crate::bot::commands::ping::Ping; +use crate::bot::commands::lobby::LobbyCommand; +use crate::bot::commands::ping::PingCommand; +use crate::bot::commands::preference::PreferenceCommand; +use crate::bot::commands::rank::RankCommand; use crate::bot::MixerBot; #[tokio::main] @@ -9,7 +14,10 @@ async fn main() -> serenity::Result<()> { "NTE2MzMyMzM2NzQ5NzQwMDUz.GiLPzQ.j5gIUGqx6vF6CFhJv8yizksDi-dOBqCvxR32EE".to_string() ); - bot.add_command(Box::new(Ping)); + bot.add_command(PingCommand); + bot.add_command(LobbyCommand); + bot.add_command(RankCommand); + bot.add_command(PreferenceCommand); bot.start().await?; diff --git a/src/mixer/mixer.rs b/src/mixer/mixer.rs new file mode 100644 index 0000000..d82cf9d --- /dev/null +++ b/src/mixer/mixer.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; +use crate::mixer::role::Role; +use crate::database::models::player::{Model as Player, Model}; +use crate::mixer::role; + +pub struct Mixer { + players: Vec, +} + +#[derive(Debug, Clone)] +pub struct MixedPlayer { + pub role: Role, + pub player: Player, +} + +impl Mixer { + pub fn new(players: Vec) -> Self { + Self { + players + } + } + + pub fn select_teams(self) -> Option<(Vec, Vec)> { + let mut players = self.players.clone(); + + // TODO! pass in the original priorities + // let original_priorities = players.iter().cloned().map(|player| (player.id, 1)).collect::>(); + + let mut team1 = Vec::new(); + let mut team2 = Vec::new(); + + for i in 0..10 { + let priorities = self.get_players_priority(&players, &team1, &team2); + + if let Some(player) = self.get_highest_priority_player(&players, &priorities) { + if i % 2 == 0 { + team1.push(player.clone()); + } + else { + team2.push(player.clone()); + } + + players.retain(|p| p.id != player.player.id); + } + else { + return None; + } + } + + Some((team1, team2)) + } + + fn get_players_priority(&self, players: &Vec, team1: &Vec, team2: &Vec) -> HashMap<(Role, i32), f32> { + let mut priorities = HashMap::new(); + + if Self::calculate_priorities(players, team1, team2, Role::Tank, &mut priorities) { + return priorities; + } + + if Self::calculate_priorities(players, team1, team2, Role::Dps, &mut priorities) { + return priorities; + } + + if Self::calculate_priorities(players, team1, team2, Role::Support, &mut priorities) { + return priorities; + } + + priorities + } + + fn average_rank(team1: &Vec, team2: &Vec, role: Role) -> f32 { + let selected_players = team1.iter().filter(|player| player.role == role) + .chain(team2.iter().filter(|player| player.role == role)).clone().collect::>(); + + selected_players.iter().map(|player| { + match player.role { + Role::Tank => player.player.tank, + Role::Dps => player.player.dps, + Role::Support => player.player.support, + _ => 0.0 + } + }).sum::() / selected_players.len() as f32 + } + + fn calculate_priorities(players: &Vec, team1: &Vec, team2: &Vec, expected: Role, priorities: &mut HashMap<(Role, i32), f32>) -> bool { + let group_coefficients = vec![10.0, 50.0, 100.0, 150.0, 125.0]; + + let team1_roles = team1.iter().map(|player| player.role).collect::>(); + let team2_roles = team2.iter().map(|player| player.role).collect::>(); + + let team1_role_count = team1_roles.iter().filter(|role| **role == expected).count(); + let team2_role_count = team2_roles.iter().filter(|role| **role == expected).count(); + let prioritize_role = team1_role_count == 0 || team2_role_count == 0; + + if !prioritize_role { + return false; + } + + + let otp = players.iter().filter(|player| + player.primary_role == expected && player.secondary_role == Role::None && player.tertiary_role == Role::None + ).cloned().collect::>(); + + let primary = players.iter().filter(|player| + player.primary_role == expected && (player.secondary_role != Role::None || player.tertiary_role == Role::None) + ).cloned().collect::>(); + + let secondary = players.iter().filter(|player| + player.secondary_role == expected + ).cloned().collect::>(); + + let flex = players.iter().filter(|player| + player.flex + ).cloned().collect::>(); + + let tertiary = players.iter().filter(|player| + player.tertiary_role == expected + ).cloned().collect::>(); + + + let average_tank_skill = Self::average_rank(team1, team2, expected); + + for player in otp { + let rank = match expected { + Role::Tank => player.tank, + Role::Dps => player.dps, + Role::Support => player.support, + _ => 0.0 + }; + let skill_difference = (rank - average_tank_skill).abs(); + + priorities.insert((expected, player.id), rank / group_coefficients[0] / (skill_difference + 1.0)); + } + + for player in primary { + let rank = match expected { + Role::Tank => player.tank, + Role::Dps => player.dps, + Role::Support => player.support, + _ => 0.0 + }; + let skill_difference = (rank - average_tank_skill).abs(); + + priorities.insert((expected, player.id), rank / group_coefficients[1] / (skill_difference + 1.0)); + } + + for player in secondary { + let rank = match expected { + Role::Tank => player.tank, + Role::Dps => player.dps, + Role::Support => player.support, + _ => 0.0 + }; + let skill_difference = (rank - average_tank_skill).abs(); + + priorities.insert((expected, player.id), rank / group_coefficients[2] / (skill_difference + 1.0)); + } + + for player in tertiary { + let rank = match expected { + Role::Tank => player.tank, + Role::Dps => player.dps, + Role::Support => player.support, + _ => 0.0 + }; + let skill_difference = (rank - average_tank_skill).abs(); + + priorities.insert((expected, player.id), rank / group_coefficients[3] / (skill_difference + 1.0)); + } + + for player in flex { + let rank = match expected { + Role::Tank => player.tank, + Role::Dps => player.dps, + Role::Support => player.support, + _ => 0.0 + }; + let skill_difference = (rank - average_tank_skill).abs(); + + priorities.insert((expected, player.id), rank / group_coefficients[4] / (skill_difference + 1.0)); + } + + true + } + + fn get_highest_priority_player(&self, players: &Vec, priorities: &HashMap<(Role, i32), f32>) -> Option { + let mut highest_priority = 0.0; + let mut highest_priority_player = None; + + for role in vec![Role::Tank, Role::Dps, Role::Support] { + for player in players { + if let Some(priority) = priorities.get(&(role, player.id)) { + if *priority > highest_priority { + highest_priority = *priority; + highest_priority_player = Some(MixedPlayer { + role, + player: player.clone(), + }); + } + } + } + } + + highest_priority_player + } +} \ No newline at end of file diff --git a/src/mixer/mod.rs b/src/mixer/mod.rs new file mode 100644 index 0000000..8914eaf --- /dev/null +++ b/src/mixer/mod.rs @@ -0,0 +1,2 @@ +pub mod mixer; +pub mod role; diff --git a/src/mixer/role.rs b/src/mixer/role.rs new file mode 100644 index 0000000..c5c4792 --- /dev/null +++ b/src/mixer/role.rs @@ -0,0 +1,80 @@ +use std::hash::{Hash, Hasher}; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +pub enum Role { + Tank, + Dps, + Support, + None +} + +impl PartialEq for Role { + fn eq(&self, other: &i32) -> bool { + match self { + Role::Tank => *other == 0, + Role::Dps => *other == 1, + Role::Support => *other == 2, + Role::None => *other == -1 + } + } +} + +impl PartialEq for i32 { + fn eq(&self, other: &Role) -> bool { + match other { + Role::Tank => *self == 0, + Role::Dps => *self == 1, + Role::Support => *self == 2, + Role::None => *self == -1 + } + } +} + +impl From<&Role> for i32 { + fn from(role: &Role) -> Self { + match role { + Role::Tank => 0, + Role::Dps => 1, + Role::Support => 2, + Role::None => -1 + } + } +} + +impl From for Role { + fn from(i: i32) -> Self { + match i { + 0 => Role::Tank, + 1 => Role::Dps, + 2 => Role::Support, + _ => Role::None + } + } +} + +impl Into for Role { + fn into(self) -> i32 { + match self { + Role::Tank => 0, + Role::Dps => 1, + Role::Support => 2, + Role::None => -1 + } + } +} + +impl FromStr for Role { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "tank" => Ok(Role::Tank), + "dps" => Ok(Role::Dps), + "support" => Ok(Role::Support), + "none" => Ok(Role::None), + _ => Ok(Role::None) + } + } +} +