diff --git a/Cargo.lock b/Cargo.lock index 711dd40..527d1fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2302,7 +2302,7 @@ dependencies = [ [[package]] name = "woxlf" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bitflags", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 0057d95..3010bd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "woxlf" -version = "0.2.0" +version = "0.3.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/discord/commands.rs b/src/discord/host.rs similarity index 62% rename from src/discord/commands.rs rename to src/discord/host.rs index 3684a6a..415a4c7 100644 --- a/src/discord/commands.rs +++ b/src/discord/host.rs @@ -1,21 +1,6 @@ -use chrono::Duration; -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::discord::helper::{add_user_to_game, parse_duration_arg}; -use crate::error::{Result, WoxlfError}; +use crate::error; +use crate::error::WoxlfError; use crate::game::global_data::GlobalData; use crate::game::message_router::{ dispatch_message, Median, MessageDest, MessageSource, WoxlfMessage, @@ -24,11 +9,19 @@ use crate::game::player_data::PlayerData; use crate::game::role::Role; use crate::game::Phase; use crate::messages::DiscordUser; +use chrono::Duration; +use rand::prelude::SliceRandom; +use rand::thread_rng; +use serenity::client::Context; +use serenity::framework::standard::macros::{command, group}; +use serenity::framework::standard::{Args, CommandResult}; +use serenity::model::channel::Message; +use serenity::model::guild::Member; +use serenity::model::id::{ChannelId, UserId}; +use serenity::utils::MessageBuilder; #[group] -#[commands( - start, say, end, broadcast, next_phase, kill, add_time, test_theme, whisper -)] +#[commands(start, say, end, broadcast, next_phase, kill, add_time, test_theme)] struct Host; #[command] @@ -47,18 +40,18 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { global_data.start_game(&game_name, Phase::Night, duration.into())?; - let players: Result> = args + let players: error::Result> = args .iter::() .flatten() .map(|discord_id| { - let discord_id = match discord_id.parse::() { + 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)) { + if let Some(discord_user) = guild.members.get(&discord_id) { Ok(discord_user) } else { Err(WoxlfError::DiscordIdParseError(discord_id.to_string())) @@ -306,52 +299,6 @@ async fn kill(ctx: &Context, msg: &Message, args: Args) -> CommandResult { Ok(()) } -#[command] -#[only_in(guilds)] -#[allowed_roles("wolfx host")] -async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); - - let mut global_data = global_data.lock().await; - - let duration = parse_duration_arg(&mut args).await?; - - global_data - .game_state_mut()? - .add_time_to_phase(duration.into()); - - let broadcast = MessageBuilder::new() - .push( - global_data - .templates()? - .build_phase_extend_message(&global_data)?, - ) - .push_line("") - .push(global_data.templates()?.build_satus_message(&global_data)?) - .build(); - - let broadcast = global_data - .templates()? - .build_announcement(&global_data, &broadcast)?; - - let woxlf_msg = WoxlfMessage::default() - .source(MessageSource::Automated) - .dest(MessageDest::Broadcast) - .median(Median::Webhook) - .content(&broadcast) - .clone(); - - dispatch_message(ctx, &mut global_data, woxlf_msg).await?; - - msg.reply(&ctx.http, "Phase has been updated") - .await - .unwrap(); - - global_data.save_game_state().unwrap(); - Ok(()) -} - #[command] #[only_in(guilds)] #[allowed_roles("wolfx host")] @@ -494,260 +441,48 @@ async fn test_theme(ctx: &Context, msg: &Message, args: Args) -> CommandResult { Ok(()) } -#[group] -#[commands(vote, status, players)] -struct Player; - #[command] #[only_in(guilds)] -#[description = "vote another subject for termination. $vote "] -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).unwrap(); +#[allowed_roles("wolfx host")] +async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let data = ctx.data.read().await; + let global_data = data.get::().unwrap(); let mut global_data = global_data.lock().await; - if global_data.game_state_mut()?.current_phase != Phase::Day { - msg.reply( - &ctx.http, - format!( - "You can only vote during the {} phase.", - global_data.game_cfg()?.vote_phase_name - ), + let duration = parse_duration_arg(&mut args).await?; + + global_data + .game_state_mut()? + .add_time_to_phase(duration.into()); + + let broadcast = MessageBuilder::new() + .push( + global_data + .templates()? + .build_phase_extend_message(&global_data)?, ) + .push_line("") + .push(global_data.templates()?.build_satus_message(&global_data)?) + .build(); + + let broadcast = global_data + .templates()? + .build_announcement(&global_data, &broadcast)?; + + let woxlf_msg = WoxlfMessage::default() + .source(MessageSource::Automated) + .dest(MessageDest::Broadcast) + .median(Median::Webhook) + .content(&broadcast) + .clone(); + + dispatch_message(ctx, &mut global_data, woxlf_msg).await?; + + msg.reply(&ctx.http, "Phase has been updated") .await .unwrap(); - return Ok(()); - } - - if global_data - .game_state_mut()? - .get_player_from_channel(msg.channel_id.0) - .is_some() - { - 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 - .channels - .get(&ChannelId::from( - global_data.cfg.discord_config.vote_channel, - )) - .unwrap(); - - let player_data = global_data - .game_state_mut()? - .get_player_from_channel_mut(msg.channel_id.0) - .unwrap(); - - player_data.cast_vote(target_player.discord_id); - - // borrow as immutable - let player_data = global_data - .game_state()? - .get_player_from_channel(msg.channel_id.0) - .unwrap(); - - let vote_msg = global_data.templates()?.build_vote_message( - &global_data, - player_data, - &target_player, - )?; - - vote_channel - .id() - .send_message(&ctx.http, |m| m.content(vote_msg)) - .await?; - } else { - msg.reply(&ctx.http, "Target not found!").await.unwrap(); - } - } else { - msg.reply( - &ctx.http, - "This command needs to be run in a game channel, goober", - ) - .await?; - } global_data.save_game_state().unwrap(); Ok(()) } - -#[command] -#[only_in(guilds)] -#[description = "Get the game status."] -async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { - let mut data = ctx.data.write().await; - let global_data = data.get_mut::().unwrap(); - - let mut global_data = global_data.lock().await; - - let mut msg_builder = MessageBuilder::new(); - - msg_builder.push( - global_data - .templates()? - .build_satus_message(&global_data) - .unwrap(), - ); - - if global_data.game_state_mut()?.current_phase == Phase::Day { - msg_builder.push_line( - global_data - .templates()? - .build_vote_tally(&global_data) - .unwrap(), - ); - } - - msg.reply(&ctx.http, msg_builder.build()).await.unwrap(); - - Ok(()) -} - -#[command] -#[only_in(guilds)] -#[description = "Get the other players in the game."] -async fn players(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_line(&global_data.game_cfg()?.player_group_name); - - for player in &global_data.game_state()?.player_data { - let alive_status = if !player.alive { " (Dead) " } else { "" }; - - msg_builder - .push("* ") - .push(&player.codename) - .push(alive_status); - - if msg.channel_id.0 == global_data.cfg.discord_config.host_channel { - let guild = msg.guild(&ctx.cache).unwrap(); - let member = guild.members.get(&UserId::from(player.discord_id)).unwrap(); - msg_builder.push_line(format!( - " ({}) [{} {}]", - member.display_name(), - player.role, - player.role.seer_color() - )); - } else { - msg_builder.push_line(""); - } - } - - msg.reply(&ctx.http, msg_builder.build()).await.unwrap(); - - Ok(()) -} - -#[command] -#[aliases("pm", "w")] -#[description = "Send a private message to another player."] -async fn whisper(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let data = ctx.data.read().await; - let global_data = data.get::().unwrap(); - let mut global_data = global_data.lock().await; - - if !global_data.game_cfg()?.whispers_allowed { - msg.reply(&ctx.http, "No private messages are allowed in this game") - .await?; - return Ok(()); - } - - if args.len() < 2 { - msg.reply(&ctx.http, "Need a recipient and message!") - .await?; - } else { - let target = args.single::()?; - let pm = args.rest(); - - let src_player = match global_data - .game_state()? - .get_player_from_discord_id(msg.author.id.0) - { - None => { - msg.reply(&ctx.http, "You are not in the game!").await?; - return Ok(()); - } - Some(player) => player, - }; - - if let Some(target_player) = global_data.game_state()?.get_player_by_codename(&target) { - if src_player.discord_id == target_player.discord_id { - msg.reply(&ctx.http, "You can't send messages to yourself!") - .await?; - return Ok(()); - } - - let woxlf_msg = WoxlfMessage::default() - .source(MessageSource::Player(Box::new(src_player.clone()))) - .dest(MessageDest::Player(Box::new(target_player.clone()))) - .median(Median::DirectMessage) - .content(pm) - .clone(); - - dispatch_message(ctx, &mut global_data, woxlf_msg).await?; - } else { - msg.reply( - &ctx.http, - format!("Could not find a player with codename {}.", target), - ) - .await?; - } - } - - Ok(()) -} - -#[help] -#[individual_command_tip = "If you want more information about a specific command, just pass the command as argument."] -#[command_not_found_text = "Could not find: `{}`."] -#[max_levenshtein_distance(3)] -#[indention_prefix = "+"] -#[lacking_role = "Strike"] -#[wrong_channel = "Strike"] -async fn help( - context: &Context, - msg: &Message, - args: Args, - help_options: &'static HelpOptions, - groups: &[&'static CommandGroup], - owners: HashSet, -) -> CommandResult { - let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await; - 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,); - 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/discord/mod.rs b/src/discord/mod.rs index cf99ab5..d7610e9 100644 --- a/src/discord/mod.rs +++ b/src/discord/mod.rs @@ -1,3 +1,59 @@ -pub mod commands; +use serenity::client::Context; +use serenity::framework::standard::macros::{help, hook}; +use serenity::framework::standard::{ + help_commands, Args, CommandGroup, CommandResult, HelpOptions, +}; +use serenity::framework::StandardFramework; +use serenity::model::channel::Message; +use serenity::model::id::UserId; +use std::collections::HashSet; + pub mod event_handler; pub mod helper; +mod host; +mod players; + +#[help] +#[individual_command_tip = "If you want more information about a specific command, just pass the command as argument."] +#[command_not_found_text = "Could not find: `{}`."] +#[max_levenshtein_distance(3)] +#[indention_prefix = "+"] +#[lacking_role = "Strike"] +#[wrong_channel = "Strike"] +async fn help( + context: &Context, + msg: &Message, + args: Args, + help_options: &'static HelpOptions, + groups: &[&'static CommandGroup], + owners: HashSet, +) -> CommandResult { + let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await; + 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,); + println!("{}", reply_msg); + msg.reply(&ctx.http, reply_msg).await.unwrap(); + } + }; +} + +pub fn command_framework() -> StandardFramework { + StandardFramework::new() + .configure(|c| c.prefix('$')) + .group(&host::HOST_GROUP) + .group(&players::PLAYER_GROUP) + .help(&HELP) + .after(handle_errors) +} diff --git a/src/discord/players.rs b/src/discord/players.rs new file mode 100644 index 0000000..ac96779 --- /dev/null +++ b/src/discord/players.rs @@ -0,0 +1,224 @@ +use crate::game::global_data::GlobalData; +use crate::game::message_router::{ + dispatch_message, Median, MessageDest, MessageSource, WoxlfMessage, +}; +use crate::game::Phase; +use serenity::client::Context; +use serenity::framework::standard::macros::{command, group}; +use serenity::framework::standard::{Args, CommandResult}; +use serenity::model::channel::Message; +use serenity::model::id::{ChannelId, UserId}; +use serenity::utils::MessageBuilder; + +#[group] +#[commands(vote, status, players, whisper)] +struct Player; + +#[command] +#[only_in(guilds)] +#[description = "vote another subject for termination. $vote "] +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).unwrap(); + + let mut global_data = global_data.lock().await; + + if global_data.game_state_mut()?.current_phase != Phase::Day { + msg.reply( + &ctx.http, + format!( + "You can only vote during the {} phase.", + global_data.game_cfg()?.vote_phase_name + ), + ) + .await + .unwrap(); + return Ok(()); + } + + if global_data + .game_state_mut()? + .get_player_from_channel(msg.channel_id.0) + .is_some() + { + 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 + .channels + .get(&ChannelId::from( + global_data.cfg.discord_config.vote_channel, + )) + .unwrap(); + + let player_data = global_data + .game_state_mut()? + .get_player_from_channel_mut(msg.channel_id.0) + .unwrap(); + + player_data.cast_vote(target_player.discord_id); + + // borrow as immutable + let player_data = global_data + .game_state()? + .get_player_from_channel(msg.channel_id.0) + .unwrap(); + + let vote_msg = global_data.templates()?.build_vote_message( + &global_data, + player_data, + &target_player, + )?; + + vote_channel + .id() + .send_message(&ctx.http, |m| m.content(vote_msg)) + .await?; + } else { + msg.reply(&ctx.http, "Target not found!").await.unwrap(); + } + } else { + msg.reply( + &ctx.http, + "This command needs to be run in a game channel, goober", + ) + .await?; + } + + global_data.save_game_state().unwrap(); + Ok(()) +} + +#[command] +#[only_in(guilds)] +#[description = "Get the game status."] +async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { + let mut data = ctx.data.write().await; + let global_data = data.get_mut::().unwrap(); + + let mut global_data = global_data.lock().await; + + let mut msg_builder = MessageBuilder::new(); + + msg_builder.push( + global_data + .templates()? + .build_satus_message(&global_data) + .unwrap(), + ); + + if global_data.game_state_mut()?.current_phase == Phase::Day { + msg_builder.push_line( + global_data + .templates()? + .build_vote_tally(&global_data) + .unwrap(), + ); + } + + msg.reply(&ctx.http, msg_builder.build()).await.unwrap(); + + Ok(()) +} + +#[command] +#[only_in(guilds)] +#[description = "Get the other players in the game."] +async fn players(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_line(&global_data.game_cfg()?.player_group_name); + + for player in &global_data.game_state()?.player_data { + let alive_status = if !player.alive { " (Dead) " } else { "" }; + + msg_builder + .push("* ") + .push(&player.codename) + .push(alive_status); + + if msg.channel_id.0 == global_data.cfg.discord_config.host_channel { + let guild = msg.guild(&ctx.cache).unwrap(); + let member = guild.members.get(&UserId::from(player.discord_id)).unwrap(); + msg_builder.push_line(format!( + " ({}) [{} {}]", + member.display_name(), + player.role, + player.role.seer_color() + )); + } else { + msg_builder.push_line(""); + } + } + + msg.reply(&ctx.http, msg_builder.build()).await.unwrap(); + + Ok(()) +} + +#[command] +#[aliases("pm", "w")] +#[description = "Send a private message to another player."] +async fn whisper(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { + let data = ctx.data.read().await; + let global_data = data.get::().unwrap(); + let mut global_data = global_data.lock().await; + + if !global_data.game_cfg()?.whispers_allowed { + msg.reply(&ctx.http, "No private messages are allowed in this game") + .await?; + return Ok(()); + } + + if args.len() < 2 { + msg.reply(&ctx.http, "Need a recipient and message!") + .await?; + } else { + let target = args.single::()?; + let pm = args.rest(); + + let src_player = match global_data + .game_state()? + .get_player_from_discord_id(msg.author.id.0) + { + None => { + msg.reply(&ctx.http, "You are not in the game!").await?; + return Ok(()); + } + Some(player) => player, + }; + + if let Some(target_player) = global_data.game_state()?.get_player_by_codename(&target) { + if src_player.discord_id == target_player.discord_id { + msg.reply(&ctx.http, "You can't send messages to yourself!") + .await?; + return Ok(()); + } + + let woxlf_msg = WoxlfMessage::default() + .source(MessageSource::Player(Box::new(src_player.clone()))) + .dest(MessageDest::Player(Box::new(target_player.clone()))) + .median(Median::DirectMessage) + .content(pm) + .clone(); + + dispatch_message(ctx, &mut global_data, woxlf_msg).await?; + } else { + msg.reply( + &ctx.http, + format!("Could not find a player with codename {}.", target), + ) + .await?; + } + } + + Ok(()) +} diff --git a/src/game/global_data.rs b/src/game/global_data.rs index 7adbafc..733bf4d 100644 --- a/src/game/global_data.rs +++ b/src/game/global_data.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use chrono::Duration; use serenity::prelude::{Mutex, TypeMapKey}; use std::fs::File; use std::io::{Read, Write}; @@ -10,7 +11,6 @@ use crate::game::game_state::GameState; use crate::game::Phase; use crate::imgur::{get_album_images, Image}; use crate::messages::MessageTemplates; -use chrono::Duration; #[derive(Debug, Clone)] pub struct GlobalData { diff --git a/src/game/message_router.rs b/src/game/message_router.rs index ce6fee4..f688e80 100644 --- a/src/game/message_router.rs +++ b/src/game/message_router.rs @@ -260,7 +260,10 @@ pub async fn send_message( } Median::DirectMessage => { let dm_msg = MessageBuilder::new() - .push_bold_line_safe(format!("{} has sent you a private message:", &msg.get_message_username(global_data)?)) + .push_bold_line_safe(format!( + "{} has sent you a private message:", + &msg.get_message_username(global_data)? + )) .push(&msg.content) .build(); diff --git a/src/game/role/mod.rs b/src/game/role/mod.rs index 8e7bfa6..1a8ffad 100644 --- a/src/game/role/mod.rs +++ b/src/game/role/mod.rs @@ -1,7 +1,7 @@ -use serde::{Deserialize, Serialize}; -use std::fmt::{Display, Formatter}; use crate::game::listener::Listeners; use crate::game::role::spy::SpyListener; +use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; mod spy; @@ -120,6 +120,7 @@ impl Role { } pub fn register_role_listener(&self, listeners: &mut Listeners) { + #[allow(clippy::single_match)] match self { Role::Spy => listeners.add_listener(Box::new(SpyListener {})), _ => {} diff --git a/src/game/role/spy.rs b/src/game/role/spy.rs index 2477b21..6b14dba 100644 --- a/src/game/role/spy.rs +++ b/src/game/role/spy.rs @@ -1,6 +1,9 @@ use crate::game::listener::{EventStatus, Listener, ListenerContext, Priority}; -use crate::game::message_router::{Median, MessageDest, MessageSource, send_private_message, WoxlfMessage}; +use crate::game::message_router::{ + send_private_message, Median, MessageDest, MessageSource, WoxlfMessage, +}; use crate::game::role::Role; +use rand::{thread_rng, Rng}; use serenity::async_trait; use serenity::model::prelude::UserId; use serenity::utils::MessageBuilder; @@ -35,7 +38,6 @@ impl Listener for SpyListener { return Ok(EventStatus::Okay); }; - let spy_player = ctx .data .game_state()? @@ -44,19 +46,30 @@ impl Listener for SpyListener { .find(|p| p.alive && p.role == Role::Spy); if let Some(spy_player) = spy_player { - if spy_player.discord_id == dest_player.discord_id || spy_player.discord_id == src_player.discord_id { + if spy_player.discord_id == dest_player.discord_id + || spy_player.discord_id == src_player.discord_id + { return Ok(EventStatus::Okay); } - let msg_content = MessageBuilder::default() - .push_bold_line_safe(format!( - "{} Sent {} a private message:", - src_player.codename, dest_player.codename - )) - .push(msg.content.clone()) - .build(); + // 1/4 chance to intercept message + if thread_rng().gen_bool(0.25) { + let msg_content = MessageBuilder::default() + .push_bold_line_safe(format!( + "{} Sent {} a private message:", + src_player.codename, dest_player.codename + )) + .push(msg.content.clone()) + .build(); - send_private_message(&ctx.ctx.http, UserId::from(spy_player.discord_id), &msg_content, &None).await? + send_private_message( + &ctx.ctx.http, + UserId::from(spy_player.discord_id), + &msg_content, + &None, + ) + .await? + } } return Ok(EventStatus::Okay); diff --git a/src/main.rs b/src/main.rs index 6016339..cfe75a8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serenity::prelude::*; use structopt::StructOpt; -use discord::commands::command_framework; +use discord::command_framework; use discord::event_handler::Handler; use game::global_data::GlobalData;