From 10fa6f690646ff9c485109a69ff54c5b8a79270c Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sun, 6 Mar 2022 12:48:11 -0700 Subject: [PATCH] Added remaining bare minimum functionality + Added phase handling and player termination + Fixed an issue with some commands not saving game data + Added game_status command + Updated readme with commands and new features + clippy + fmt --- README.md | 33 ++++- src/commands.rs | 370 +++++++++++++++++++++++++++++++++++++----------- src/data.rs | 74 +++++++++- src/helper.rs | 112 ++++++++++++++- 4 files changed, 500 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 4c22a5a..ec11c09 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,31 @@ Discord bot for managing an anonymous [Werewolf Game](https://en.wikipedia.org/w A host gets a list of players to play, and then begins the game with the `!start` command. Each player is assigned a channel where they will view the game through. The player can read and send messages -in this channel normally. When a message is sent, it is forwarded to all other channels. The message's author -is obscured by a codename. +in this channel normally. When a message is sent, it is forwarded to all other player channels. The message's author +is obscured by a codename. During day phases, players can cast a vote for who they wish to "terminate" that day. -The bot also handles communications from the host, daily votes, and notifying players of deaths. +The game proceeds as a normal Werewolf game. + +## Channels +* Host Channel: Used by the host to interact with the game, can also be used by spectators to see the game state. + * Real player names are displayed along with code names in this channel +* Player Channel: A channel for a single player, allows them to chat with the other players and run commands. +* Vote Channel: Contains all the votes made in a game. + +## Commands + +## Host +* `!start ` - starts the game +* `!end` - Ends the current game +* `!say ` - Allows the host to speak into the game chat +* `!broadcast ` - Broadcasts a system message, this message is then pinned in each player channel +* `!next_phase ` - Send the next phase message. Also cycles the phase +* `!terminate ` - Kills a player and removes them from the game +* `!add_time ` - Adds more time to the current game + +## Players +* `!vote ` - Casts a vote for a player to be terminated. Only can be used during the day phase +* `!status` - Get the current game status. Includes time left in the phase and current vote tallies ## Example Config ```toml @@ -18,8 +39,12 @@ token = "" app_id = 0 # Channel to accept host commands from host_channel = 1 +# Channel to put vote status messages in +vote_channel = 2 # Category to crate the player cannels in -category = 2 +category = 3 +# Directory to save game data into, game data will be saved as "wOxlf.toml" +game_state_dir = "." # Random code names are generated in the format of diff --git a/src/commands.rs b/src/commands.rs index 6af0090..36a0b1b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,87 +1,22 @@ -use crate::data::{BotConfig, GlobalData, MessageSource, PlayerData}; -use crate::helper::{clear_game_state, save_game_state, send_msg_to_player_channels}; -use rand::Rng; use serenity::framework::standard::macros::{command, group}; use serenity::framework::standard::{Args, CommandResult}; use serenity::framework::StandardFramework; -use serenity::model::channel::{PermissionOverwrite, PermissionOverwriteType}; -use serenity::model::guild::{Guild, Member}; use serenity::model::id::ChannelId; use serenity::model::prelude::{Message, UserId}; -use serenity::model::Permissions; 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, +}; + #[group] -#[commands(start, say, end, broadcast)] +#[commands(start, say, end, broadcast, next_phase, terminate, add_time)] struct Host; -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) -} - -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); - - while global_data.game_state.codename_exists(&codename) { - codename = generate_codename(&global_data.cfg); - } - - let channel = guild - .create_channel(&ctx.http, |c| { - c.category(&ChannelId::from(global_data.cfg.category)) - .name(format!("{}'s Channel", discord_user.display_name())) - }) - .await?; - - let allow = Permissions::SEND_MESSAGES - | Permissions::READ_MESSAGE_HISTORY - | Permissions::READ_MESSAGE_HISTORY; - - let overwrite = PermissionOverwrite { - allow, - deny: Default::default(), - kind: PermissionOverwriteType::Member(discord_user.user.id), - }; - - channel.create_permission(&ctx.http, &overwrite).await?; - - let msg = channel.send_message(&ctx.http, |m| { - m.content(MessageBuilder::new() - .push("Welcome ") - .mention(discord_user) - .push_line(" to your WOxlf Terminal. You may use this terminal to communicate to other subjects.") - .push_line("You will also use this terminal for choosing one of your fellow subjects for termination.") - .push_line("Happy testing :)") - .push_line("") - .push("SUBJECT CODENAME: ") - .push_line(&codename) - ) - }).await?; - - channel.pin(&ctx.http, msg.id).await?; - - let player_data = PlayerData { - channel: channel.id.0, - discord_id: discord_user.user.id.0, - codename, - }; - - global_data.game_state.player_data.push(player_data); - - Ok(()) -} - #[command] #[only_in(guilds)] #[allowed_roles("wolfx host")] @@ -96,9 +31,21 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { clear_game_state(&mut global_data).unwrap(); + 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); + for player in args.iter::().flatten() { if let Some(discord_user) = guild.members.get(&UserId::from(player)) { - add_user_to_game(ctx, &guild, &mut global_data, discord_user).await?; + helper::add_user_to_game(ctx, &guild, &mut global_data, discord_user).await?; } else { msg.reply( &ctx.http, @@ -136,6 +83,7 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult { clear_game_state(&mut global_data).unwrap(); + msg.reply(&ctx.http, "Game ended!").await.unwrap(); Ok(()) } @@ -166,21 +114,281 @@ async fn broadcast(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let global_data = global_data.lock().await; - let msg = MessageBuilder::new() - .push_bold_line("\\*\\*IMPORTANT wOxlf SYSTEM MESSAGE\\*\\*") - .push_line("") - .push_line(args.rest()) - .push_line("") - .push_bold_line("\\*\\*END OF SYSTEM MESSAGE\\*\\*") - .build(); + let msg = build_system_message(args.rest()); send_msg_to_player_channels(ctx, &guild, &global_data, MessageSource::Host, &msg, true).await; Ok(()) } +#[command] +#[only_in(guilds)] +#[allowed_roles("wolfx host")] +async fn next_phase(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; + + 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.next_phase(); + + global_data.game_state.phase_end_time = get_phase_end_timestamp(duration); + + let broadcast = MessageBuilder::new() + .push_line(args.rest()) + .push_line("") + .push(print_game_status(&global_data.game_state)) + .build(); + + let broadcast = build_system_message(&broadcast); + + send_msg_to_player_channels( + ctx, + &guild, + &global_data, + MessageSource::Host, + &broadcast, + true, + ) + .await; + + if global_data.game_state.current_phase == Phase::Day { + let vote_channel = guild + .channels + .get(&ChannelId::from(global_data.cfg.vote_channel)) + .unwrap(); + vote_channel + .send_message(&ctx.http, |m| { + m.content(format!( + "**DAY {} VOTES:**", + global_data.game_state.phase_number + )) + }) + .await + .unwrap(); + } + + msg.reply( + &ctx.http, + format!( + "Phase has been cycled to {}.", + &global_data.game_state.current_phase + ), + ) + .await + .unwrap(); + + save_game_state(&global_data).unwrap(); + + Ok(()) +} + +#[command] +#[only_in(guilds)] +#[allowed_roles("wolfx host")] +async fn terminate(ctx: &Context, msg: &Message, 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; + + let target = args.rest().to_lowercase(); + let index = global_data + .game_state + .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_channel = guild + .channels + .get(&ChannelId::from(player.channel)) + .unwrap(); + + player_channel.delete(&ctx.http).await.unwrap(); + + msg.reply( + &ctx.http, + format!("{} has been terminated.", player.codename), + ) + .await + .unwrap(); + } else { + msg.reply(&ctx.http, "No subject found with that codename.") + .await + .unwrap(); + } + + save_game_state(&global_data).unwrap(); + Ok(()) +} + +#[command] +#[only_in(guilds)] +#[allowed_roles("wolfx host")] +async fn add_time(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; + + global_data.game_state.next_phase(); + + 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.add_time_to_phase(duration); + + let broadcast = MessageBuilder::new() + .push_line("EXPERIMENT PHASE HAS BEEN EXTENDED!!!") + .push_line("") + .push(print_game_status(&global_data.game_state)) + .build(); + + let broadcast = build_system_message(&broadcast); + + send_msg_to_player_channels( + ctx, + &guild, + &global_data, + MessageSource::Host, + &broadcast, + true, + ) + .await; + + msg.reply(&ctx.http, "Phase has been updated") + .await + .unwrap(); + + save_game_state(&global_data).unwrap(); + Ok(()) +} + +#[group] +#[commands(vote, status)] +struct Player; + +#[command] +#[only_in(guilds)] +async fn vote(ctx: &Context, msg: &Message, 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; + + if global_data.game_state.current_phase != Phase::Day { + msg.reply( + &ctx.http, + "You can only select subject for termination during the day!", + ) + .await + .unwrap(); + return Ok(()); + } + + if global_data + .game_state + .get_player_from_channel(msg.channel_id.0) + .is_some() + { + let target_player = global_data.game_state.get_player_by_codename(args.rest()); + + if let Some(target_player) = target_player { + let vote_channel = guild + .channels + .get(&ChannelId::from(global_data.cfg.vote_channel)) + .unwrap(); + + let player_data = global_data + .game_state + .get_player_from_channel_mut(msg.channel_id.0) + .unwrap(); + player_data.vote_target = Some(target_player.channel); + + vote_channel + .send_message(&ctx.http, |m| { + m.content(format!( + "{} has selected {} for termination", + &player_data.codename, target_player.codename + )) + }) + .await + .unwrap(); + } else { + msg.reply(&ctx.http, "Subject not found!").await.unwrap(); + } + } else { + msg.reply( + &ctx.http, + "This command needs to be run in a game channel, goober", + ) + .await + .unwrap(); + } + + save_game_state(&global_data).unwrap(); + Ok(()) +} + +#[command] +#[only_in(guilds)] +async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { + let data = ctx.data.read().await; + let global_data = data.get::().unwrap(); + + let global_data = global_data.lock().await; + + let mut msg_builder = MessageBuilder::new(); + + msg_builder.push(print_game_status(&global_data.game_state)); + + if global_data.game_state.current_phase == Phase::Day { + msg_builder.push_line(""); + + let vote_tallies = global_data.game_state.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() { + msg_builder.push_line(format!("{}: {}", player, tally)); + } + } + } + + msg.reply(&ctx.http, msg_builder.build()).await.unwrap(); + + Ok(()) +} + pub fn command_framework() -> StandardFramework { StandardFramework::new() .configure(|c| c.prefix("!")) .group(&HOST_GROUP) + .group(&PLAYER_GROUP) } diff --git a/src/data.rs b/src/data.rs index a255535..4897b11 100644 --- a/src/data.rs +++ b/src/data.rs @@ -1,6 +1,8 @@ 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; @@ -18,6 +20,7 @@ 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, @@ -38,7 +41,7 @@ impl BotConfig { } } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] pub enum Phase { Day, Night, @@ -50,16 +53,30 @@ impl Default for Phase { } } +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, } @@ -73,11 +90,66 @@ impl GameState { 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_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_channel(vote_target); + if let Some(target) = target { + *vote_set.entry(target.codename.clone()).or_insert(0) += 1; + } + } + } + + vote_set + } } #[derive(Debug, Deserialize, Serialize, Clone)] diff --git a/src/helper.rs b/src/helper.rs index 7303e1c..18192de 100644 --- a/src/helper.rs +++ b/src/helper.rs @@ -1,10 +1,19 @@ -use crate::data::{GlobalData, MessageSource}; +use std::fs::File; +use std::io::prelude::*; + +use rand::Rng; +use serenity::framework::standard::CommandResult; +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::Permissions; use serenity::prelude::Context; -use std::fs::File; -use std::io::prelude::*; +use serenity::utils::MessageBuilder; + +use crate::data::{BotConfig, GameState, GlobalData, MessageSource, PlayerData}; +use std::time::UNIX_EPOCH; pub async fn send_msg_to_player_channels( ctx: &Context, @@ -97,3 +106,100 @@ pub fn clear_game_state(global_data: &mut GlobalData) -> std::io::Result<()> { 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); + + while global_data.game_state.codename_exists(&codename) { + codename = generate_codename(&global_data.cfg); + } + + let channel = guild + .create_channel(&ctx.http, |c| { + c.category(&ChannelId::from(global_data.cfg.category)) + .name(format!("{}'s Channel", discord_user.display_name())) + }) + .await?; + + let allow = Permissions::SEND_MESSAGES + | Permissions::READ_MESSAGE_HISTORY + | Permissions::READ_MESSAGE_HISTORY; + + let overwrite = PermissionOverwrite { + allow, + deny: Default::default(), + kind: PermissionOverwriteType::Member(discord_user.user.id), + }; + + channel.create_permission(&ctx.http, &overwrite).await?; + + let msg = channel.send_message(&ctx.http, |m| { + m.content(MessageBuilder::new() + .push("Welcome ") + .mention(discord_user) + .push_line(" to your WOxlf Terminal. You may use this terminal to communicate to other subjects.") + .push_line("You will also use this terminal for choosing one of your fellow subjects for termination.") + .push_line("Happy testing :)") + .push_line("") + .push("SUBJECT CODENAME: ") + .push_line(&codename) + .push(print_game_status(&global_data.game_state)) + ) + }).await?; + + channel.pin(&ctx.http, msg.id).await?; + + let player_data = PlayerData { + channel: channel.id.0, + discord_id: discord_user.user.id.0, + vote_target: None, + codename, + }; + + global_data.game_state.player_data.push(player_data); + + Ok(()) +} + +pub fn build_system_message(msg: &str) -> String { + MessageBuilder::new() + .push_bold_line("\\*\\*IMPORTANT wOxlf SYSTEM MESSAGE\\*\\*") + .push_line("") + .push_line(msg) + .push_line("") + .push_bold_line("\\*\\*END OF SYSTEM MESSAGE\\*\\*") + .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() +}