From 0796f0be21b1d0cb93e8407effa9b2c06bc307f8 Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sat, 19 Mar 2022 21:12:38 -0600 Subject: [PATCH] General cleanup after the first game + Randomized codename order obscuring players more + Added better error handling code + Support hour + minute time increments + Refactored layout to be more clear + clippy + fmt --- Cargo.lock | 29 ++++- Cargo.toml | 5 +- src/config.rs | 38 ++++++ src/data.rs | 183 ---------------------------- src/{ => discord}/commands.rs | 221 +++++++++++++++++++--------------- src/{ => discord}/helper.rs | 112 +++++------------ src/discord/mod.rs | 2 + src/error.rs | 57 +++++++++ src/game/game_state.rs | 158 ++++++++++++++++++++++++ src/game/global_data.rs | 108 +++++++++++++++++ src/game/mod.rs | 49 ++++++++ src/game/player_data.rs | 19 +++ src/main.rs | 46 ++++--- 13 files changed, 646 insertions(+), 381 deletions(-) create mode 100644 src/config.rs delete mode 100644 src/data.rs rename src/{ => discord}/commands.rs (67%) rename src/{ => discord}/helper.rs (58%) create mode 100644 src/discord/mod.rs create mode 100644 src/error.rs create mode 100644 src/game/game_state.rs create mode 100644 src/game/global_data.rs create mode 100644 src/game/mod.rs create mode 100644 src/game/player_data.rs diff --git a/Cargo.lock b/Cargo.lock index 4cd87e5..02110d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,15 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "739f4a8db6605981345c5654f3a85b056ce52f37a39d34da03f25bf2151ea16e" +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -977,6 +986,23 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "regex" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + [[package]] name = "reqwest" version = "0.11.9" @@ -1720,11 +1746,12 @@ dependencies = [ [[package]] name = "woxlf" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "config", "rand 0.8.5", + "regex", "serde", "serenity", "structopt", diff --git a/Cargo.toml b/Cargo.toml index f142639..04d32be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "woxlf" -version = "0.1.0" +version = "0.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -8,10 +8,11 @@ edition = "2021" [dependencies] config = "0.12.0" structopt = "0.3.26" -chrono = "0.4.19" +chrono = {version="0.4.19", features=["serde"]} serde = "1.0.136" rand = "0.8.5" toml = "0.5.8" +regex = "1.5.5" [dependencies.serenity] version = "0.10.10" diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8295a49 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,38 @@ +use std::path::Path; +use std::path::PathBuf; + +use config::{Config, File}; +use serde::{Deserialize, Serialize}; +use structopt::StructOpt; + +#[derive(Debug, StructOpt)] +#[structopt(name = "WOXlf", about = "WOXlf discord bot")] +pub struct Args { + pub cfg_path: PathBuf, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct BotConfig { + pub token: String, + pub app_id: u64, + pub host_channel: u64, + pub vote_channel: u64, + pub category: u64, + pub game_state_dir: PathBuf, + pub occupation: Vec, + pub adjective: Vec, +} + +impl BotConfig { + pub fn new(config_path: &Path) -> Result { + let cfg = Config::builder() + .add_source(File::from(config_path)) + .build()?; + + cfg.try_deserialize() + } + + pub fn get_game_state_path(&self) -> PathBuf { + self.game_state_dir.join("wOxlf_data.toml") + } +} diff --git a/src/data.rs b/src/data.rs deleted file mode 100644 index f4905da..0000000 --- a/src/data.rs +++ /dev/null @@ -1,183 +0,0 @@ -use config::{Config, File}; -use serde::{Deserialize, Serialize}; -use serenity::prelude::TypeMapKey; -use std::collections::HashMap; -use std::fmt::{Display, Formatter}; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use structopt::StructOpt; -use tokio::sync::Mutex; - -#[derive(Debug, StructOpt)] -#[structopt(name = "WOXlf", about = "WOXlf discord bot")] -pub struct Args { - pub cfg_path: PathBuf, -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct BotConfig { - pub token: String, - pub app_id: u64, - pub host_channel: u64, - pub vote_channel: u64, - pub category: u64, - pub game_state_dir: PathBuf, - pub occupation: Vec, - pub adjective: Vec, -} - -impl BotConfig { - pub fn new(config_path: &Path) -> Result { - let cfg = Config::builder() - .add_source(File::from(config_path)) - .build()?; - - cfg.try_deserialize() - } - - pub fn get_game_state_path(&self) -> PathBuf { - self.game_state_dir.join("wOxlf_data.toml") - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] -pub enum Phase { - Day, - Night, -} - -impl Default for Phase { - fn default() -> Self { - Self::Night - } -} - -impl Display for Phase { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let phase_name = match self { - Self::Day => "Day", - Self::Night => "Night", - }; - - write!(f, "{}", phase_name) - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default, Hash)] -pub struct PlayerData { - pub channel: u64, - pub discord_id: u64, - pub codename: String, - pub vote_target: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct GameState { - pub phase_number: u64, - pub current_phase: Phase, - pub phase_end_time: u64, - pub player_data: Vec, -} - -impl GameState { - pub fn codename_exists(&self, codename: &str) -> bool { - self.player_data - .iter() - .any(|data| data.codename.to_lowercase() == codename) - } - - pub fn clear(&mut self) { - self.player_data.clear(); - self.current_phase = Phase::Night; - self.phase_end_time = 0; - self.phase_number = 1; - } - - pub fn get_player_from_channel(&self, channel_id: u64) -> Option<&PlayerData> { - self.player_data.iter().find(|p| p.channel == channel_id) - } - - pub fn get_player_from_discord_id(&self, discord_id: u64) -> Option<&PlayerData> { - self.player_data.iter().find(|p| p.discord_id == discord_id) - } - - pub fn get_player_from_channel_mut(&mut self, channel_id: u64) -> Option<&mut PlayerData> { - self.player_data - .iter_mut() - .find(|p| p.channel == channel_id) - } - - pub fn get_player_by_codename(&self, codename: &str) -> Option { - self.player_data - .iter() - .find(|p| p.codename.to_lowercase() == codename.to_lowercase()) - .cloned() - } - - pub fn next_phase(&mut self) { - if self.current_phase == Phase::Night { - self.current_phase = Phase::Day - } else { - self.phase_number += 1; - self.current_phase = Phase::Night - } - - for mut player in &mut self.player_data { - player.vote_target = None - } - } - - pub fn get_phase_end_time(&self) -> String { - format!("", self.phase_end_time) - } - - pub fn get_phase_countdown(&self) -> String { - format!("", self.phase_end_time) - } - - pub fn add_time_to_phase(&mut self, hours: u64) { - self.phase_end_time += hours * 60 * 60; - } - - pub fn get_vote_tallies(&self) -> HashMap { - let mut vote_set: HashMap = HashMap::new(); - - for player in &self.player_data { - if let Some(vote_target) = player.vote_target { - let target = self.get_player_from_discord_id(vote_target); - if let Some(target) = target { - *vote_set.entry(target.codename.clone()).or_insert(0) += 1; - } - } - } - - vote_set - } -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct GlobalData { - pub cfg: BotConfig, - pub game_state: GameState, -} - -impl GlobalData { - pub fn new(cfg: BotConfig) -> Self { - Self { - cfg, - game_state: GameState::default(), - } - } -} - -impl TypeMapKey for GlobalData { - type Value = Arc>; -} - -#[derive(Debug, Deserialize, Serialize, Clone)] -pub enum MessageSource { - Player(u64), - Host, - Automated, -} diff --git a/src/commands.rs b/src/discord/commands.rs similarity index 67% rename from src/commands.rs rename to src/discord/commands.rs index 8c9a72d..43ba8b5 100644 --- a/src/commands.rs +++ b/src/discord/commands.rs @@ -1,21 +1,25 @@ -use serenity::framework::standard::macros::help; -use serenity::framework::standard::macros::{command, group}; +use std::collections::HashSet; + +use rand::prelude::SliceRandom; +use rand::thread_rng; +use serenity::framework::standard::macros::{command, group, help, hook}; use serenity::framework::standard::{ help_commands, Args, CommandGroup, CommandResult, HelpOptions, }; use serenity::framework::StandardFramework; +use serenity::model::guild::Member; use serenity::model::id::ChannelId; use serenity::model::prelude::{Message, UserId}; use serenity::prelude::Context; use serenity::utils::MessageBuilder; -use crate::data::{GlobalData, MessageSource, Phase}; -use crate::helper; -use crate::helper::{ - build_system_message, clear_game_state, get_phase_end_timestamp, print_game_status, - save_game_state, send_msg_to_player_channels, +use crate::discord::helper::{ + add_user_to_game, build_system_message, parse_duration_arg, send_msg_to_player_channels, }; -use std::collections::HashSet; +use crate::error::{Result, WoxlfError}; +use crate::game::global_data::GlobalData; +use crate::game::MessageSource; +use crate::game::Phase; #[group] #[commands(start, say, end, broadcast, next_phase, terminate, add_time)] @@ -30,38 +34,54 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let mut data = ctx.data.write().await; let global_data = data.get_mut::().unwrap(); let guild = msg.guild(&ctx.cache).await.unwrap(); - let mut global_data = global_data.lock().await; - clear_game_state(&mut global_data).unwrap(); + let duration = parse_duration_arg(&mut args).await?; + + global_data.start_game(Phase::Night, duration.into()); + + let players: Result> = args + .iter::() + .flatten() + .map(|discord_id| { + let discord_id = match discord_id.parse::() { + Ok(discord_id) => discord_id, + Err(_) => { + return Err(WoxlfError::DiscordIdParseError(discord_id)); + } + }; + + if let Some(discord_user) = guild.members.get(&UserId::from(discord_id)) { + Ok(discord_user) + } else { + Err(WoxlfError::DiscordIdParseError(discord_id.to_string())) + } + }) + .collect(); + + let mut players = match players { + Ok(players) => players, + Err(e) => { + let err_msg = match e { + WoxlfError::DiscordIdParseError(e) => { + format!("Error parsing '{}' as a discord user id.", e) + } + _ => "Internal bot error".to_string(), + }; + + msg.reply(&ctx.http, err_msg).await?; - let duration = match args.single::() { - Ok(d) => d, - Err(_) => { - msg.reply(&ctx.http, "Error parsing phase duration!") - .await - .unwrap(); return Ok(()); } }; - global_data.game_state.phase_end_time = get_phase_end_timestamp(duration); + players.shuffle(&mut thread_rng()); - for player in args.iter::().flatten() { - if let Some(discord_user) = guild.members.get(&UserId::from(player)) { - helper::add_user_to_game(ctx, &guild, &mut global_data, discord_user).await?; - } else { - msg.reply( - &ctx.http, - format!("User {} is invalid or not in this server!", player), - ) - .await?; - - break; - } + for player in players { + add_user_to_game(ctx, &guild, &mut global_data, player).await?; } - save_game_state(&global_data).unwrap(); + global_data.save_game_state().unwrap(); Ok(()) } @@ -76,7 +96,7 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult { let mut global_data = global_data.lock().await; - for player_data in &global_data.game_state.player_data { + for player_data in &global_data.game_state_mut()?.player_data { let channel = guild .channels .get(&ChannelId::from(player_data.channel)) @@ -85,7 +105,7 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult { channel.delete(&ctx.http).await?; } - clear_game_state(&mut global_data).unwrap(); + global_data.clear_game_state()?; msg.reply(&ctx.http, "Game ended!").await.unwrap(); Ok(()) @@ -95,24 +115,24 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult { #[only_in(guilds)] #[allowed_roles("wolfx host")] async fn say(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); + let mut data = ctx.data.write().await; + let global_data = data.get_mut::().unwrap(); let guild = msg.guild(&ctx.cache).await.unwrap(); - let global_data = global_data.lock().await; + let mut global_data = global_data.lock().await; let msg = format!("**wOxlf **> {}", args.rest()); send_msg_to_player_channels( ctx, &guild, - &global_data, + &mut global_data, MessageSource::Host, &msg, None, false, ) - .await; + .await?; Ok(()) } @@ -121,24 +141,24 @@ async fn say(ctx: &Context, msg: &Message, args: Args) -> CommandResult { #[only_in(guilds)] #[allowed_roles("wolfx host")] async fn broadcast(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); + let mut data = ctx.data.write().await; + let global_data = data.get_mut::().unwrap(); let guild = msg.guild(&ctx.cache).await.unwrap(); - let global_data = global_data.lock().await; + let mut global_data = global_data.lock().await; let msg = build_system_message(args.rest()); send_msg_to_player_channels( ctx, &guild, - &global_data, + &mut global_data, MessageSource::Host, &msg, None, true, ) - .await; + .await?; Ok(()) } @@ -153,24 +173,14 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu let mut global_data = global_data.lock().await; - let duration = match args.single::() { - Ok(d) => d, - Err(_) => { - msg.reply(&ctx.http, "Error parsing phase duration!") - .await - .unwrap(); - return Ok(()); - } - }; + let duration = parse_duration_arg(&mut args).await?; - global_data.game_state.next_phase(); - - global_data.game_state.phase_end_time = get_phase_end_timestamp(duration); + global_data.game_state_mut()?.next_phase(duration.into()); let broadcast = MessageBuilder::new() .push_line(args.rest()) .push_line("") - .push(print_game_status(&global_data.game_state)) + .push(global_data.print_game_status()) .build(); let broadcast = build_system_message(&broadcast); @@ -178,15 +188,15 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu send_msg_to_player_channels( ctx, &guild, - &global_data, + &mut global_data, MessageSource::Host, &broadcast, None, true, ) - .await; + .await?; - if global_data.game_state.current_phase == Phase::Day { + if global_data.game_state_mut()?.current_phase == Phase::Day { let vote_channel = guild .channels .get(&ChannelId::from(global_data.cfg.vote_channel)) @@ -195,24 +205,22 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu .send_message(&ctx.http, |m| { m.content(format!( "**DAY {} VOTES:**", - global_data.game_state.phase_number + global_data.game_state_mut().unwrap().phase_number )) }) - .await - .unwrap(); + .await?; } msg.reply( &ctx.http, format!( "Phase has been cycled to {}.", - &global_data.game_state.current_phase + &global_data.game_state_mut()?.current_phase ), ) - .await - .unwrap(); + .await?; - save_game_state(&global_data).unwrap(); + global_data.save_game_state()?; Ok(()) } @@ -229,13 +237,13 @@ async fn terminate(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let target = args.rest().to_lowercase(); let index = global_data - .game_state + .game_state_mut()? .player_data .iter() .position(|p| p.codename.to_lowercase() == target); if let Some(index) = index { - let player = global_data.game_state.player_data.remove(index); + let player = global_data.game_state_mut()?.player_data.remove(index); let player_channel = guild .channels @@ -256,7 +264,7 @@ async fn terminate(ctx: &Context, msg: &Message, args: Args) -> CommandResult { .unwrap(); } - save_game_state(&global_data).unwrap(); + global_data.save_game_state().unwrap(); Ok(()) } @@ -270,22 +278,16 @@ async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult let mut global_data = global_data.lock().await; - let duration = match args.single::() { - Ok(d) => d, - Err(_) => { - msg.reply(&ctx.http, "Error parsing phase duration!") - .await - .unwrap(); - return Ok(()); - } - }; + let duration = parse_duration_arg(&mut args).await?; - global_data.game_state.add_time_to_phase(duration); + global_data + .game_state_mut()? + .add_time_to_phase(duration.into()); let broadcast = MessageBuilder::new() .push_line("EXPERIMENT PHASE HAS BEEN EXTENDED!!!") .push_line("") - .push(print_game_status(&global_data.game_state)) + .push(global_data.print_game_status()) .build(); let broadcast = build_system_message(&broadcast); @@ -293,19 +295,19 @@ async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult send_msg_to_player_channels( ctx, &guild, - &global_data, + &mut global_data, MessageSource::Host, &broadcast, None, true, ) - .await; + .await?; msg.reply(&ctx.http, "Phase has been updated") .await .unwrap(); - save_game_state(&global_data).unwrap(); + global_data.save_game_state().unwrap(); Ok(()) } @@ -323,7 +325,7 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let mut global_data = global_data.lock().await; - if global_data.game_state.current_phase != Phase::Day { + if global_data.game_state_mut()?.current_phase != Phase::Day { msg.reply( &ctx.http, "You can only select subject for termination during the day!", @@ -334,11 +336,13 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { } if global_data - .game_state + .game_state_mut()? .get_player_from_channel(msg.channel_id.0) .is_some() { - let target_player = global_data.game_state.get_player_by_codename(args.rest()); + let target_player = global_data + .game_state_mut()? + .get_player_by_codename(args.rest()); if let Some(target_player) = target_player { let vote_channel = guild @@ -347,10 +351,11 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { .unwrap(); let player_data = global_data - .game_state + .game_state_mut()? .get_player_from_channel_mut(msg.channel_id.0) .unwrap(); - player_data.vote_target = Some(target_player.discord_id); + + player_data.cast_vote(target_player.discord_id); vote_channel .send_message(&ctx.http, |m| { @@ -373,7 +378,7 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { .unwrap(); } - save_game_state(&global_data).unwrap(); + global_data.save_game_state().unwrap(); Ok(()) } @@ -381,25 +386,25 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { #[only_in(guilds)] #[description = "Get the game status. $status"] async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); + let mut data = ctx.data.write().await; + let global_data = data.get_mut::().unwrap(); - let global_data = global_data.lock().await; + let mut global_data = global_data.lock().await; let mut msg_builder = MessageBuilder::new(); - msg_builder.push(print_game_status(&global_data.game_state)); + msg_builder.push(global_data.print_game_status()); - if global_data.game_state.current_phase == Phase::Day { + if global_data.game_state_mut()?.current_phase == Phase::Day { msg_builder.push_line(""); - let vote_tallies = global_data.game_state.get_vote_tallies(); + let vote_tallies = global_data.game_state_mut()?.get_vote_tallies(); if vote_tallies.is_empty() { msg_builder.push_line("NO TERMINATION VOTES HAVE BEEN CAST"); } else { msg_builder.push_line("TERMINATION VOTE TALLIES:"); - for (player, tally) in global_data.game_state.get_vote_tallies() { + for (player, tally) in global_data.game_state_mut()?.get_vote_tallies() { msg_builder.push_line(format!("{}: {}", player, tally)); } } @@ -423,7 +428,7 @@ async fn players(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { msg_builder.push_line("Test Subjects:"); - for player in &global_data.game_state.player_data { + for player in &global_data.game_state()?.player_data { msg_builder.push("* ").push(&player.codename); if msg.channel_id.0 == global_data.cfg.host_channel { @@ -445,7 +450,7 @@ async fn players(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { #[command_not_found_text = "Could not find: `{}`."] #[max_levenshtein_distance(3)] #[indention_prefix = "+"] -#[lacking_role = "Nothing"] +#[lacking_role = "Strike"] #[wrong_channel = "Strike"] async fn help( context: &Context, @@ -459,10 +464,32 @@ async fn help( Ok(()) } +#[hook] +async fn handle_errors( + ctx: &Context, + msg: &Message, + command_name: &str, + command_result: CommandResult, +) { + match command_result { + Ok(()) => println!("Successfully processed command '{}'", command_name), + Err(err) => { + let reply_msg = format!( + "Command '{}' returned an error. {}", + command_name, + err.to_string() + ); + println!("{}", reply_msg); + msg.reply(&ctx.http, reply_msg).await.unwrap(); + } + }; +} + pub fn command_framework() -> StandardFramework { StandardFramework::new() .configure(|c| c.prefix('$')) .group(&HOST_GROUP) .group(&PLAYER_GROUP) .help(&HELP) + .after(handle_errors) } diff --git a/src/helper.rs b/src/discord/helper.rs similarity index 58% rename from src/helper.rs rename to src/discord/helper.rs index 038b993..63ae467 100644 --- a/src/helper.rs +++ b/src/discord/helper.rs @@ -1,31 +1,29 @@ -use std::fs::File; -use std::io::prelude::*; - -use rand::Rng; -use serenity::framework::standard::CommandResult; +use serenity::client::Context; +use serenity::framework::standard::Args; +use serenity::http::AttachmentType; use serenity::model::channel::{PermissionOverwrite, PermissionOverwriteType}; -use serenity::model::guild::Member; -use serenity::model::id::UserId; -use serenity::model::prelude::ChannelId; -use serenity::model::prelude::Guild; +use serenity::model::guild::{Guild, Member}; +use serenity::model::id::{ChannelId, UserId}; use serenity::model::Permissions; -use serenity::prelude::Context; use serenity::utils::MessageBuilder; -use crate::data::{BotConfig, GameState, GlobalData, MessageSource, PlayerData}; -use serenity::http::AttachmentType; -use std::time::UNIX_EPOCH; +use crate::error::WoxlfError; +use crate::game::game_state::PhaseDuration; +use crate::game::global_data::GlobalData; +use crate::game::player_data::PlayerData; +use crate::game::MessageSource; +use crate::{error, game}; pub async fn send_msg_to_player_channels( ctx: &Context, guild: &Guild, - global_data: &GlobalData, + global_data: &mut GlobalData, msg_source: MessageSource, msg: &str, attachment: Option>>, pin: bool, -) { - for player_data in &global_data.game_state.player_data { +) -> error::Result<()> { + for player_data in &global_data.game_state_mut()?.player_data { if let MessageSource::Player(channel_id) = msg_source { if channel_id == player_data.channel { continue; @@ -47,12 +45,11 @@ pub async fn send_msg_to_player_channels( m }) - .await - .unwrap(); + .await?; if pin { // pin system messages - msg.pin(&ctx.http).await.unwrap(); + msg.pin(&ctx.http).await?; } } @@ -64,7 +61,7 @@ pub async fn send_msg_to_player_channels( let source = match msg_source { MessageSource::Player(channel_id) => { let discord_id = global_data - .game_state + .game_state_mut()? .get_player_from_channel(channel_id) .unwrap() .discord_id; @@ -89,60 +86,21 @@ pub async fn send_msg_to_player_channels( m }) - .await - .unwrap(); -} - -pub fn save_game_state(global_data: &GlobalData) -> std::io::Result<()> { - let s = toml::to_string_pretty(&global_data.game_state).unwrap(); - - let mut file = File::create(global_data.cfg.get_game_state_path())?; - - file.write_all(s.as_bytes()) -} - -pub fn get_game_state(global_data: &mut GlobalData) -> std::io::Result<()> { - let mut file = File::open(global_data.cfg.get_game_state_path())?; - - let mut data = String::new(); - - file.read_to_string(&mut data)?; - - global_data.game_state = toml::from_str(&data).unwrap(); + .await?; Ok(()) } -pub fn clear_game_state(global_data: &mut GlobalData) -> std::io::Result<()> { - global_data.game_state.clear(); - - let state_path = global_data.cfg.get_game_state_path(); - if state_path.exists() { - std::fs::remove_file(global_data.cfg.get_game_state_path())?; - } - - Ok(()) -} - -pub fn generate_codename(config: &BotConfig) -> String { - let mut rng = rand::thread_rng(); - - let occupation = &config.occupation[rng.gen_range(0..config.occupation.len())]; - let adj = &config.adjective[rng.gen_range(0..config.adjective.len())]; - - format!("{} {}", adj, occupation) -} - pub async fn add_user_to_game( ctx: &Context, guild: &Guild, global_data: &mut GlobalData, discord_user: &Member, -) -> CommandResult { - let mut codename = generate_codename(&global_data.cfg); +) -> error::Result<()> { + let mut codename = game::generate_codename(&global_data.cfg); - while global_data.game_state.codename_exists(&codename) { - codename = generate_codename(&global_data.cfg); + while global_data.game_state_mut()?.codename_exists(&codename) { + codename = game::generate_codename(&global_data.cfg); } let channel = guild @@ -175,7 +133,7 @@ pub async fn add_user_to_game( .push_line("") .push("SUBJECT CODENAME: ") .push_line(&codename) - .push(print_game_status(&global_data.game_state)) + .push(global_data.print_game_status()) ) }).await?; @@ -188,7 +146,7 @@ pub async fn add_user_to_game( codename, }; - global_data.game_state.player_data.push(player_data); + global_data.game_state_mut()?.player_data.push(player_data); Ok(()) } @@ -203,21 +161,9 @@ pub fn build_system_message(msg: &str) -> String { .build() } -pub fn print_game_status(game_state: &GameState) -> String { - MessageBuilder::new() - .push_line(format!( - "CURRENT EXPERIMENT PHASE: {} {}", - game_state.current_phase, game_state.phase_number - )) - .push_line(format!( - "PHASE END TIME: {}", - game_state.get_phase_end_time() - )) - .push_line(format!("PHASE ENDING {}", game_state.get_phase_countdown())) - .build() -} - -pub fn get_phase_end_timestamp(hours: u64) -> u64 { - let end_time = std::time::SystemTime::now() + std::time::Duration::from_secs(hours * 60 * 60); - end_time.duration_since(UNIX_EPOCH).unwrap().as_secs() +pub async fn parse_duration_arg(args: &mut Args) -> error::Result { + match args.single::() { + Ok(d) => Ok(d), + Err(_) => Err(WoxlfError::DurationParseError), + } } diff --git a/src/discord/mod.rs b/src/discord/mod.rs new file mode 100644 index 0000000..c31adff --- /dev/null +++ b/src/discord/mod.rs @@ -0,0 +1,2 @@ +pub mod commands; +pub mod helper; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..1f62916 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,57 @@ +use serenity::prelude::SerenityError; +use std::fmt::{Display, Formatter}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum WoxlfError { + IOError(std::io::Error), + GameStateParseError(toml::de::Error), + GameStateSerializeError(toml::ser::Error), + DurationParseError, + SerenityError(serenity::Error), + DiscordIdParseError(String), + GameNotInProgress, +} + +impl std::error::Error for WoxlfError {} + +impl Display for WoxlfError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let msg = match self { + WoxlfError::IOError(e) => format!("IO Error: {}", e), + WoxlfError::GameStateParseError(e) => format!("Game State Parse Error: {}", e), + WoxlfError::GameStateSerializeError(e) => format!("Game State Serialize Error: {}", e), + WoxlfError::DurationParseError => "Unable to parse duration".to_string(), + WoxlfError::SerenityError(e) => format!("Serenity error: {}", e), + WoxlfError::DiscordIdParseError(e) => format!("Unable to parse player id {}", e), + WoxlfError::GameNotInProgress => "A game is not currently in progress".to_string(), + }; + + write!(f, "Woxlf Error: {}", msg) + } +} + +impl From for WoxlfError { + fn from(err: SerenityError) -> Self { + Self::SerenityError(err) + } +} + +impl From for WoxlfError { + fn from(err: std::io::Error) -> Self { + Self::IOError(err) + } +} + +impl From for WoxlfError { + fn from(err: toml::de::Error) -> Self { + Self::GameStateParseError(err) + } +} + +impl From for WoxlfError { + fn from(err: toml::ser::Error) -> Self { + Self::GameStateSerializeError(err) + } +} diff --git a/src/game/game_state.rs b/src/game/game_state.rs new file mode 100644 index 0000000..cd44121 --- /dev/null +++ b/src/game/game_state.rs @@ -0,0 +1,158 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use chrono::{DateTime, Duration, Utc}; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use crate::error::WoxlfError; +use crate::game::player_data::PlayerData; +use crate::game::Phase; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct GameState { + pub phase_number: u64, + pub current_phase: Phase, + pub starting_phase: Phase, + pub phase_end_time: DateTime, + pub player_data: Vec, +} + +impl GameState { + pub fn new(starting_phase: Phase, starting_phase_duration: Duration) -> Self { + let phase_end_time = Utc::now() + starting_phase_duration; + Self { + phase_number: 1, + current_phase: starting_phase, + phase_end_time, + player_data: vec![], + starting_phase, + } + } + + pub fn codename_exists(&self, codename: &str) -> bool { + let codename = codename.to_lowercase(); + + self.player_data.iter().any(|data| { + let player_codename = data.codename.to_lowercase(); + let adj_occupation: Vec<&str> = player_codename.split(' ').collect(); + + codename.contains(adj_occupation[1]) + }) + } + + pub fn get_player_from_channel(&self, channel_id: u64) -> Option<&PlayerData> { + self.player_data.iter().find(|p| p.channel == channel_id) + } + + pub fn get_player_from_discord_id(&self, discord_id: u64) -> Option<&PlayerData> { + self.player_data.iter().find(|p| p.discord_id == discord_id) + } + + pub fn get_player_from_channel_mut(&mut self, channel_id: u64) -> Option<&mut PlayerData> { + self.player_data + .iter_mut() + .find(|p| p.channel == channel_id) + } + + pub fn get_player_by_codename(&self, codename: &str) -> Option { + self.player_data + .iter() + .find(|p| p.codename.to_lowercase() == codename.to_lowercase()) + .cloned() + } + + pub fn next_phase(&mut self, duration: Duration) { + if self.current_phase == Phase::Night { + self.current_phase = Phase::Day + } else { + self.current_phase = Phase::Night + } + + if self.current_phase == self.starting_phase { + self.phase_number += 1; + } + + for player in &mut self.player_data { + player.clear() + } + + self.set_phase_end_time(duration); + } + + pub fn set_phase_end_time(&mut self, duration: Duration) { + self.phase_end_time = Utc::now() + duration; + } + + pub fn get_phase_end_time(&self) -> String { + format!("", self.phase_end_time.timestamp()) + } + + pub fn get_phase_countdown(&self) -> String { + format!("", self.phase_end_time.timestamp()) + } + + pub fn add_time_to_phase(&mut self, duration: Duration) { + self.phase_end_time = self.phase_end_time + duration; + } + + pub fn get_vote_tallies(&self) -> HashMap { + let mut vote_set: HashMap = HashMap::new(); + + for player in &self.player_data { + if let Some(vote_target) = player.vote_target { + let target = self.get_player_from_discord_id(vote_target); + if let Some(target) = target { + *vote_set.entry(target.codename.clone()).or_insert(0) += 1; + } + } + } + + vote_set + } +} + +#[derive(Debug, Clone)] +pub struct PhaseDuration(Duration); + +impl From for Duration { + fn from(dur: PhaseDuration) -> Self { + dur.0 + } +} + +impl FromStr for PhaseDuration { + type Err = WoxlfError; + + fn from_str(s: &str) -> std::result::Result { + let re = Regex::new(r"(((?P[0-9]*)h)?((?P[0-9]*)m)?)").unwrap(); + + let mut duration = Duration::hours(0); + + if re.is_match(s) { + let cap = re.captures_iter(s).next().unwrap(); + + if let Some(hour_str) = cap.name("hour") { + let hours: i64 = hour_str + .as_str() + .parse() + .map_err(|_| WoxlfError::DurationParseError)?; + + duration = duration + Duration::hours(hours); + } + + if let Some(minute_str) = cap.name("min") { + let minutes: i64 = minute_str + .as_str() + .parse() + .map_err(|_| WoxlfError::DurationParseError)?; + + duration = duration + Duration::minutes(minutes); + } + + Ok(PhaseDuration(duration)) + } else { + Err(WoxlfError::DurationParseError) + } + } +} diff --git a/src/game/global_data.rs b/src/game/global_data.rs new file mode 100644 index 0000000..4287847 --- /dev/null +++ b/src/game/global_data.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serenity::prelude::{Mutex, TypeMapKey}; +use std::fs::File; +use std::io::{Read, Write}; + +use crate::config::BotConfig; +use crate::error::{Result, WoxlfError}; +use crate::game::game_state::GameState; +use crate::game::Phase; +use chrono::Duration; +use serenity::utils::MessageBuilder; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct GlobalData { + pub cfg: BotConfig, + pub game_state: Option, +} + +impl GlobalData { + pub fn new(cfg: BotConfig) -> Self { + Self { + cfg, + game_state: None, + } + } + + pub fn start_game(&mut self, starting_phase: Phase, starting_phase_duration: Duration) { + self.game_state = Some(GameState::new(starting_phase, starting_phase_duration)) + } + + pub fn save_game_state(&mut self) -> Result<()> { + if let Some(game_state) = &mut self.game_state { + let s = toml::to_string_pretty(game_state)?; + + let mut file = File::create(self.cfg.get_game_state_path())?; + + file.write_all(s.as_bytes())?; + + Ok(()) + } else { + Err(WoxlfError::GameNotInProgress) + } + } + + pub fn game_state_exists(&self) -> bool { + self.cfg.get_game_state_path().exists() + } + + pub fn load_game_state(&mut self) -> Result<()> { + let mut file = File::open(self.cfg.get_game_state_path())?; + + let mut data = String::new(); + + file.read_to_string(&mut data)?; + + self.game_state = Some(toml::from_str(&data)?); + + Ok(()) + } + + pub fn game_state_mut(&mut self) -> Result<&mut GameState> { + match &mut self.game_state { + None => Err(WoxlfError::GameNotInProgress), + Some(game_state) => Ok(game_state), + } + } + + pub fn game_state(&self) -> Result<&GameState> { + match &self.game_state { + None => Err(WoxlfError::GameNotInProgress), + Some(game_state) => Ok(game_state), + } + } + + pub fn clear_game_state(&mut self) -> Result<()> { + self.game_state = None; + + if self.game_state_exists() { + std::fs::remove_file(self.cfg.get_game_state_path())?; + } + + Ok(()) + } + + pub fn print_game_status(&self) -> String { + if let Some(game_state) = &self.game_state { + MessageBuilder::new() + .push_line(format!( + "CURRENT EXPERIMENT PHASE: {} {}", + game_state.current_phase, game_state.phase_number + )) + .push_line(format!( + "PHASE END TIME: {}", + game_state.get_phase_end_time() + )) + .push_line(format!("PHASE ENDING {}", game_state.get_phase_countdown())) + .build() + } else { + "Game not in progress.".to_string() + } + } +} + +impl TypeMapKey for GlobalData { + type Value = Arc>; +} diff --git a/src/game/mod.rs b/src/game/mod.rs new file mode 100644 index 0000000..37fb959 --- /dev/null +++ b/src/game/mod.rs @@ -0,0 +1,49 @@ +use std::fmt::{Display, Formatter}; + +use rand::Rng; +use serde::{Deserialize, Serialize}; + +use crate::config::BotConfig; + +pub mod game_state; +pub mod global_data; +pub mod player_data; + +#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, Copy)] +pub enum Phase { + Day, + Night, +} + +impl Default for Phase { + fn default() -> Self { + Self::Night + } +} + +impl Display for Phase { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let phase_name = match self { + Self::Day => "Day", + Self::Night => "Night", + }; + + write!(f, "{}", phase_name) + } +} + +pub fn generate_codename(config: &BotConfig) -> String { + let mut rng = rand::thread_rng(); + + let occupation = &config.occupation[rng.gen_range(0..config.occupation.len())]; + let adj = &config.adjective[rng.gen_range(0..config.adjective.len())]; + + format!("{} {}", adj, occupation) +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub enum MessageSource { + Player(u64), + Host, + Automated, +} diff --git a/src/game/player_data.rs b/src/game/player_data.rs new file mode 100644 index 0000000..dff2039 --- /dev/null +++ b/src/game/player_data.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone, Default, Hash)] +pub struct PlayerData { + pub channel: u64, + pub discord_id: u64, + pub codename: String, + pub vote_target: Option, +} + +impl PlayerData { + pub fn cast_vote(&mut self, vote_target_id: u64) { + self.vote_target = Some(vote_target_id) + } + + pub fn clear(&mut self) { + self.vote_target = None; + } +} diff --git a/src/main.rs b/src/main.rs index 0489d13..4ed9c07 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,18 +1,24 @@ -mod commands; -mod data; -mod helper; +use std::sync::Arc; -use crate::commands::command_framework; -use crate::data::{Args, BotConfig, GlobalData, MessageSource}; -use crate::helper::{get_game_state, send_msg_to_player_channels}; use serenity::async_trait; use serenity::client::bridge::gateway::GatewayIntents; use serenity::http::AttachmentType; use serenity::model::prelude::{Message, Ready}; use serenity::prelude::*; -use std::sync::Arc; use structopt::StructOpt; +use discord::commands::command_framework; +use discord::helper::send_msg_to_player_channels; +use game::global_data::GlobalData; +use game::MessageSource; + +use crate::config::{Args, BotConfig}; + +mod config; +mod discord; +mod error; +mod game; + struct Handler {} #[async_trait] @@ -28,12 +34,18 @@ impl EventHandler for Handler { let data = ctx.data.read().await; - let game_data = data.get::().unwrap(); + let global_data = data.get::().unwrap(); - let game_data = game_data.lock().await; + let mut global_data = global_data.lock().await; - if let Some(player_data) = game_data - .game_state + if global_data.game_state.is_none() { + // no game in progress + return; + } + + if let Some(player_data) = global_data + .game_state() + .unwrap() .get_player_from_channel(msg.channel_id.0) { let guild = msg.guild(&ctx.cache).await.unwrap(); @@ -48,13 +60,14 @@ impl EventHandler for Handler { send_msg_to_player_channels( &ctx, &guild, - &game_data, + &mut global_data, MessageSource::Player(msg.channel_id.0), &user_msg, Some(attachments), false, ) - .await; + .await + .expect("Unable to send message to players"); } } @@ -71,8 +84,11 @@ async fn main() { let mut global_data = GlobalData::new(bot_cfg.clone()); - if get_game_state(&mut global_data).is_ok() { - println!("Resuming game...") + if global_data.game_state_exists() { + println!("Resuming game..."); + global_data + .load_game_state() + .expect("Unable to open saved game state."); } let mut client = Client::builder(&bot_cfg.token)