From 881f7247ef6765000bb29c82675689ab9575b5bd Mon Sep 17 00:00:00 2001 From: Lionarius Date: Thu, 13 Apr 2023 00:30:28 +0300 Subject: [PATCH] i want to die --- .gitignore | 4 +- Cargo.toml | 12 +- Shuttle.toml | 1 + run/database/script.sql | 28 --- schema.sql | 95 ++++++++ src/algorithm/glicko2/mod.rs | 67 ++++++ src/algorithm/mod.rs | 1 + src/bot/commands/lobby.rs | 325 ++++++++++++++++++++++++---- src/bot/commands/mod.rs | 1 + src/bot/commands/ping.rs | 16 +- src/bot/commands/preference.rs | 4 +- src/bot/commands/rank.rs | 11 +- src/bot/commands/settings.rs | 105 +++++++++ src/bot/handlers/command_handler.rs | 12 +- src/bot/handlers/event_handler.rs | 57 ----- src/bot/handlers/mod.rs | 1 - src/bot/mod.rs | 117 +++++----- src/database/mod.rs | 88 +++----- src/database/models/player.rs | 28 ++- src/main.rs | 54 ++++- src/mixer/mixer.rs | 205 +++++++++++++----- src/mixer/mod.rs | 1 + src/mixer/player.rs | 56 ++++- src/mixer/rating.rs | 102 +++++++++ src/mixer/role.rs | 10 + src/mixer/team.rs | 75 ++++--- 26 files changed, 1100 insertions(+), 376 deletions(-) create mode 100644 Shuttle.toml delete mode 100644 run/database/script.sql create mode 100644 schema.sql create mode 100644 src/algorithm/glicko2/mod.rs create mode 100644 src/algorithm/mod.rs create mode 100644 src/bot/commands/settings.rs delete mode 100644 src/bot/handlers/event_handler.rs create mode 100644 src/mixer/rating.rs diff --git a/.gitignore b/.gitignore index 5b1c8d6..c102107 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,5 @@ mixer_discord_bot.iml # Rust Cargo.lock +Secrets.toml target/ - -# Database -data.db diff --git a/Cargo.toml b/Cargo.toml index 268ac46..a3ce5f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [package] -name = "mixer_discord_bot" +name = "mixer-discord-bot" version = "0.1.0" edition = "2021" [dependencies] tokio = { version = "*", features = ["full"] } -serenity = { version = "*", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "cache", "utils"] } -sea-orm = { version = "*", features = ["sqlx-sqlite", "runtime-tokio-rustls", "macros"] } +serenity = {version = "*", default-features = false, features = ["rustls_backend", "client", "gateway", "model", "cache", "collector", "utils"] } +sqlx = { version = "0.6.2", features = ["runtime-tokio-native-tls", "postgres"] } +sea-orm = { version = "*", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } +itertools = "*" +shuttle-secrets = "*" +shuttle-serenity = "*" +shuttle-runtime = "*" +shuttle-shared-db = { version = "*", features = ["postgres"] } \ No newline at end of file diff --git a/Shuttle.toml b/Shuttle.toml new file mode 100644 index 0000000..f398fe6 --- /dev/null +++ b/Shuttle.toml @@ -0,0 +1 @@ +name = "mixer-discord-bot" \ No newline at end of file diff --git a/run/database/script.sql b/run/database/script.sql deleted file mode 100644 index 532af18..0000000 --- a/run/database/script.sql +++ /dev/null @@ -1,28 +0,0 @@ -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/schema.sql b/schema.sql new file mode 100644 index 0000000..6c6cf51 --- /dev/null +++ b/schema.sql @@ -0,0 +1,95 @@ +-- DROP TABLE IF EXISTS players_test; +-- DROP FUNCTION IF EXISTS rating_not_null; +-- DROP TYPE rating; +-- DROP TYPE IF EXISTS role; + + +-- CREATE TYPE rating AS ( +-- rating REAL, +-- rd REAL, +-- volatility REAL +-- ); + +-- CREATE TYPE role AS ENUM ('tank', 'dps', 'support'); + +-- CREATE OR REPLACE FUNCTION rating_not_null(r rating) RETURNS BOOLEAN AS $$ +-- BEGIN +-- RETURN (r.rating IS NOT NULL) AND (r.rd IS NOT NULL) AND (r.volatility IS NOT NULL); +-- END; +-- $$ LANGUAGE plpgsql; + +-- CREATE TABLE players_test ( +-- id SERIAL PRIMARY KEY, +-- discord_id BIGINT NOT NULL, +-- bn_name TEXT DEFAULT '', +-- bn_tag TEXT DEFAULT '', + +-- tank_rating rating NOT NULL DEFAULT (2500.0, 300.0, 0.06), +-- dps_rating rating NOT NULL DEFAULT (2500.0, 300.0, 0.06), +-- support_rating rating NOT NULL DEFAULT (2500.0, 300.0, 0.06), + +-- flex BOOLEAN NOT NULL DEFAULT true, +-- primary_role role, +-- secondary_role role, +-- tertiary_role role, +-- UNIQUE (discord_id), +-- CHECK ( +-- rating_not_null(tank_rating) AND +-- rating_not_null(dps_rating) AND +-- rating_not_null(support_rating) +-- ) +-- ); + +CREATE TABLE IF NOT EXISTS players ( + id SERIAL PRIMARY KEY, + discord_id BIGINT NOT NULL, + bn_name TEXT, + bn_tag TEXT, + tank_rating REAL NOT NULL DEFAULT 2500.0, + tank_rd REAL NOT NULL DEFAULT 300, + tank_volatility REAL NOT NULL DEFAULT 0.06, + dps_rating REAL NOT NULL DEFAULT 2500.0, + dps_rd REAL NOT NULL DEFAULT 300, + dps_volatility REAL NOT NULL DEFAULT 0.06, + support_rating REAL NOT NULL DEFAULT 2500.0, + support_rd REAL NOT NULL DEFAULT 300, + support_volatility REAL NOT NULL DEFAULT 0.06, + flex BOOLEAN 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 SERIAL PRIMARY KEY, + guild_id BIGINT NOT NULL, + main_voice_id BIGINT NOT NULL, + red_team_voice_id BIGINT NOT NULL, + blue_team_voice_id BIGINT NOT NULL +); + +-- CREATE TABLE IF NOT EXISTS guilds ( +-- id SERIAL PRIMARY KEY, +-- guild_id BIGINT NOT NULL, +-- separate_roles BOOLEAN NOT NULL DEFAULT false, +-- grandmaster_name TEXT, +-- master_name TEXT, +-- diamond_name TEXT, +-- platinum_name TEXT, +-- gold_name TEXT, +-- silver_name TEXT, +-- bronze_name TEXT, +-- +-- tank_name TEXT, +-- dps_name TEXT, +-- support_name TEXT, +-- +-- UNIQUE (guild_id) +-- ); + +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); \ No newline at end of file diff --git a/src/algorithm/glicko2/mod.rs b/src/algorithm/glicko2/mod.rs new file mode 100644 index 0000000..d20df67 --- /dev/null +++ b/src/algorithm/glicko2/mod.rs @@ -0,0 +1,67 @@ +pub fn update(r: f32, rd: f32, volatility: f32, r_opponent: f32, rd_opponent: f32, scale: f32, score: f32) -> (f32, f32, f32) { + let tau = 0.2f32; + + let mu = (r / scale - 1500.0) / 173.7178; + let phi = rd / 173.7178; + let mu_opponent = (r_opponent / scale - 1500.0) / 173.7178; + let phi_opponent = rd_opponent / 173.7178; + + let g = 1.0 / (1.0 + 3.0 * phi_opponent.powi(2) / std::f32::consts::PI.powi(2)).sqrt(); + + let e = 1.0 / (1.0 + (-g * (mu - mu_opponent)).exp()); + + let v = 1.0 / (g.powi(2) * e * (1.0 - e)); + + let delta = v * g * (score - e); + + + let a_init = volatility.powi(2).ln(); + + let f = |x: f32| -> f32 { + ((x.exp() * (delta.powi(2) - phi.powi(2) - v - x.exp())) + / (2.0 * (phi.powi(2) + v + x.exp()).powi(2))) - ((x - a_init) / (tau.powi(2))) + }; + + let epsilon = 0.000001; + + let mut a = a_init; + let mut b = if delta.powi(2) > phi.powi(2) + v { + (delta.powi(2) - phi.powi(2) - v).ln() + } else { + let mut k = 1.0; + while f(a_init - k * tau) < 0.0 { + k += 1.0; + } + a_init - k * tau + }; + + let mut f_a = f(a); + let mut f_b = f(b); + + while (b - a).abs() > epsilon { + let c = a + (a - b) * f_a / (f_b - f_a); + let f_c = f(c); + + if f_c * f_b <= 0.0 { + a = b; + f_a = f_b; + } else { + f_a /= 2.0; + } + + b = c; + f_b = f_c; + } + + let sigma_prime = (a / 2.0).exp(); + + + let phi_star = (phi.powi(2) + sigma_prime.powi(2)).sqrt(); + let phi_prime = 1.0 / (1.0 / phi_star.powi(2) + 1.0 / v).sqrt(); + let mu_prime = mu + phi_prime.powi(2) * g * (score - e); + + let r_prime = (173.7178 * mu_prime + 1500.0) * scale; + let rd_prime = 173.7178 * phi_prime; + + (r_prime, rd_prime, sigma_prime) +} \ No newline at end of file diff --git a/src/algorithm/mod.rs b/src/algorithm/mod.rs new file mode 100644 index 0000000..fc1fb2b --- /dev/null +++ b/src/algorithm/mod.rs @@ -0,0 +1 @@ +pub mod glicko2; \ No newline at end of file diff --git a/src/bot/commands/lobby.rs b/src/bot/commands/lobby.rs index 13eb26e..abdc730 100644 --- a/src/bot/commands/lobby.rs +++ b/src/bot/commands/lobby.rs @@ -1,20 +1,28 @@ -use serenity::builder::CreateApplicationCommand; +use std::time::Duration; +use itertools::Itertools; +use serenity::builder::{CreateApplicationCommand, CreateEmbed}; use serenity::client::Context; use serenity::model::application::interaction::{ application_command::ApplicationCommandInteraction, InteractionResponseType }; use serenity::async_trait; -use serenity::http::CacheHttp; +use serenity::futures::future::join_all; +use serenity::futures::StreamExt; use serenity::model::application::command::CommandOptionType; +use serenity::model::application::component::ButtonStyle; +use serenity::model::application::interaction::message_component::MessageComponentInteraction; use serenity::model::channel::{ChannelType, PermissionOverwrite, PermissionOverwriteType}; use serenity::model::id::{ChannelId, RoleId, UserId}; use serenity::model::Permissions; +use serenity::utils::Colour; use crate::bot::commands::MixerCommand; use crate::database::DatabaseContainer; +use crate::database::models::lobby::Model; use crate::mixer::mixer; use crate::mixer::player::Player; use crate::mixer::role::Role; +use crate::mixer::team::Team; #[derive(Clone)] @@ -54,16 +62,21 @@ impl MixerCommand for LobbyCommand { 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 guild = guild_id.to_partial_guild(ctx).await?; + let member = guild.member(ctx, interaction.user.id).await?; + let mut has_permission = false; - if let Ok(perms) = member.permissions(&ctx.cache) { - if perms.manage_channels() { + if guild.owner_id == member.user.id { + has_permission = true; + } + if let Ok(perms) = member.permissions(ctx) { + if perms.administrator() { has_permission = true; } } if !has_permission { - interaction.create_interaction_response(&ctx.http, |response| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content("You don't have permission to create a lobby!") @@ -75,7 +88,7 @@ impl LobbyCommand { 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| { + let main_voice = interaction.guild_id.unwrap().create_channel(ctx, |c| { c.name("Mix Lobby").kind(ChannelType::Voice) }).await?; @@ -86,16 +99,18 @@ impl LobbyCommand { kind: PermissionOverwriteType::Role(RoleId::from(interaction.guild_id.unwrap().0)) } ]; - let red_voice = interaction.guild_id.unwrap().create_channel(&ctx.http, |c| { + let red_voice = interaction.guild_id.unwrap().create_channel(ctx, |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| { + let blue_voice = interaction.guild_id.unwrap().create_channel(ctx, |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; + drop(db); + drop(data); - interaction.create_interaction_response(&ctx.http, |response| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content("Successfully created a new mix lobby!") @@ -107,18 +122,20 @@ impl LobbyCommand { 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 db = data.get::().unwrap().read().await; let guild_id = interaction.guild_id.unwrap(); - let member = guild_id.member(ctx.http(), interaction.user.id).await?; + let member = guild_id.member(ctx, interaction.user.id).await?; + + let channels = guild_id.channels(ctx).await?; let mut is_in_lobby = false; let mut channel_id = None; - for (id, channel) in guild_id.channels(ctx.http()).await? { + for (id, channel) in channels { if channel.kind != ChannelType::Voice { continue; } - let members = channel.members(&ctx.cache).await?; + let members = channel.members(ctx).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); @@ -127,7 +144,7 @@ impl LobbyCommand { } if !is_in_lobby { - interaction.create_interaction_response(ctx.http(), |response| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content("You are not in the mix lobby!").ephemeral(true) @@ -139,47 +156,273 @@ impl LobbyCommand { 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(); + let main_channel = ChannelId::from(lobby.main_voice_id as u64).to_channel(ctx).await.unwrap().guild().unwrap(); + let red_channel = ChannelId::from(lobby.red_team_voice_id as u64).to_channel(ctx).await.unwrap().guild().unwrap(); + let blue_channel = ChannelId::from(lobby.blue_team_voice_id as u64).to_channel(ctx).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?; + interaction.create_interaction_response(ctx, |response| { + response.kind(InteractionResponseType::DeferredChannelMessageWithSource) + }).await?; + + for member in red_channel.members(ctx).await? { + member.move_to_voice_channel(ctx, 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?; + for member in blue_channel.members(ctx).await? { + member.move_to_voice_channel(ctx, main_channel.id).await?; } // TODO: uncomment this - // let members = main_channel.members(ctx.cache().unwrap()).await?; - // let users = members.iter().map(|m| m.user.id).collect::>(); - let users = (0..10).map(|id| UserId::from(id)).collect::>(); + let members = main_channel.members(ctx).await?; + let users = members.iter().map(|m| m.user.id).collect::>(); + // let users = (0..11).map(|id| UserId::from(id)).chain(users.into_iter()).collect::>(); let players = db.get_players(users).await; let players = players.into_iter().map(|p| Player::new(p)).collect::>(); let slots = vec![Role::Tank, Role::Dps, Role::Dps, Role::Support, Role::Support]; - if let Some((team1, team2)) = mixer::mix_players(&players, slots) { - println!("Average rank {}", team1.average_rank()); - println!("Average rank tank {}", team1.average_rank_role(&Role::Tank)); - println!("Average rank dps {}", team1.average_rank_role(&Role::Dps)); - println!("Average rank support {}", team1.average_rank_role(&Role::Support)); - println!("Average rank {}", team2.average_rank()); - println!("Average rank tank {}", team2.average_rank_role(&Role::Tank)); - println!("Average rank dps {}", team2.average_rank_role(&Role::Dps)); - println!("Average rank support {}\n", team2.average_rank_role(&Role::Support)); - println!("Team 1: {:?}\n\n", team1.players.iter().map(|p| (p.0.clone().0, p.1.clone().unwrap().name.clone())).collect::>()); - println!("Team 2: {:?}", team2.players.iter().map(|p| (p.0.clone().0, p.1.clone().unwrap().name.clone())).collect::>()); + + interaction.edit_original_interaction_response(ctx, |response| { + response.content("Mixing teams...") + }).await?; + + if let Some(teams) = mixer::mix_players(&players, slots) { + // let interaction = interaction.clone(); + self.process_valid_teams(ctx, interaction, lobby, teams, players).await?; } else { - println!("Fair lobby could not be mixed") + interaction.edit_original_interaction_response(ctx, |response| { + response.content("Fair teams' composition could not be found!") + }).await?; } - interaction.create_interaction_response(ctx.http(), |response| { - response.kind(InteractionResponseType::ChannelMessageWithSource) - .interaction_response_data(|message| { - message.content("Successfully started the mix lobby!").ephemeral(true) + Ok(()) + } + + async fn process_valid_teams(&self, ctx: &Context, interaction: ApplicationCommandInteraction, lobby: Model, teams: (Team, Team), players: Vec) -> serenity::Result<()> { + let (team1, team2) = teams.clone(); + + let team1_names = team1.players.iter() + .sorted_by(|((a, _), _), ((b, _), _)| i32::from(*a).cmp(&i32::from(*b))) + .map(|(_, i)| async { + if let Some(user) = players[i.unwrap()].discord_id.to_user(ctx).await.ok() { + user.name + } + else { + players[i.unwrap()].bn_name.clone() + } + }).collect_vec(); + let team2_names = team2.players.iter() + .sorted_by(|((a, _), _), ((b, _), _)| i32::from(*a).cmp(&i32::from(*b))) + .map(|(_, i)| async { + if let Some(user) = players[i.unwrap()].discord_id.to_user(ctx).await.ok() { + user.name + } + else { + players[i.unwrap()].bn_name.clone() + } + }).collect_vec(); + + let team1_names = join_all(team1_names).await; + let team2_names = join_all(team2_names).await; + + interaction.edit_original_interaction_response(ctx, |response| { + response.content("").components(|components| { + components.create_action_row(|row| { + row.create_button(|button| { + button.custom_id("cancel").label("Cancel") + .style(ButtonStyle::Danger) + }); + row.create_button(|button| { + button.custom_id("swap").label("Swap").disabled(true) + .style(ButtonStyle::Primary) + }); + row.create_button(|button| { + button.custom_id("start").label("Start") + .style(ButtonStyle::Success) + }) }) + }).embed(|embed| { + embed.title("Teams").fields(vec![ + ("Team 1", "", false), + ("Tank", &team1_names[0], true), + ("Dps", &format!("{}\n{}", team1_names[1], team1_names[2]), true), + ("Support", &format!("{}\n{}", team1_names[3], team1_names[4]), true), + ("Team 2", "", false), + ("Tank", &team2_names[0], true), + ("Dps", &format!("{}\n{}", team2_names[1], team2_names[2]), true), + ("Support", &format!("{}\n{}", team2_names[3], team2_names[4]), true), + ]).colour(Colour::new(0xcfa22f)) + }) + }).await.unwrap(); + + let msg = interaction.get_interaction_response(ctx).await.unwrap(); + let collector = msg.await_component_interactions(ctx) + .timeout(Duration::from_secs(2 * 60)) + .guild_id(interaction.guild_id.unwrap()) + .channel_id(interaction.channel_id) + .author_id(interaction.user.id) + .collect_limit(1) + .build(); + + let interactions = collector.collect::>().await; + if let Some(interaction) = interactions.first() { + match interaction.data.custom_id.as_str() { + "start" => self.process_valid_teams_start(ctx, interaction, lobby, &team1, &team2, players).await?, + "cancel" => self.process_valid_teams_cancel(ctx, interaction, &team1, &team2).await?, + "swap" => self.process_valid_teams_swap(ctx, interaction, &team1, &team2).await?, + _ => {} + } + } + else { + interaction.delete_original_interaction_response(ctx).await?; + } + + Ok(()) + } + + async fn process_valid_teams_start(&self, ctx: &Context, interaction: &MessageComponentInteraction, lobby: Model, team1: &Team, team2: &Team, players: Vec) -> serenity::Result<()> { + let main_channel = ChannelId::from(lobby.main_voice_id as u64).to_channel(ctx).await.unwrap().guild().unwrap(); + let red_channel = ChannelId::from(lobby.red_team_voice_id as u64).to_channel(ctx).await.unwrap().guild().unwrap(); + let blue_channel = ChannelId::from(lobby.blue_team_voice_id as u64).to_channel(ctx).await.unwrap().guild().unwrap(); + + for member in main_channel.members(ctx).await? { + let index = players.iter().position(|p| p.discord_id == member.user.id); + + if team1.players.iter().any(|(_, i)| *i == index && index.is_some()) { + member.move_to_voice_channel(ctx, red_channel.id).await?; + } + else if team2.players.iter().any(|(_, i)| *i == index && index.is_some()) { + member.move_to_voice_channel(ctx, blue_channel.id).await?; + } + } + + let embed = interaction.message.embeds.get(0).unwrap(); + interaction.delete_original_interaction_response(ctx).await?; + + // create msg without interaction + let msg = interaction.channel_id.send_message(ctx, |message| { + message.content(format!("<@{}>", interaction.user.id.0)) + .set_embeds(vec![CreateEmbed::from(embed.clone())]) + .components(|components| { + components.create_action_row(|row| { + row.create_button(|button| { + button.custom_id("win_team1") + .label("Team 1 win") + .style(ButtonStyle::Success) + }).create_button(|button| { + button.custom_id("draw") + .label("Draw") + .style(ButtonStyle::Secondary) + }).create_button(|button| { + button.custom_id("win_team2") + .label("Team 2 win") + .style(ButtonStyle::Success) + }) + }).create_action_row(|row| { + row.create_button(|button| { + button.custom_id("cancel") + .label("Cancel game") + .style(ButtonStyle::Danger) + }) + }) + }) }).await?; + + // let msg = interaction.edit_original_interaction_response(ctx, |message| { + // message.components(|components| { + // components.create_action_row(|row| { + // row.create_button(|button| { + // button.custom_id("win_team1") + // .label("Team 1 win") + // .style(ButtonStyle::Success) + // }).create_button(|button| { + // button.custom_id("draw") + // .label("Draw") + // .style(ButtonStyle::Secondary) + // }).create_button(|button| { + // button.custom_id("win_team2") + // .label("Team 2 win") + // .style(ButtonStyle::Success) + // }) + // }).create_action_row(|row| { + // row.create_button(|button| { + // button.custom_id("cancel") + // .label("Cancel game") + // .style(ButtonStyle::Danger) + // }) + // }) + // }) + // }).await?; + + let collector = msg.await_component_interactions(ctx) + .timeout(Duration::from_secs(30 * 60)) + .guild_id(interaction.guild_id.unwrap()) + .channel_id(interaction.channel_id) + .author_id(interaction.user.id) + .collect_limit(1) + .build(); + + let interactions = collector.collect::>().await; + if let Some(interaction) = interactions.first() { + let mut score = 0.5f32; + match interaction.data.custom_id.as_str() { + "win_team1" => score = 1.0, + "draw" => score = 0.5, + "win_team2" => score = 0.0, + "cancel" => { + return msg.delete(ctx).await; + // return interaction.delete_original_interaction_response(ctx).await; + } + _ => {} + } + + let team1_average_rating = team1.average_rating(&players); + let team2_average_rating = team2.average_rating(&players); + + let team1 = team1.players.clone().into_iter() + .filter_map(|((role, _), player)| + if player.is_some() { + Some((role, player.unwrap())) + } else { + None + } + ).collect::>(); + let team2 = team2.players.clone().into_iter() + .filter_map(|((role, _), player)| + if player.is_some() { + Some((role, player.unwrap())) + } else { + None + } + ).collect::>(); + + let data = ctx.data.read().await; + let db = data.get::().unwrap().read().await; + + for (role, index) in team1 { + let mut rating = players[index].ranks[&role]; + rating.update(&team2_average_rating, score); + db.update_player_rank(players[index].discord_id, Some(role), rating).await; + } + + for (role, index) in team2 { + let mut rating = players[index].ranks[&role]; + rating.update(&team1_average_rating, 1.0 - score); + db.update_player_rank(players[index].discord_id, Some(role), rating).await; + } + + drop(db); + drop(data); + } + + msg.delete(ctx).await + } + + async fn process_valid_teams_cancel(&self, ctx: &Context, interaction: &MessageComponentInteraction, team1: &Team, team2: &Team) -> serenity::Result<()> { + interaction.delete_original_interaction_response(ctx).await + } + + async fn process_valid_teams_swap(&self, ctx: &Context, interaction: &MessageComponentInteraction, team1: &Team, team2: &Team) -> serenity::Result<()> { + Ok(()) } } \ No newline at end of file diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs index 21fe8dd..2b380c4 100644 --- a/src/bot/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -2,6 +2,7 @@ pub mod ping; pub mod lobby; pub mod rank; pub mod preference; +pub mod settings; use serenity::builder::CreateApplicationCommand; use serenity::client::Context; diff --git a/src/bot/commands/ping.rs b/src/bot/commands/ping.rs index 853a752..67a5e68 100644 --- a/src/bot/commands/ping.rs +++ b/src/bot/commands/ping.rs @@ -5,7 +5,6 @@ use serenity::model::application::interaction::{ InteractionResponseType }; use serenity::async_trait; -use serenity::http::CacheHttp; use crate::bot::commands::MixerCommand; #[derive(Clone)] @@ -23,27 +22,22 @@ impl MixerCommand for PingCommand { 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, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content(content).ephemeral(true) }) }).await?; - let follow1 = interaction.create_followup_message(&ctx.http(), |followup| { + let follow1 = interaction.create_followup_message(ctx, |followup| { followup.content("followup1").ephemeral(true) }).await?; - let follow2 = interaction.create_followup_message(&ctx.http(), |followup| { + let follow2 = interaction.create_followup_message(ctx, |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"); + interaction.delete_followup_message(ctx, follow1).await?; + interaction.delete_followup_message(ctx, follow2).await?; Ok(()) } diff --git a/src/bot/commands/preference.rs b/src/bot/commands/preference.rs index 894d003..b2e5d8d 100644 --- a/src/bot/commands/preference.rs +++ b/src/bot/commands/preference.rs @@ -81,7 +81,7 @@ impl MixerCommand for PreferenceCommand { 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| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content(format!("User not found")).ephemeral(true) @@ -110,7 +110,7 @@ impl MixerCommand for PreferenceCommand { _ => {} } - interaction.create_interaction_response(&ctx.http, |response| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content(format!("Preference set for {}", user.name)).ephemeral(true) diff --git a/src/bot/commands/rank.rs b/src/bot/commands/rank.rs index 35564f8..7254eb9 100644 --- a/src/bot/commands/rank.rs +++ b/src/bot/commands/rank.rs @@ -8,6 +8,7 @@ use serenity::model::Permissions; use serenity::model::prelude::command::CommandOptionType; use crate::bot::commands::MixerCommand; use crate::database::DatabaseContainer; +use crate::mixer::rating::Rating; use crate::mixer::role::Role; #[derive(Clone)] @@ -71,7 +72,7 @@ impl MixerCommand for RankCommand { 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| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content(format!("Rank must be between 1 and 5000")).ephemeral(true) @@ -83,10 +84,14 @@ impl MixerCommand for RankCommand { { let data = ctx.data.read().await; let db = data.get::().unwrap().read().await; - db.update_player_rank(user.id, role, rank as f32).await; + + db.update_player_rank(user.id, role, Rating::new_no_sigma( + rank as f32, + 300.0 + )).await; } - interaction.create_interaction_response(&ctx.http, |response| { + interaction.create_interaction_response(ctx, |response| { response.kind(InteractionResponseType::ChannelMessageWithSource) .interaction_response_data(|message| { message.content(format!("Setting rank for user {:?} to {:?} {:?}", user.id, role, rank)) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs new file mode 100644 index 0000000..fb47002 --- /dev/null +++ b/src/bot/commands/settings.rs @@ -0,0 +1,105 @@ +use std::collections::hash_map::RandomState; +use std::collections::HashMap; +use serenity::builder::CreateApplicationCommand; +use serenity::client::Context; +use serenity::model::application::interaction::application_command::{ApplicationCommandInteraction, CommandDataOption}; +use serenity::model::application::interaction::InteractionResponseType; +use serenity::async_trait; +use serenity::model::application::command::CommandOptionType; +use serenity::model::Permissions; +use crate::bot::commands::MixerCommand; +use crate::mixer::role::Role; + +pub struct SettingsCommand; + +#[async_trait] +impl MixerCommand for SettingsCommand { + fn name(&self) -> String { + "settings".to_string() + } + + fn create(&self, command: &mut CreateApplicationCommand) { + command.name(&self.name()).description("Change your server settings") + .create_option(|option| { + option.name("roles").kind(CommandOptionType::SubCommandGroup) + .description("Change your role settings") + .create_sub_option(|sub_option| { + sub_option.name("automatic").kind(CommandOptionType::SubCommand) + .description("Automatically assign roles based on your rank") + }) + }).default_member_permissions(Permissions::ADMINISTRATOR) + .dm_permission(false); + } + + async fn execute(&self, ctx: &Context, interaction: ApplicationCommandInteraction) -> serenity::Result<()> { + let data = interaction.data.options.get(0).unwrap().clone(); + match data.name.as_str() { + "roles" => self.process_roles_subcommand(ctx, interaction.clone(), data).await?, + _ => {} + } + + interaction.create_interaction_response(ctx, |response| { + response.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|message| { + message.content("Settings updated") + }) + }).await?; + + Ok(()) + } +} + +impl SettingsCommand { + async fn process_roles_subcommand(&self, ctx: &Context, interaction: ApplicationCommandInteraction, data: CommandDataOption) -> serenity::Result<()> { + match data.options.get(0).unwrap().name.as_str() { + "automatic" => { + let roles: HashMap<_, _, RandomState> = HashMap::from_iter([ + ("support", Role::Support), + ("damage", Role::Dps), + ("dps", Role::Dps), + ("tank", Role::Tank) + ].into_iter()); + let ranks: HashMap<_, _, RandomState> = HashMap::from_iter([ + ("bronze", 0), + ("silver", 1), + ("gold", 2), + ("platinum", 3), + ("diamond", 4), + ("master", 5), + ("grandmaster", 6), + ].into_iter()); + + let guild = interaction.guild_id.unwrap().to_partial_guild(ctx).await.unwrap(); + // let guild_roles = guild.roles; + let mut guild_roles = HashMap::new(); + for (id, guild_role) in guild.roles { + for (name, role) in roles.iter() { + if !(guild_role.name.to_lowercase().contains(name)) { + continue; + } + + for (name, rank) in ranks.iter() { + if !(guild_role.name.to_lowercase().contains(name)) { + continue; + } + if !guild_roles.contains_key(&id) { + guild_roles.insert(id, (role, rank)); + } + else { + let (_, old_rank) = guild_roles.get(&id).unwrap(); + if rank > *old_rank { + guild_roles.insert(id, (role, rank)); + } + } + } + } + } + + println!("{:#?}", guild_roles); + } + _ => {} + } + + Ok(()) + } +} \ No newline at end of file diff --git a/src/bot/handlers/command_handler.rs b/src/bot/handlers/command_handler.rs index ff94f54..f547cdc 100644 --- a/src/bot/handlers/command_handler.rs +++ b/src/bot/handlers/command_handler.rs @@ -1,9 +1,7 @@ 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 { @@ -17,6 +15,10 @@ impl MixerCommandHandler { } } + pub fn add_command(&mut self, command: T) { + self.commands.insert(command.name(), Box::new(command)); + } + pub fn create_all(&self, create_commands: &mut CreateApplicationCommands) { self.commands.values().for_each(|command| { create_commands.create_application_command(|create_command| { @@ -34,10 +36,4 @@ impl MixerCommandHandler { 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 deleted file mode 100644 index a8b2511..0000000 --- a/src/bot/handlers/event_handler.rs +++ /dev/null @@ -1,57 +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 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 index 71eb3aa..5f7c2cf 100644 --- a/src/bot/handlers/mod.rs +++ b/src/bot/handlers/mod.rs @@ -1,2 +1 @@ -pub mod event_handler; pub mod command_handler; \ No newline at end of file diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 1238193..0421adc 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -3,79 +3,78 @@ pub mod interactions; mod handlers; use std::collections::HashMap; -use std::sync::Arc; -use serenity::{CacheAndHttp, Client}; -use serenity::prelude::{GatewayIntents, TypeMap, TypeMapKey}; -use tokio::sync::RwLock; +use serenity::client::{Context, EventHandler}; +use serenity::http::CacheHttp; +use serenity::model::application::interaction::{Interaction, InteractionResponseType}; +use serenity::model::gateway::Ready; +use serenity::async_trait; +use serenity::model::application::command::Command; +use serenity::model::prelude::VoiceState; + use crate::bot::commands::MixerCommand; -use crate::database::{MixerDatabase, DatabaseContainer}; -use crate::bot::handlers::command_handler::{MixerCommandHandler, MixerCommandHandlerContainer}; -use crate::bot::handlers::event_handler::Handler; +use crate::bot::handlers::command_handler::MixerCommandHandler; +use crate::database::DatabaseContainer; pub struct MixerBot { - token: String, - commands: Option>>, - // lobbies: Vec + command_handler: MixerCommandHandler, } -struct MixerBotContainer; - - -impl TypeMapKey for MixerBotContainer { - type Value = Arc>; -} - - impl MixerBot { - pub fn new(token: String) -> Self { + pub fn new() -> Self { Self { - token, - commands: Some(HashMap::new()), - // lobbies: vec![] + command_handler: MixerCommandHandler::new(HashMap::new()) } } - pub async fn start(mut self) -> serenity::Result<()> { - let mut client = Client::builder(&self.token, GatewayIntents::all()).event_handler(Handler).await?; - - { - let mut data = client.data.write().await; - data.insert::(Arc::new(MixerCommandHandler::new(self.commands.unwrap()))); - self.commands = None; - - data.insert::(Arc::new(RwLock::new(self))); - - 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(); - let cache_and_http = client.cache_and_http.clone(); - let data = client.data.clone(); - 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: T) -> &mut Self { - self.commands.as_mut().unwrap().insert(command.name(), Box::new(command)); + self.command_handler.add_command(command); self } +} - pub async fn shutdown(&mut self, data: Arc>, cache_and_http: Arc) { +#[async_trait] +impl EventHandler for MixerBot { + async fn ready(&self, ctx: Context, data_about_bot: Ready) { + println!("{} is connected!", data_about_bot.user.name); - println!("Bot has been shutdown."); + Command::set_global_application_commands(&ctx.http, |commands| { + self.command_handler.create_all(commands); + commands + }).await.unwrap(); + } + + async fn voice_state_update(&self, ctx: Context, _old: Option, new: VoiceState) { + if let Some(guild_id) = new.guild_id { + if let Some(channel_id) = new.channel_id { + let data = ctx.data.read().await; + let db = data.get::().unwrap().read().await; + + if let Some(_) = db.get_lobby_by_channel(guild_id, channel_id).await { + if let Some(member) = new.member { + if member.user.bot { + return; + } + } + + if db.get_player(new.user_id).await.is_none() { + db.insert_player(new.user_id).await; + } + } + } + } + } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + match interaction { + Interaction::ApplicationCommand(command) => { + self.command_handler.handle_command(&ctx, command).await.unwrap(); + } + Interaction::MessageComponent(component) => { + component.create_interaction_response(ctx.http(), |response| { + response.kind(InteractionResponseType::DeferredUpdateMessage) + }).await.unwrap(); + } + _ => {} + } } } diff --git a/src/database/mod.rs b/src/database/mod.rs index c94cdae..0b39720 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,13 +1,14 @@ pub mod models; use std::sync::Arc; -use sea_orm::{ActiveModelTrait, ColumnTrait, ConnectionTrait, Database, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, SqlxPostgresConnector}; use sea_orm::ActiveValue::Set; use serenity::model::id::{ChannelId, UserId}; use serenity::model::prelude::GuildId; use serenity::prelude::TypeMapKey; -use tokio::fs; +use sqlx::PgPool; use tokio::sync::RwLock; +use crate::mixer::rating::Rating; use crate::mixer::role::Role; pub struct MixerDatabase { @@ -15,32 +16,12 @@ pub struct MixerDatabase { } impl MixerDatabase { - pub async fn new(url: &str) -> Self { + pub fn new (pool: PgPool) -> Self { Self { - connection: Database::connect(url).await.expect("Could not connect to database") + connection: SqlxPostgresConnector::from_sqlx_postgres_pool(pool) } } - 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), @@ -54,17 +35,29 @@ impl MixerDatabase { .expect("Could not insert player into database"); } - pub async fn update_player_rank(&self, id: UserId, role: Option, rank: f32) { - if !self.has_player(id).await { + pub async fn update_player_rank(&self, id: UserId, role: Option, rating: Rating) { + if self.get_player(id).await.is_none() { self.insert_player(id).await; } let mut player = self.get_player(id).await.unwrap().into_active_model(); match role { - Some(Role::Tank) => player.tank = Set(rank), - Some(Role::Dps) => player.dps = Set(rank), - Some(Role::Support) => player.support = Set(rank), + Some(Role::Tank) => { + player.tank_rating = Set(rating.value); + player.tank_rd = Set(rating.rd); + player.tank_volatility = Set(rating.volatility); + }, + Some(Role::Dps) => { + player.dps_rating = Set(rating.value); + player.dps_rd = Set(rating.rd); + player.dps_volatility = Set(rating.volatility); + }, + Some(Role::Support) => { + player.support_rating = Set(rating.value); + player.support_rd = Set(rating.rd); + player.support_volatility = Set(rating.volatility); + }, None => return } @@ -74,7 +67,7 @@ impl MixerDatabase { } pub async fn update_player_preference(&self, id: UserId, flex: bool, primary: Option, secondary: Option, tertiary: Option) { - if !self.has_player(id).await { + if self.get_player(id).await.is_none() { self.insert_player(id).await; } @@ -106,29 +99,20 @@ impl MixerDatabase { .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_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 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 { diff --git a/src/database/models/player.rs b/src/database/models/player.rs index c3c51b8..0d63d1c 100644 --- a/src/database/models/player.rs +++ b/src/database/models/player.rs @@ -11,15 +11,31 @@ pub struct Model { 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 = 2500.0)] + pub tank_rating: f32, + #[sea_orm(default_value = 580.0)] + pub tank_rd: f32, + #[sea_orm(default_value = 0.06)] + pub tank_volatility: f32, + + #[sea_orm(default_value = 2500.0)] + pub dps_rating: f32, + #[sea_orm(default_value = 580.0)] + pub dps_rd: f32, + #[sea_orm(default_value = 0.06)] + pub dps_volatility: f32, + + #[sea_orm(default_value = 2500.0)] + pub support_rating: f32, + #[sea_orm(default_value = 580.0)] + pub support_rd: f32, + #[sea_orm(default_value = 0.06)] + pub support_volatility: f32, + #[sea_orm(default_value = -1)] pub primary_role: i32, #[sea_orm(default_value = -1)] diff --git a/src/main.rs b/src/main.rs index e1d8c32..634372b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,25 +1,65 @@ mod bot; mod mixer; mod database; +mod algorithm; +use std::sync::Arc; +use serenity::Client; +use serenity::prelude::GatewayIntents; +use shuttle_runtime::Context; +use shuttle_secrets::SecretStore; +use sqlx::{Executor, PgPool}; +use tokio::sync::RwLock; 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::commands::settings::SettingsCommand; use crate::bot::MixerBot; +use crate::database::{DatabaseContainer, MixerDatabase}; -#[tokio::main] -async fn main() -> serenity::Result<()> { - let mut bot = MixerBot::new( - "NTE2MzMyMzM2NzQ5NzQwMDUz.GiLPzQ.j5gIUGqx6vF6CFhJv8yizksDi-dOBqCvxR32EE".to_string() - ); + +#[shuttle_runtime::main] +async fn serenity( + #[shuttle_shared_db::Postgres( + local_uri = "postgres://postgres:{secrets.PASSWORD}@localhost:5432/postgres" + )] pool: PgPool, + #[shuttle_secrets::Secrets] secret_store: SecretStore +) -> shuttle_serenity::ShuttleSerenity { + let token = secret_store.get("DISCORD_TOKEN").context("'DISCORD_TOKEN' was not found")?; + let app_id = secret_store.get("DISCORD_APP_ID").context("'DISCORD_APP_ID' was not found")?; + + pool.execute(include_str!("../schema.sql")).await.unwrap(); + + let mut bot = MixerBot::new(); bot.add_command(PingCommand); bot.add_command(LobbyCommand); bot.add_command(RankCommand); bot.add_command(PreferenceCommand); + bot.add_command(SettingsCommand); - bot.start().await?; + let client = Client::builder(&token, GatewayIntents::all()) + .event_handler(bot) + .application_id(app_id.parse::().unwrap()) + .await + .expect("Error creating client"); - Ok(()) + { + let mut data = client.data.write().await; + + let db = MixerDatabase::new(pool); + data.insert::(Arc::new(RwLock::new(db))); + } + + let shard_manager = client.shard_manager.clone(); + tokio::spawn(async move { + tokio::signal::ctrl_c().await.expect("Could not register ctrl+c handler"); + + println!("Bot has been shutdown."); + + shard_manager.lock().await.shutdown_all().await; + }); + + Ok(client.into()) } diff --git a/src/mixer/mixer.rs b/src/mixer/mixer.rs index 4a9246c..1e29e70 100644 --- a/src/mixer/mixer.rs +++ b/src/mixer/mixer.rs @@ -1,78 +1,167 @@ +use std::cmp::Ordering; +use itertools::Itertools; use crate::mixer::role::Role; use crate::mixer::player::Player; use crate::mixer::team::Team; -pub fn mix_players(players: &Vec, slots: Vec) -> Option<(Team, Team)> { - let mut players = players.clone(); - let mut team1 = Team::new(slots.clone()); - let mut team2 = Team::new(slots.clone()); +struct PlayerRoleEntry { + pub index: usize, + pub role: Role, + pub priority: f32, +} - for _ in 0..(slots.len()*2) { - let mut priorities = calculate_priorities(&players, &team1, &team2); - priorities.sort_by(|(_, _, p1), (_, _, p2)| p2.partial_cmp(p1).unwrap()); +impl Eq for PlayerRoleEntry {} - for (player, role, _) in priorities { - if team1.full_rank() > team2.full_rank() { - if team2.has_slot(&role) { - team2.add_player(&player, &role); - players.retain(|p| p.id != player.id); - break; - } - } else { - if team1.has_slot(&role) { - team1.add_player(&player, &role); - players.retain(|p| p.id != player.id); - break; - } - } - } +impl PartialEq for PlayerRoleEntry { + fn eq(&self, other: &Self) -> bool { + self.index == other.index && self.role == other.role } - - if team1.count() < slots.len() || team2.count() < slots.len() { - return None; - } - - Some((team1, team2)) } -fn calculate_priorities(players: &Vec, team1: &Team, team2: &Team) -> Vec<(Player, Role, f32)> { - let mut priorities = Vec::new(); +pub fn mix_players(players: &[Player], slots: Vec) -> Option<(Team, Team)> { + let players = players.to_vec(); - for player in players { - for (role, priority) in player.base_priority() { - priorities.push((player.clone(), role, priority)); + let entries = calculate_priorities(&players); + let tanks = entries.iter().filter(|e| e.role == Role::Tank).collect_vec(); + let supports = entries.iter().filter(|e| e.role == Role::Support).collect_vec(); + let dps = entries.iter().filter(|e| e.role == Role::Dps).collect_vec(); + + + let tank_count = slots.iter().filter(|r| **r == Role::Tank).count(); + let support_count = slots.iter().filter(|r| **r == Role::Support).count(); + let dps_count = slots.iter().filter(|r| **r == Role::Dps).count(); + + let tank_combos = tanks.iter().combinations(tank_count) + .sorted_by(|a, b| { + let a = a.iter().map(|e| e.priority).sum::(); + let b = b.iter().map(|e| e.priority).sum::(); + a.partial_cmp(&b).unwrap_or(Ordering::Equal) + }).collect_vec(); + let dps_combos = dps.iter().combinations(dps_count) + .sorted_by(|a, b| { + let a = a.iter().map(|e| e.priority).sum::(); + let b = b.iter().map(|e| e.priority).sum::(); + a.partial_cmp(&b).unwrap_or(Ordering::Equal) + }).collect_vec(); + let support_combos = supports.iter().combinations(support_count) + .sorted_by(|a, b| { + let a = a.iter().map(|e| e.priority).sum::(); + let b = b.iter().map(|e| e.priority).sum::(); + a.partial_cmp(&b).unwrap_or(Ordering::Equal) + }).collect_vec(); + + let mut best_team1 = None; + let mut best_team2 = None; + let mut best_diff = None; + + let threshold = 100.0; + + // this is awful, but it works + for tank1_combo in &tank_combos { + for tank2_combo in &tank_combos { + if tank1_combo.iter().any(|e| tank2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + + for dps1_combo in &dps_combos { + if tank1_combo.iter().any(|e| dps1_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if tank2_combo.iter().any(|e| dps1_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + + for dps2_combo in &dps_combos { + if tank1_combo.iter().any(|e| dps2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if tank2_combo.iter().any(|e| dps2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if dps1_combo.iter().any(|e| dps2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + + for support1_combo in &support_combos { + if tank1_combo.iter().any(|e| support1_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if tank2_combo.iter().any(|e| support1_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if dps1_combo.iter().any(|e| support1_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if dps2_combo.iter().any(|e| support1_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + + for support2_combo in &support_combos { + if tank1_combo.iter().any(|e| support2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if tank2_combo.iter().any(|e| support2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if dps1_combo.iter().any(|e| support2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if dps2_combo.iter().any(|e| support2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + if support1_combo.iter().any(|e| support2_combo.iter().any(|e2| e.index == e2.index)) { + continue; + } + + let mut team1 = Team::new(slots.clone()); + let mut team2 = Team::new(slots.clone()); + + for entry in tank1_combo.iter().chain(dps1_combo.iter()).chain(support1_combo.iter()) { + team1.add_player(entry.index, &entry.role); + } + + for entry in tank2_combo.iter().chain(dps2_combo.iter()).chain(support2_combo.iter()) { + team2.add_player(entry.index, &entry.role); + } + + let diff_rating = (team1.full_rating(&players).value - team2.full_rating(&players).value).abs(); + let diff_rating_tank = (team1.full_rating_role(&Role::Tank, &players).value - team2.full_rating_role(&Role::Tank, &players).value).abs(); + let diff_rating_dps = (team1.full_rating_role(&Role::Dps, &players).value - team2.full_rating_role(&Role::Dps, &players).value).abs(); + let diff_rating_support = (team1.full_rating_role(&Role::Support, &players).value - team2.full_rating_role(&Role::Support, &players).value).abs(); + let diff = diff_rating + (diff_rating_tank + diff_rating_dps + diff_rating_support); + + if diff + threshold < best_diff.unwrap_or(f32::MAX) { + if diff < threshold { + return Some((team1, team2)); + } + + best_team1 = Some(team1); + best_team2 = Some(team2); + best_diff = Some(diff); + } + } + } + } + } } } - for item in &mut priorities { - let (player, role, _) = item; - let empty_teams = team1.count_role(role) == 0 && team2.count_role(role) == 0; + if let (Some(team1), Some(team2)) = (best_team1, best_team2) { + Some((team1, team2)) + } else { + None + } +} - let role_diff_rank = 1.0 + { - if empty_teams { - 0.0 - } else { - (player.ranks[role] - (team1.full_rank_role(role) - team2.full_rank_role(role)).abs()).abs() - } - } as f32; +fn calculate_priorities(players: &[Player]) -> Vec { + let mut priorities = Vec::new(); - let sum_average_rank = team1.average_rank_role(role) + team2.average_rank_role(role); - let role_diff_avg_rank = 1.0 + { - if empty_teams { - let filtered_players = players.iter().filter(|player| player.priority_roles.contains(&Some(role.clone())) || player.flex).collect::>(); - (player.ranks[role] - filtered_players.iter().map(|player| player.ranks[role]).sum::() / filtered_players.len() as f32).abs() - } else { - (player.ranks[role] - sum_average_rank).abs() - } - } as f32; - - let team_rank_difference = (team1.average_rank_role(role) - team2.average_rank_role(role)).abs(); - let rank_difference_weight = 1.0 + 1.5 * team_rank_difference; - - let complex_coefficient = role_diff_rank * role_diff_avg_rank * rank_difference_weight; - item.2 *= player.ranks[role] / complex_coefficient; + for (i, player) in players.iter().enumerate() { + for (role, priority) in player.base_priority() { + priorities.push(PlayerRoleEntry { index: i, role, priority }); + } } priorities diff --git a/src/mixer/mod.rs b/src/mixer/mod.rs index 09e5550..32c60e1 100644 --- a/src/mixer/mod.rs +++ b/src/mixer/mod.rs @@ -2,3 +2,4 @@ pub mod mixer; pub mod role; pub mod player; pub mod team; +pub mod rating; diff --git a/src/mixer/player.rs b/src/mixer/player.rs index 170f322..fd7ed23 100644 --- a/src/mixer/player.rs +++ b/src/mixer/player.rs @@ -1,13 +1,17 @@ use std::collections::HashMap; +use serenity::model::id::UserId; use crate::database::models::player::Model; +use crate::mixer::rating::Rating; use crate::mixer::role::Role; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct Player { pub(crate) id: i32, - pub(crate) name: String, + pub(crate) discord_id: UserId, + pub(crate) bn_name: String, + pub(crate) bn_tag: String, - pub(crate) ranks: HashMap, + pub(crate) ranks: HashMap, pub(crate) flex: bool, pub(crate) priority_roles: Vec>, } @@ -17,10 +21,21 @@ impl Player { pub fn new(model: Model) -> Self { Self { id: model.id, - name: model.bn_name, - ranks: vec![(Role::Tank, model.tank), (Role::Dps, model.dps), (Role::Support, model.support)].into_iter().collect(), + discord_id: UserId::from(model.discord_id as u64), + bn_name: model.bn_name, + bn_tag: model.bn_tag, + + ranks: vec![ + (Role::Tank, Rating::new(model.tank_rating, model.tank_rd, model.tank_volatility)), + (Role::Dps, Rating::new(model.dps_rating, model.dps_rd, model.dps_volatility)), + (Role::Support, Rating::new(model.support_rating, model.support_rd, model.support_volatility)) + ].into_iter().collect(), + flex: model.flex, - priority_roles: vec![model.primary_role, model.secondary_role, model.tertiary_role].into_iter().map(|role| { + priority_roles: vec![ + model.primary_role, + model.secondary_role, model.tertiary_role + ].into_iter().map(|role| { match role { -1 => None, _ => Some(Role::from(role)) @@ -29,24 +44,43 @@ impl Player { } } + pub fn to_model(self) -> Model { + Model { + id: self.id, + discord_id: self.discord_id.0 as i64, + bn_name: self.bn_name, + bn_tag: self.bn_tag, + tank_rating: self.ranks.get(&Role::Tank).unwrap().value, + tank_rd: self.ranks.get(&Role::Tank).unwrap().rd, + tank_volatility: self.ranks.get(&Role::Tank).unwrap().volatility, + dps_rating: self.ranks.get(&Role::Dps).unwrap().value, + dps_rd: self.ranks.get(&Role::Dps).unwrap().rd, + dps_volatility: self.ranks.get(&Role::Dps).unwrap().volatility, + support_rating: self.ranks.get(&Role::Support).unwrap().value, + support_rd: self.ranks.get(&Role::Support).unwrap().rd, + support_volatility: self.ranks.get(&Role::Support).unwrap().volatility, + flex: self.flex, + primary_role: Role::option_to_i32(self.priority_roles.get(0).unwrap().clone()), + secondary_role: Role::option_to_i32(self.priority_roles.get(1).unwrap().clone()), + tertiary_role: Role::option_to_i32(self.priority_roles.get(2).unwrap().clone()), + } + } + pub fn base_priority(&self) -> HashMap { let mut priorities = HashMap::new(); let priority_points = 100.0; if self.flex { for role in Role::iter() { - priorities.insert(role, (priority_points / self.priority_roles.len() as f32) as f32); + priorities.insert(role, 1.5 * (priority_points / (Role::iter().count()) as f32)); } return priorities; } - let count = self.priority_roles.iter().filter(|role| role.is_some()).count() as f32; - let denominator = count * (count + 1.0) * (2.0 * count + 1.0) / 6.0; - for (i, role) in self.priority_roles.iter().enumerate() { if let Some(role) = role { - priorities.insert(role.clone(), priority_points * (count - i as f32)*(count - i as f32) / denominator as f32); + priorities.insert(role.clone(), priority_points / (i + 1) as f32); } } diff --git a/src/mixer/rating.rs b/src/mixer/rating.rs new file mode 100644 index 0000000..b1bf416 --- /dev/null +++ b/src/mixer/rating.rs @@ -0,0 +1,102 @@ +use std::cmp::Ordering; +use std::iter::Sum; +use std::ops::{Add, AddAssign, Div}; +use crate::algorithm::glicko2; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rating { + pub value: f32, + pub rd: f32, + pub volatility: f32, +} + +impl Eq for Rating {} + +impl Ord for Rating { + fn cmp(&self, other: &Self) -> Ordering { + self.value.partial_cmp(&other.value).unwrap_or(Ordering::Equal) + } +} + +impl PartialOrd for Rating { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Default for Rating { + fn default() -> Self { + Self { + value: 2500.0, + rd: 580.0, + volatility: 0.06, + } + } +} + +impl Add for Rating { + type Output = Self; + + fn add(self, other: Self) -> Self::Output { + Self { + value: self.value + other.value, + rd: self.rd + other.rd, + volatility: self.volatility + other.volatility, + } + } +} + +impl AddAssign for Rating { + fn add_assign(&mut self, other: Self) { + self.value += other.value; + self.rd += other.rd; + self.volatility += other.volatility; + } +} + +impl Div for Rating { + type Output = Self; + + fn div(self, rhs: f32) -> Self::Output { + Self { + value: self.value / rhs, + rd: self.rd / rhs, + volatility: self.volatility / rhs, + } + } +} + +impl Sum for Rating { + fn sum>(iter: I) -> Self { + let mut sum = Self::zero(); + for rating in iter { + sum += rating; + } + sum + } +} + +impl Rating { + pub fn new(value: f32, rd: f32, sigma: f32) -> Self { + Self { + value, + rd, + volatility: sigma, + } + } + + pub fn zero() -> Self { + Self::new(0.0, 0.0, 0.0) + } + + pub fn new_no_sigma(value: f32, rd: f32) -> Self { + Self::new(value, rd, 0.06) + } + + pub fn update(&mut self, other: &Self, score: f32) { + let (value, rd, sigma) = glicko2::update(self.value, self.rd, self.volatility, other.value, other.rd, 5.0/3.0, score); + self.value = value; + self.rd = rd; + self.volatility = sigma; + } +} \ No newline at end of file diff --git a/src/mixer/role.rs b/src/mixer/role.rs index 42f2df4..1cad31d 100644 --- a/src/mixer/role.rs +++ b/src/mixer/role.rs @@ -21,6 +21,16 @@ impl FromStr for Role { } } +impl From for i32 { + fn from(role: Role) -> Self { + match role { + Role::Tank => 0, + Role::Dps => 1, + Role::Support => 2 + } + } +} + impl From for Role { fn from(i: i32) -> Self { match i { diff --git a/src/mixer/team.rs b/src/mixer/team.rs index 50c551c..56d337c 100644 --- a/src/mixer/team.rs +++ b/src/mixer/team.rs @@ -1,10 +1,14 @@ use std::collections::HashMap; use crate::mixer::player::Player; +use crate::mixer::rating::Rating; use crate::mixer::role::Role; #[derive(Debug, Clone)] pub struct Team { - pub players: HashMap<(Role, i32), Option> + pub players: HashMap<(Role, i32), Option>, + + max_role: HashMap, + count_role: HashMap, } @@ -19,61 +23,80 @@ impl Team { } } players - } + }, + max_role: { + let mut max_role = HashMap::new(); + for role in Role::iter() { + max_role.insert(role, slots.iter().filter(|slot| **slot == role).count()); + } + max_role + }, + count_role: { + let mut count_role = HashMap::new(); + for role in Role::iter() { + count_role.insert(role, 0); + } + count_role + }, } } pub fn count(&self) -> usize { - self.players.iter().filter(|(_, player)| player.is_some()).count() + self.count_role.values().sum() } pub fn count_role(&self, role: &Role) -> usize { - self.players.iter().filter(|((r, _), player)| r == role && player.is_some()).count() + self.count_role.get(role).unwrap().clone() } - pub fn full_rank(&self) -> f32 { - self.players.iter().map(|((role, _), player)| { - if let Some(player) = player { - player.ranks.get(role).unwrap().clone() + pub fn full_rating(&self, players: &[Player]) -> Rating { + self.players.iter().map(|((role, _), index)| { + if let Some(index) = index { + players[*index].ranks.get(role).unwrap().clone() } else { - 0.0 + Rating::zero() } - }).sum::() + }).sum() } - pub fn average_rank(&self) -> f32 { + pub fn average_rating(&self, players: &[Player]) -> Rating { if self.players.len() == 0 { - return 0.0; + return Rating::zero(); } - self.full_rank() / self.players.len() as f32 + self.full_rating(players) / self.players.len() as f32 } - pub fn full_rank_role(&self, role: &Role) -> f32 { - self.players.iter().filter(|((r, _), _)| r == role).map(|((_, _), player)| { - if let Some(player) = player { - player.ranks.get(&role).unwrap().clone() + pub fn full_rating_role(&self, role: &Role, players: &[Player]) -> Rating { + self.players.iter().filter(|((r, _), _)| r == role).map(|((_, _), index)| { + if let Some(index) = index { + players[*index].ranks.get(&role).unwrap().clone() } else { - 0.0 + Rating::zero() } - }).sum::() + }).sum() } - pub fn average_rank_role(&self, role: &Role) -> f32 { + pub fn average_rating_role(&self, role: &Role, players: &[Player]) -> Rating { let count = self.players.iter().filter(|((r, _), _)| r == role).count(); if count == 0 { - return 0.0; + return Rating::zero() } - self.full_rank_role(role) / count as f32 + self.full_rating_role(role, players) / count as f32 } pub fn has_slot(&self, role: &Role) -> bool { - self.players.iter().filter(|((r, _), _)| r == role).any(|(_, player)| player.is_none()) + self.count_role(role) < *self.max_role.get(role).unwrap() } - pub fn add_player(&mut self, player: &Player, role: &Role) { - let slot = self.players.iter().filter(|((r, _), _)| r == role).find(|(_, player)| player.is_none()).unwrap().0.clone(); - self.players.insert(slot, Some(player.clone())); + pub fn add_player(&mut self, index: usize, role: &Role) { + if !self.has_slot(role) { + panic!("No slot for role {:?}", role); + } + + let count = self.count_role(role); + self.players.insert((*role, count as i32), Some(index)); + self.count_role.insert(*role, count + 1); } } \ No newline at end of file