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
msg_refactor
Joey Hines 2022-05-28 16:31:41 -06:00
parent 2e5c102887
commit 230930a23c
No known key found for this signature in database
GPG Key ID: 80F567B5C968F91B
12 changed files with 771 additions and 191 deletions

278
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

103
README.md
View File

@ -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 <Adjective> <Occupation>
# 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

View File

@ -13,22 +13,44 @@ pub struct Args {
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct GameConfig {
pub occupation: Vec<String>,
pub adjective: Vec<String>,
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<String>,
pub last_name: Vec<String>,
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<GameConfig>,
}
impl BotConfig {
@ -40,6 +62,14 @@ impl BotConfig {
cfg.try_deserialize()
}
pub fn get_game_config(&self, game_name: &str) -> Option<GameConfig> {
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")
}

View File

@ -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::<String>()?;
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<Vec<&Member>> = args
.iter::<String>()
@ -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::<GlobalData>().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::<GlobalData>().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()));

View File

@ -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<String>,
last_name: Option<String>,
) -> error::Result<PlayerData> {
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<PhaseDuration> {

View File

@ -1,6 +1,7 @@
use crate::imgur::ImgurError;
use serenity::prelude::SerenityError;
use std::fmt::{Display, Formatter};
use tera::Error;
pub type Result<T> = std::result::Result<T, WoxlfError>;
@ -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<ImgurError> for WoxlfError {
Self::ImgurError(err)
}
}
impl From<tera::Error> for WoxlfError {
fn from(err: Error) -> Self {
Self::TemplateError(err)
}
}

View File

@ -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!("<t:{}:f>", self.phase_end_time.timestamp())
}
pub fn get_phase_countdown(&self) -> String {
format!("<t:{}:R>", self.phase_end_time.timestamp())
}
pub fn add_time_to_phase(&mut self, duration: Duration) {
self.phase_end_time = self.phase_end_time + duration;
}

View File

@ -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<GameConfig>,
pub templates: Option<MessageTemplates>,
pub game_state: Option<GameState>,
}
@ -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<String> {
let images: Vec<Image> = 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<String> {
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 {

View File

@ -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<PlayerData>),

View File

@ -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())

172
src/messages/mod.rs 100644
View File

@ -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<dyn Fn(&HashMap<String, Value>) -> tera::Result<Value> + Send + Sync> {
let time_flag = time_flag.to_string();
Box::new(
move |args: &HashMap<String, Value>| -> tera::Result<Value> {
match args.get("time") {
Some(val) => match tera::from_value::<String>(val.clone()) {
Ok(v) => {
let time = v.parse::<DateTime<Utc>>().unwrap();
Ok(
tera::to_value(format!("<t:{}:{}>", 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<String, tera::Error> {
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<String, tera::Error> {
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<String, tera::Error> {
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<String, tera::Error> {
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<String, tera::Error> {
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<String, tera::Error> {
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<String>,
last_name: Option<String>,
) -> Result<String, tera::Error> {
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<MessageConfig> for MessageTemplates {
type Error = tera::Error;
fn try_from(config: MessageConfig) -> Result<Self, Self::Error> {
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 })
}
}