From 230930a23c56067d3ad95c1d1bad0c2272b7418c Mon Sep 17 00:00:00 2001 From: Joey Hines Date: Sat, 28 May 2022 16:31:41 -0600 Subject: [PATCH] Added config for themes. Fixes #1 * WOxlf now supports a number of game themes * Each theme can have custom messages, profile pics, phrasing, etc * Messages are defined as tera templates * Couple small improvements + Clippy + fmt --- Cargo.lock | 278 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 1 + README.md | 103 ++++++++++++--- src/config.rs | 38 +++++- src/discord/commands.rs | 154 ++++++++++++++-------- src/discord/helper.rs | 61 +++------ src/error.rs | 14 ++ src/game/game_state.rs | 23 +--- src/game/global_data.rs | 84 ++++++++---- src/game/mod.rs | 31 ----- src/main.rs | 3 +- src/messages/mod.rs | 172 +++++++++++++++++++++++++ 12 files changed, 771 insertions(+), 191 deletions(-) create mode 100644 src/messages/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 665fc99..cde9552 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,6 +124,15 @@ dependencies = [ "byte-tools", ] +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "memchr", +] + [[package]] name = "bumpalo" version = "3.9.1" @@ -180,6 +189,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "chrono-tz" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58549f1842da3080ce63002102d5bc954c7bc843d4f47818e642abdc36253552" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db058d493fb2f65f41861bfed7e3fe6335264a9f0f92710cab5bdf01fef09069" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "2.34.0" @@ -259,6 +290,22 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf124c720b7686e3c2663cf54062ab0f68a88af2fb6a030e87e30bf721fcb38" +dependencies = [ + "cfg-if", + "lazy_static", +] + +[[package]] +name = "deunicode" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850878694b7933ca4c9569d30a34b55031b9b139ee1fc7b94a527c4ef960d690" + [[package]] name = "digest" version = "0.8.1" @@ -483,6 +530,30 @@ dependencies = [ "wasi 0.10.0+wasi-snapshot-preview1", ] +[[package]] +name = "globset" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10463d9ff00a2a068db14231982f5132edebad0d7660cd956a1c30292dbcbfbd" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + [[package]] name = "h2" version = "0.3.11" @@ -569,6 +640,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "humansize" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026" + [[package]] name = "hyper" version = "0.14.17" @@ -630,6 +707,24 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713f1b139373f96a2e0ce3ac931cd01ee973c3c5dd7c40c0c2efe96ad2b6751d" +dependencies = [ + "crossbeam-utils", + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.8.0" @@ -917,6 +1012,15 @@ dependencies = [ "hashbrown 0.9.1", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "pathdiff" version = "0.2.1" @@ -972,6 +1076,45 @@ dependencies = [ "sha-1 0.8.2", ] +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", + "uncased", +] + [[package]] name = "pin-project" version = "1.0.10" @@ -1283,6 +1426,15 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.19" @@ -1431,12 +1583,27 @@ dependencies = [ "opaque-debug 0.3.0", ] +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + [[package]] name = "slab" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9def91fd1e018fe007022791f865d0ccc9b3a0d5001e01aabb8b40e46000afb5" +[[package]] +name = "slug" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bc762e6a4b6c6fcaade73e77f9ebc6991b676f88bb2358bddb56560f073373" +dependencies = [ + "deunicode", +] + [[package]] name = "socket2" version = "0.4.4" @@ -1514,6 +1681,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "tera" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3cac831b615c25bcef632d1cabf864fa05813baad3d526829db18eb70e8b58d" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1523,6 +1712,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thread_local" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5516c27b78311c50bf42c071425c560ac799b11c30b31f87e3081965fe5e0180" +dependencies = [ + "once_cell", +] + [[package]] name = "time" version = "0.1.44" @@ -1714,6 +1912,65 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" +[[package]] +name = "uncased" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b01702b0fd0b3fadcf98e098780badda8742d4f4a7676615cad90e8ac73622" +dependencies = [ + "version_check", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicase" version = "2.6.0" @@ -1804,6 +2061,17 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi", + "winapi-util", +] + [[package]] name = "want" version = "0.3.0" @@ -1956,6 +2224,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -1984,6 +2261,7 @@ dependencies = [ "serde", "serenity", "structopt", + "tera", "tokio", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index e561950..9605263 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ toml = "0.5.8" regex = "1.5.5" futures = "0.3.21" reqwest = "0.11.10" +tera = "1.15.0" [dependencies.serenity] version = "0.10.10" diff --git a/README.md b/README.md index 28a3647..bd12b6c 100644 --- a/README.md +++ b/README.md @@ -34,25 +34,98 @@ The game proceeds as a normal Werewolf game. ## Example Config ```toml -# Discord Bot Token -token = "" -# Discord App Id -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 = 3 -# Directory to save game data into, game data will be saved as "wOxlf.toml" +# Directory to store the game state in game_state_dir = "." +# imgur API client id +imgur_client_id = "" -# Random code names are generated in the format of +# Discord Bot Config +[discord_config] +# Bot token +token = "" +# Bor app id +app_id = 0 +# Channel for the host to interact the bot +host_channel = 949483310613143564 +# Webhook id for the host channel +host_webhook_id = 955135068341415996 +# CHannel to post vote status +vote_channel = 950078550717923329 +# Category to create player channels under +category = 949766593322303518 -occupation = ["Engineer", "Scientist", "Dancer", "Farmer", "Captain", "Janitor", "Author", "Bartender", "Bum", - "Student", "Teacher", "Chef", "Waiter", "Comedian"] +# An example game config +[[game_config]] +# Game name, used to identify this config +game_name = "rouge_ai" +# The bot's name for this game +bot_name = "WOxlf" +# Vote phase name +vote_phase_name = "Day" +# Enemy phase name +enemy_phase_name = "Night" +# Imgur album hash for profile pics +profile_album_hash = "Raf84L4" +# PLayer group name +player_group_name = "Test Subjects" -adjective = ["Sleepy", "Drunk", "Smart", "Gifted", "Extreme", "Eccentric", "Amazing", "Bad", "Silly", "Dumb", "Smelly"] +# Names used for codename generation +first_name = ["Sleepy", "Drunk", "Smart", "Gifted", "Extreme", "Eccentric", "Amazing", "Bad", "Silly", "Dumb", "Smelly", + "Gooey", "Ok", "Poor", "Fast", "Gentle", "Dangerous", "Spooky", "Soft", "Small", "Big"] + +last_name = ["Engineer", "Scientist", "Dancer", "Farmer", "Captain", "Janitor", "Author", "Bartender", "Bum", + "Student", "Teacher", "Chef", "Waiter", "Comedian", "Doctor", "Athlete", "Gamer"] + +# Message config for this game +[game_config.messages] +# Format of the codenames +name_format = "{{ first_name }} {{ last_name }}" + +# Welcome message to send to players at the start of a game +welcome_message = ''' +Welcome {{ discord_user.mention }} to your WOxlf Terminal. You may use this terminal to communicate to other subjects. +You will also use this terminal for choosing one of your fellow subjects for termination. + +Happy testing :) + +Do $help to see all commands. + +**SUBJECT CODENAME: {{ player_data.codename }}** + +''' + +# Status message format +status_message = ''' +**EXPERIMENT STATUS** +CURRENT EXPERIMENT PHASE: {{ game_state.current_phase }} {{ game_state.phase_number}} +PHASE END TIME: {{ to_local_time(time=game_state.phase_end_time) }} +PHASE ENDING {{ to_countdown(time=game_state.phase_end_time) }} + +**TEST SUBJECTS REMAINING:** +{% for player in game_state.player_data %}* {{ player.codename }} +{% endfor %} +''' + +# Wrapper for annoucments +announcement_format = ''' +**\*\*IMPORTANT wOxlf SYSTEM MESSAGE\*\*** +{{ message }} +**\*\*END OF SYSTEM MESSAGE\*\*** +''' + +# Vote tally message format +tally_message = ''' +{% if tallies | length == 0 %} NO TERMIANTION VOTES HAVE BEEN CAST! {% else %} +**TERMINATION VOTE TALLIES:** +{% for target, count in tallies %}* {{ target }}: {{ count }} {% endfor %} +{% endif %} +''' + +# Vote message format, used in the vote status channel +vote_message = "{{ player_data.codename }} has selected {{ target_data.codename }} for termination." + +# Message to send whene extending a phase +phase_extend_message = "THE EXPERIMENT PHASE HAS BEEN EXTENDED!!!!" ``` ## License diff --git a/src/config.rs b/src/config.rs index 65b6fe0..814095a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,22 +13,44 @@ pub struct Args { #[derive(Debug, Deserialize, Serialize, Clone)] pub struct GameConfig { - pub occupation: Vec, - pub adjective: Vec, + pub game_name: String, + pub bot_name: String, + pub vote_phase_name: String, + pub enemy_phase_name: String, + pub player_group_name: String, pub profile_album_hash: String, + pub first_name: Vec, + pub last_name: Vec, + pub messages: MessageConfig, } #[derive(Debug, Deserialize, Serialize, Clone)] -pub struct BotConfig { +pub struct MessageConfig { + pub welcome_message: String, + pub status_message: String, + pub announcement_format: String, + pub tally_message: String, + pub vote_message: String, + pub phase_extend_message: String, + pub name_format: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DiscordConfig { pub token: String, pub app_id: u64, pub host_channel: u64, pub host_webhook_id: u64, pub vote_channel: u64, pub category: u64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct BotConfig { pub imgur_client_id: String, pub game_state_dir: PathBuf, - pub game_config: GameConfig, + pub discord_config: DiscordConfig, + pub game_config: Vec, } impl BotConfig { @@ -40,6 +62,14 @@ impl BotConfig { cfg.try_deserialize() } + pub fn get_game_config(&self, game_name: &str) -> Option { + let game_name = game_name.to_lowercase(); + self.game_config + .iter() + .find(|g| g.game_name.to_lowercase() == game_name) + .cloned() + } + pub fn get_game_state_path(&self) -> PathBuf { self.game_state_dir.join("wOxlf_data.toml") } diff --git a/src/discord/commands.rs b/src/discord/commands.rs index 823c34d..b681b47 100644 --- a/src/discord/commands.rs +++ b/src/discord/commands.rs @@ -13,13 +13,12 @@ use serenity::model::prelude::{Message, UserId}; use serenity::prelude::Context; use serenity::utils::MessageBuilder; -use crate::discord::helper::{ - add_user_to_game, build_system_message, parse_duration_arg, send_msg_to_player_channels, -}; +use crate::discord::helper::{add_user_to_game, parse_duration_arg, send_msg_to_player_channels}; use crate::error::{Result, WoxlfError}; use crate::game::global_data::GlobalData; use crate::game::MessageSource; use crate::game::Phase; +use crate::messages::DiscordUser; #[group] #[commands(start, say, end, broadcast, next_phase, terminate, add_time)] @@ -36,9 +35,10 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { let guild = msg.guild(&ctx.cache).await.unwrap(); let mut global_data = global_data.lock().await; + let game_name = args.single::()?; let duration = parse_duration_arg(&mut args).await?; - global_data.start_game(Phase::Night, duration.into()); + global_data.start_game(&game_name, Phase::Night, duration.into())?; let players: Result> = args .iter::() @@ -77,12 +77,38 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { players.shuffle(&mut thread_rng()); + let mut first_names = global_data.game_cfg()?.first_name.clone(); + let mut last_names = global_data.game_cfg()?.last_name.clone(); + + first_names.shuffle(&mut thread_rng()); + last_names.shuffle(&mut thread_rng()); + for player in players { - add_user_to_game(ctx, &guild, &mut global_data, player).await?; + let first_name = first_names.pop(); + let last_name = last_names.pop(); + add_user_to_game(ctx, &guild, &mut global_data, player, first_name, last_name).await?; + } + + for player_data in &global_data.game_state()?.player_data { + let channel = ChannelId::from(player_data.channel); + let discord_user = guild.member(&ctx.http, player_data.discord_id).await?; + + let intro_message = global_data.templates()?.build_welcome_message( + &global_data, + &DiscordUser::from(&discord_user), + player_data, + )?; + + let msg = channel + .send_message(&ctx.http, |m| m.content(intro_message)) + .await?; + channel.pin(&ctx.http, msg.id).await?; } global_data.save_game_state().unwrap(); + ctx.http.edit_nickname(guild.id.0, Some(&global_data.game_cfg()?.bot_name)).await?; + Ok(()) } @@ -107,6 +133,7 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult { global_data.clear_game_state()?; + ctx.http.edit_nickname(guild.id.0, None).await?; msg.reply(&ctx.http, "Game ended!").await.unwrap(); Ok(()) } @@ -121,14 +148,12 @@ async fn say(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let mut global_data = global_data.lock().await; - let msg = format!("**wOxlf **> {}", args.rest()); - send_msg_to_player_channels( ctx, &guild, &mut global_data, MessageSource::Host, - &msg, + args.rest(), None, false, ) @@ -147,7 +172,9 @@ async fn broadcast(ctx: &Context, msg: &Message, args: Args) -> CommandResult { let mut global_data = global_data.lock().await; - let msg = build_system_message(args.rest()); + let msg = global_data + .templates()? + .build_announcement(&global_data, args.rest())?; send_msg_to_player_channels( ctx, @@ -180,10 +207,12 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu let broadcast = MessageBuilder::new() .push_line(args.rest()) .push_line("") - .push(global_data.print_game_status()) + .push(global_data.templates()?.build_satus_message(&global_data)?) .build(); - let broadcast = build_system_message(&broadcast); + let broadcast = global_data + .templates()? + .build_announcement(&global_data, &broadcast)?; send_msg_to_player_channels( ctx, @@ -199,13 +228,16 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu if global_data.game_state_mut()?.current_phase == Phase::Day { let vote_channel = guild .channels - .get(&ChannelId::from(global_data.cfg.vote_channel)) + .get(&ChannelId::from( + global_data.cfg.discord_config.vote_channel, + )) .unwrap(); vote_channel .send_message(&ctx.http, |m| { m.content(format!( - "**DAY {} VOTES:**", - global_data.game_state_mut().unwrap().phase_number + "**{} {} Votes:**", + global_data.game_cfg().unwrap().vote_phase_name.clone(), + &global_data.game_state_mut().unwrap().phase_number )) }) .await?; @@ -215,7 +247,7 @@ async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResu &ctx.http, format!( "Phase has been cycled to {}.", - &global_data.game_state_mut()?.current_phase + &global_data.get_phase_name()? ), ) .await?; @@ -252,14 +284,11 @@ async fn terminate(ctx: &Context, msg: &Message, args: Args) -> CommandResult { player_channel.delete(&ctx.http).await.unwrap(); - msg.reply( - &ctx.http, - format!("{} has been terminated.", player.codename), - ) - .await - .unwrap(); + msg.reply(&ctx.http, format!("{} ", player.codename)) + .await + .unwrap(); } else { - msg.reply(&ctx.http, "No subject found with that codename.") + msg.reply(&ctx.http, "No player found with that codename.") .await .unwrap(); } @@ -285,12 +314,18 @@ async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult .add_time_to_phase(duration.into()); let broadcast = MessageBuilder::new() - .push_line("EXPERIMENT PHASE HAS BEEN EXTENDED!!!") + .push( + global_data + .templates()? + .build_phase_extend_message(&global_data)?, + ) .push_line("") - .push(global_data.print_game_status()) + .push(global_data.templates()?.build_satus_message(&global_data)?) .build(); - let broadcast = build_system_message(&broadcast); + let broadcast = global_data + .templates()? + .build_announcement(&global_data, &broadcast)?; send_msg_to_player_channels( ctx, @@ -328,7 +363,10 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { if global_data.game_state_mut()?.current_phase != Phase::Day { msg.reply( &ctx.http, - "You can only select subject for termination during the day!", + format!( + "You can only vote during the {} phase.", + global_data.game_cfg()?.vote_phase_name + ), ) .await .unwrap(); @@ -347,7 +385,9 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { if let Some(target_player) = target_player { let vote_channel = guild .channels - .get(&ChannelId::from(global_data.cfg.vote_channel)) + .get(&ChannelId::from( + global_data.cfg.discord_config.vote_channel, + )) .unwrap(); let player_data = global_data @@ -357,25 +397,30 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { player_data.cast_vote(target_player.discord_id); - vote_channel - .send_message(&ctx.http, |m| { - m.content(format!( - "{} has selected {} for termination", - &player_data.codename, target_player.codename - )) - }) - .await + // 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 + .send_message(&ctx.http, |m| m.content(vote_msg)) + .await?; } else { - msg.reply(&ctx.http, "Subject not found!").await.unwrap(); + 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 - .unwrap(); + .await?; } global_data.save_game_state().unwrap(); @@ -384,7 +429,7 @@ async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult { #[command] #[only_in(guilds)] -#[description = "Get the game status. $status"] +#[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(); @@ -393,21 +438,20 @@ async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { let mut msg_builder = MessageBuilder::new(); - msg_builder.push(global_data.print_game_status()); + 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(""); - - 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_mut()?.get_vote_tallies() { - msg_builder.push_line(format!("{}: {}", player, tally)); - } - } + msg_builder.push_line( + global_data + .templates()? + .build_vote_tally(&global_data) + .unwrap(), + ); } msg.reply(&ctx.http, msg_builder.build()).await.unwrap(); @@ -417,7 +461,7 @@ async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { #[command] #[only_in(guilds)] -#[description = "Get the other players in the game. $status"] +#[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(); @@ -426,12 +470,12 @@ async fn players(ctx: &Context, msg: &Message, _args: Args) -> CommandResult { let mut msg_builder = MessageBuilder::new(); - msg_builder.push_line("Test Subjects:"); + msg_builder.push_line(&global_data.game_cfg()?.player_group_name); 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 { + if msg.channel_id.0 == global_data.cfg.discord_config.host_channel { let guild = msg.guild(&ctx.cache).await.unwrap(); let member = guild.members.get(&UserId::from(player.discord_id)).unwrap(); msg_builder.push_line(format!(" ({})", member.display_name())); diff --git a/src/discord/helper.rs b/src/discord/helper.rs index 8a66da6..2424305 100644 --- a/src/discord/helper.rs +++ b/src/discord/helper.rs @@ -5,14 +5,13 @@ use serenity::model::channel::{Message, PermissionOverwrite, PermissionOverwrite use serenity::model::guild::{Guild, Member}; use serenity::model::id::{ChannelId, UserId}; use serenity::model::Permissions; -use serenity::utils::MessageBuilder; +use crate::error; 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}; use serenity::prelude::SerenityError; fn filter_source_channel(player_data: &&PlayerData, msg_source: &MessageSource) -> bool { @@ -75,7 +74,9 @@ pub async fn send_msg_to_player_channels( let host_channel = guild .channels - .get(&ChannelId::from(global_data.cfg.host_channel)) + .get(&ChannelId::from( + global_data.cfg.discord_config.host_channel, + )) .unwrap(); let source = match msg_source { @@ -195,7 +196,7 @@ pub async fn send_webhook_msg_to_player_channels( send_webhook_msg( &ctx.http, - global_data.cfg.host_webhook_id, + global_data.cfg.discord_config.host_webhook_id, &host_channel_username, profile_pic, msg, @@ -210,16 +211,21 @@ pub async fn add_user_to_game( guild: &Guild, global_data: &mut GlobalData, discord_user: &Member, -) -> error::Result<()> { - let mut codename = game::generate_codename(&global_data.cfg); - - while global_data.game_state_mut()?.codename_exists(&codename) { - codename = game::generate_codename(&global_data.cfg); + first_name: Option, + last_name: Option, +) -> error::Result { + if first_name == None && last_name == None { + return Err(WoxlfError::RanOutOfCodenames); } + let codename = global_data + .templates()? + .build_name(global_data, first_name, last_name) + .unwrap(); + let channel = guild .create_channel(&ctx.http, |c| { - c.category(&ChannelId::from(global_data.cfg.category)) + c.category(&ChannelId::from(global_data.cfg.discord_config.category)) .name(format!("{}'s Channel", discord_user.display_name())) }) .await?; @@ -242,24 +248,6 @@ pub async fn add_user_to_game( ) .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_line("Do $help to see all commands.") - .push_line("") - .push("SUBJECT CODENAME: ") - .push_line(&codename) - .push(global_data.print_game_status()) - ) - }).await?; - - channel.pin(&ctx.http, msg.id).await?; - let player_data = PlayerData { channel: channel.id.0, discord_id: discord_user.user.id.0, @@ -269,19 +257,12 @@ pub async fn add_user_to_game( profile_pic_url: global_data.get_profile_pic_url().await?, }; - global_data.game_state_mut()?.player_data.push(player_data); + global_data + .game_state_mut()? + .player_data + .push(player_data.clone()); - 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() + Ok(player_data) } pub async fn parse_duration_arg(args: &mut Args) -> error::Result { diff --git a/src/error.rs b/src/error.rs index dfa03b1..d097e8e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,7 @@ use crate::imgur::ImgurError; use serenity::prelude::SerenityError; use std::fmt::{Display, Formatter}; +use tera::Error; pub type Result = std::result::Result; @@ -15,6 +16,8 @@ pub enum WoxlfError { GameNotInProgress, HostWebhookError, ImgurError(ImgurError), + RanOutOfCodenames, + TemplateError(tera::Error), } impl std::error::Error for WoxlfError {} @@ -31,6 +34,11 @@ impl Display for WoxlfError { WoxlfError::GameNotInProgress => "A game is not currently in progress".to_string(), WoxlfError::HostWebhookError => "Unable to communicate to the host webhook".to_string(), WoxlfError::ImgurError(err) => format!("Imgur module error: {}", err), + WoxlfError::RanOutOfCodenames => { + "Ran out of codename combinations, add more first/last names to the config" + .to_string() + } + WoxlfError::TemplateError(e) => format!("Template error: {}", e), }; write!(f, "Woxlf Error: {}", msg) @@ -66,3 +74,9 @@ impl From for WoxlfError { Self::ImgurError(err) } } + +impl From for WoxlfError { + fn from(err: Error) -> Self { + Self::TemplateError(err) + } +} diff --git a/src/game/game_state.rs b/src/game/game_state.rs index cd44121..37b0f4b 100644 --- a/src/game/game_state.rs +++ b/src/game/game_state.rs @@ -11,6 +11,7 @@ use crate::game::Phase; #[derive(Debug, Deserialize, Serialize, Clone)] pub struct GameState { + pub game_name: String, pub phase_number: u64, pub current_phase: Phase, pub starting_phase: Phase, @@ -19,9 +20,10 @@ pub struct GameState { } impl GameState { - pub fn new(starting_phase: Phase, starting_phase_duration: Duration) -> Self { + pub fn new(starting_phase: Phase, starting_phase_duration: Duration, game_name: &str) -> Self { let phase_end_time = Utc::now() + starting_phase_duration; Self { + game_name: game_name.to_string(), phase_number: 1, current_phase: starting_phase, phase_end_time, @@ -30,17 +32,6 @@ impl GameState { } } - 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) } @@ -84,14 +75,6 @@ impl GameState { 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; } diff --git a/src/game/global_data.rs b/src/game/global_data.rs index a9d0837..3e41d17 100644 --- a/src/game/global_data.rs +++ b/src/game/global_data.rs @@ -1,22 +1,23 @@ 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::config::{BotConfig, GameConfig}; use crate::error::{Result, WoxlfError}; use crate::game::game_state::GameState; use crate::game::Phase; use crate::imgur::{get_album_images, Image}; +use crate::messages::MessageTemplates; use chrono::Duration; use rand::prelude::SliceRandom; -use serenity::utils::MessageBuilder; -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Clone)] pub struct GlobalData { pub cfg: BotConfig, + pub game_cfg: Option, + pub templates: Option, pub game_state: Option, } @@ -24,12 +25,29 @@ impl GlobalData { pub fn new(cfg: BotConfig) -> Self { Self { cfg, + game_cfg: None, + templates: None, 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 start_game( + &mut self, + game_name: &str, + starting_phase: Phase, + starting_phase_duration: Duration, + ) -> Result<()> { + let game_config = self.cfg.get_game_config(game_name).unwrap(); + + self.templates = Some(MessageTemplates::try_from(game_config.messages.clone())?); + self.game_cfg = Some(game_config); + self.game_state = Some(GameState::new( + starting_phase, + starting_phase_duration, + game_name, + )); + + Ok(()) } pub fn save_game_state(&mut self) -> Result<()> { @@ -59,6 +77,14 @@ impl GlobalData { self.game_state = Some(toml::from_str(&data)?); + let game_config = self + .cfg + .get_game_config(&self.game_state.as_ref().unwrap().game_name) + .unwrap(); + + self.templates = Some(MessageTemplates::try_from(game_config.messages.clone()).unwrap()); + self.game_cfg = Some(game_config); + Ok(()) } @@ -76,8 +102,23 @@ impl GlobalData { } } + pub fn templates(&self) -> Result<&MessageTemplates> { + match &self.templates { + None => Err(WoxlfError::GameNotInProgress), + Some(templates) => Ok(templates), + } + } + + pub fn game_cfg(&self) -> Result<&GameConfig> { + match &self.game_cfg { + None => Err(WoxlfError::GameNotInProgress), + Some(game_config) => Ok(game_config), + } + } pub fn clear_game_state(&mut self) -> Result<()> { self.game_state = None; + self.game_cfg = None; + self.templates = None; if self.game_state_exists() { std::fs::remove_file(self.cfg.get_game_state_path())?; @@ -86,33 +127,26 @@ impl GlobalData { 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() - } - } - pub async fn get_profile_pic_url(&self) -> Result { let images: Vec = get_album_images( &self.cfg.imgur_client_id, - &self.cfg.game_config.profile_album_hash, + &self.game_cfg()?.profile_album_hash, ) .await?; Ok(images.choose(&mut rand::thread_rng()).unwrap().link.clone()) } + + pub fn get_phase_name(&self) -> Result { + let game_cfg = self.game_cfg()?; + let state = self.game_state()?; + + Ok(if state.current_phase == Phase::Day { + game_cfg.vote_phase_name.clone() + } else { + game_cfg.enemy_phase_name.clone() + }) + } } impl TypeMapKey for GlobalData { diff --git a/src/game/mod.rs b/src/game/mod.rs index 6c5adde..1cbc3c7 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -1,11 +1,6 @@ -use std::fmt::{Display, Formatter}; - -use rand::thread_rng; use serde::{Deserialize, Serialize}; -use crate::config::BotConfig; use crate::game::player_data::PlayerData; -use rand::prelude::SliceRandom; pub mod game_state; pub mod global_data; @@ -23,32 +18,6 @@ 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) - } -} - -pub fn generate_codename(config: &BotConfig) -> String { - let occupation = &config - .game_config - .occupation - .choose(&mut thread_rng()) - .unwrap(); - let adj = &config - .game_config - .adjective - .choose(&mut thread_rng()) - .unwrap(); - - format!("{} {}", adj, occupation) -} - #[derive(Debug, Clone)] pub enum MessageSource { Player(Box), diff --git a/src/main.rs b/src/main.rs index 3ace8a1..a7640ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod discord; mod error; mod game; mod imgur; +mod messages; #[tokio::main] async fn main() { @@ -31,7 +32,7 @@ async fn main() { .expect("Unable to open saved game state."); } - let mut client = Client::builder(&bot_cfg.token) + let mut client = Client::builder(&bot_cfg.discord_config.token) .event_handler(Handler {}) .framework(command_framework()) .intents(GatewayIntents::all()) diff --git a/src/messages/mod.rs b/src/messages/mod.rs new file mode 100644 index 0000000..fe7c8e7 --- /dev/null +++ b/src/messages/mod.rs @@ -0,0 +1,172 @@ +use crate::config::MessageConfig; +use crate::game::player_data::PlayerData; +use crate::GlobalData; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serenity::model::guild::Member; +use serenity::prelude::Mentionable; +use std::collections::HashMap; +use tera::{Tera, Value}; + +fn time_to_discord_time( + time_flag: &str, +) -> Box) -> tera::Result + Send + Sync> { + let time_flag = time_flag.to_string(); + + Box::new( + move |args: &HashMap| -> tera::Result { + match args.get("time") { + Some(val) => match tera::from_value::(val.clone()) { + Ok(v) => { + let time = v.parse::>().unwrap(); + + Ok( + tera::to_value(format!("", time.timestamp(), time_flag)) + .unwrap(), + ) + } + Err(_) => Err("Failed to parse value as time".into()), + }, + None => Err("Missing parameter".into()), + } + }, + ) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscordUser { + display_name: String, + mention: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Tally { + target: String, + votes: u64, +} + +impl From<&Member> for DiscordUser { + fn from(m: &Member) -> Self { + Self { + display_name: m.display_name().to_string(), + mention: m.mention().to_string(), + } + } +} + +#[derive(Debug, Clone)] +pub struct MessageTemplates { + templates: Tera, +} + +impl MessageTemplates { + pub fn build_welcome_message( + &self, + global_data: &GlobalData, + discord_user: &DiscordUser, + player_data: &PlayerData, + ) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("game_state", global_data.game_state().unwrap()); + context.insert("discord_user", discord_user); + context.insert("player_data", player_data); + let mut msg = self.templates.render("welcome_message", &context)?; + msg.push_str(&self.templates.render("status_message", &context)?); + + Ok(msg) + } + + pub fn build_satus_message(&self, global_data: &GlobalData) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("game_state", global_data.game_state().unwrap()); + self.templates.render("status_message", &context) + } + + pub fn build_announcement( + &self, + global_data: &GlobalData, + message: &str, + ) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("game_state", global_data.game_state().unwrap()); + context.insert("message", message); + self.templates.render("announcement", &context) + } + + pub fn build_vote_tally(&self, global_data: &GlobalData) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("game_state", global_data.game_state().unwrap()); + context.insert( + "tallies", + &global_data.game_state().unwrap().get_vote_tallies(), + ); + + self.templates.render("tally_message", &context) + } + + pub fn build_vote_message( + &self, + global_data: &GlobalData, + player: &PlayerData, + target: &PlayerData, + ) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("game_state", global_data.game_state().unwrap()); + context.insert("player_data", player); + context.insert("target_data", target); + + self.templates.render("vote_message", &context) + } + + pub fn build_phase_extend_message( + &self, + global_data: &GlobalData, + ) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("game_state", global_data.game_state().unwrap()); + + self.templates.render("phase_extend_message", &context) + } + + pub fn build_name( + &self, + global_data: &GlobalData, + first_name: Option, + last_name: Option, + ) -> Result { + let mut context = tera::Context::new(); + context.insert("game_cfg", global_data.game_cfg().unwrap()); + context.insert("first_name", &first_name); + context.insert("last_name", &last_name); + + self.templates.render("name_format", &context) + } +} + +impl TryFrom for MessageTemplates { + type Error = tera::Error; + + fn try_from(config: MessageConfig) -> Result { + let mut templates = Tera::default(); + + templates.register_function("to_countdown", time_to_discord_time("R")); + templates.register_function("to_local_time", time_to_discord_time("f")); + + templates.add_raw_template("welcome_message", &config.welcome_message)?; + templates.add_raw_template("status_message", &config.status_message)?; + templates.add_raw_template("announcement", &config.announcement_format)?; + templates.add_raw_template("tally_message", &config.tally_message)?; + templates.add_raw_template("vote_message", &config.vote_message)?; + templates.add_raw_template("phase_extend_message", &config.phase_extend_message)?; + + templates.add_raw_template("name_format", &config.name_format)?; + + Ok(Self { templates }) + } +}