Added remaining bare minimum functionality

+ Added phase handling and player termination
+ Fixed an issue with some commands not saving game data
+ Added game_status command
+ Updated readme with commands and new features
+ clippy + fmt
msg_refactor
Joey Hines 2022-03-06 12:48:11 -07:00
parent e72d603149
commit 10fa6f6906
No known key found for this signature in database
GPG Key ID: 80F567B5C968F91B
4 changed files with 500 additions and 89 deletions

View File

@ -5,10 +5,31 @@ Discord bot for managing an anonymous [Werewolf Game](https://en.wikipedia.org/w
A host gets a list of players to play, and then begins the game with the `!start` command.
Each player is assigned a channel where they will view the game through. The player can read and send messages
in this channel normally. When a message is sent, it is forwarded to all other channels. The message's author
is obscured by a codename.
in this channel normally. When a message is sent, it is forwarded to all other player channels. The message's author
is obscured by a codename. During day phases, players can cast a vote for who they wish to "terminate" that day.
The bot also handles communications from the host, daily votes, and notifying players of deaths.
The game proceeds as a normal Werewolf game.
## Channels
* Host Channel: Used by the host to interact with the game, can also be used by spectators to see the game state.
* Real player names are displayed along with code names in this channel
* Player Channel: A channel for a single player, allows them to chat with the other players and run commands.
* Vote Channel: Contains all the votes made in a game.
## Commands
## Host
* `!start <list of player ids>` - starts the game
* `!end` - Ends the current game
* `!say <msg>` - Allows the host to speak into the game chat
* `!broadcast <msg>` - Broadcasts a system message, this message is then pinned in each player channel
* `!next_phase <duration> <msg>` - Send the next phase message. Also cycles the phase
* `!terminate <player>` - Kills a player and removes them from the game
* `!add_time <duration>` - Adds more time to the current game
## Players
* `!vote <player>` - Casts a vote for a player to be terminated. Only can be used during the day phase
* `!status` - Get the current game status. Includes time left in the phase and current vote tallies
## Example Config
```toml
@ -18,8 +39,12 @@ token = ""
app_id = 0
# Channel to accept host commands from
host_channel = 1
# Channel to put vote status messages in
vote_channel = 2
# Category to crate the player cannels in
category = 2
category = 3
# Directory to save game data into, game data will be saved as "wOxlf.toml"
game_state_dir = "."
# Random code names are generated in the format of <Adjective> <Occupation>

View File

@ -1,87 +1,22 @@
use crate::data::{BotConfig, GlobalData, MessageSource, PlayerData};
use crate::helper::{clear_game_state, save_game_state, send_msg_to_player_channels};
use rand::Rng;
use serenity::framework::standard::macros::{command, group};
use serenity::framework::standard::{Args, CommandResult};
use serenity::framework::StandardFramework;
use serenity::model::channel::{PermissionOverwrite, PermissionOverwriteType};
use serenity::model::guild::{Guild, Member};
use serenity::model::id::ChannelId;
use serenity::model::prelude::{Message, UserId};
use serenity::model::Permissions;
use serenity::prelude::Context;
use serenity::utils::MessageBuilder;
use crate::data::{GlobalData, MessageSource, Phase};
use crate::helper;
use crate::helper::{
build_system_message, clear_game_state, get_phase_end_timestamp, print_game_status,
save_game_state, send_msg_to_player_channels,
};
#[group]
#[commands(start, say, end, broadcast)]
#[commands(start, say, end, broadcast, next_phase, terminate, add_time)]
struct Host;
fn generate_codename(config: &BotConfig) -> String {
let mut rng = rand::thread_rng();
let occupation = &config.occupation[rng.gen_range(0..config.occupation.len())];
let adj = &config.adjective[rng.gen_range(0..config.adjective.len())];
format!("{} {}", adj, occupation)
}
async fn add_user_to_game(
ctx: &Context,
guild: &Guild,
global_data: &mut GlobalData,
discord_user: &Member,
) -> CommandResult {
let mut codename = generate_codename(&global_data.cfg);
while global_data.game_state.codename_exists(&codename) {
codename = generate_codename(&global_data.cfg);
}
let channel = guild
.create_channel(&ctx.http, |c| {
c.category(&ChannelId::from(global_data.cfg.category))
.name(format!("{}'s Channel", discord_user.display_name()))
})
.await?;
let allow = Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY
| Permissions::READ_MESSAGE_HISTORY;
let overwrite = PermissionOverwrite {
allow,
deny: Default::default(),
kind: PermissionOverwriteType::Member(discord_user.user.id),
};
channel.create_permission(&ctx.http, &overwrite).await?;
let msg = channel.send_message(&ctx.http, |m| {
m.content(MessageBuilder::new()
.push("Welcome ")
.mention(discord_user)
.push_line(" to your WOxlf Terminal. You may use this terminal to communicate to other subjects.")
.push_line("You will also use this terminal for choosing one of your fellow subjects for termination.")
.push_line("Happy testing :)")
.push_line("")
.push("SUBJECT CODENAME: ")
.push_line(&codename)
)
}).await?;
channel.pin(&ctx.http, msg.id).await?;
let player_data = PlayerData {
channel: channel.id.0,
discord_id: discord_user.user.id.0,
codename,
};
global_data.game_state.player_data.push(player_data);
Ok(())
}
#[command]
#[only_in(guilds)]
#[allowed_roles("wolfx host")]
@ -96,9 +31,21 @@ async fn start(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
clear_game_state(&mut global_data).unwrap();
let duration = match args.single::<u64>() {
Ok(d) => d,
Err(_) => {
msg.reply(&ctx.http, "Error parsing phase duration!")
.await
.unwrap();
return Ok(());
}
};
global_data.game_state.phase_end_time = get_phase_end_timestamp(duration);
for player in args.iter::<u64>().flatten() {
if let Some(discord_user) = guild.members.get(&UserId::from(player)) {
add_user_to_game(ctx, &guild, &mut global_data, discord_user).await?;
helper::add_user_to_game(ctx, &guild, &mut global_data, discord_user).await?;
} else {
msg.reply(
&ctx.http,
@ -136,6 +83,7 @@ async fn end(ctx: &Context, msg: &Message, mut _args: Args) -> CommandResult {
clear_game_state(&mut global_data).unwrap();
msg.reply(&ctx.http, "Game ended!").await.unwrap();
Ok(())
}
@ -166,21 +114,281 @@ async fn broadcast(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let global_data = global_data.lock().await;
let msg = MessageBuilder::new()
.push_bold_line("\\*\\*IMPORTANT wOxlf SYSTEM MESSAGE\\*\\*")
.push_line("")
.push_line(args.rest())
.push_line("")
.push_bold_line("\\*\\*END OF SYSTEM MESSAGE\\*\\*")
.build();
let msg = build_system_message(args.rest());
send_msg_to_player_channels(ctx, &guild, &global_data, MessageSource::Host, &msg, true).await;
Ok(())
}
#[command]
#[only_in(guilds)]
#[allowed_roles("wolfx host")]
async fn next_phase(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await;
let duration = match args.single::<u64>() {
Ok(d) => d,
Err(_) => {
msg.reply(&ctx.http, "Error parsing phase duration!")
.await
.unwrap();
return Ok(());
}
};
global_data.game_state.next_phase();
global_data.game_state.phase_end_time = get_phase_end_timestamp(duration);
let broadcast = MessageBuilder::new()
.push_line(args.rest())
.push_line("")
.push(print_game_status(&global_data.game_state))
.build();
let broadcast = build_system_message(&broadcast);
send_msg_to_player_channels(
ctx,
&guild,
&global_data,
MessageSource::Host,
&broadcast,
true,
)
.await;
if global_data.game_state.current_phase == Phase::Day {
let vote_channel = guild
.channels
.get(&ChannelId::from(global_data.cfg.vote_channel))
.unwrap();
vote_channel
.send_message(&ctx.http, |m| {
m.content(format!(
"**DAY {} VOTES:**",
global_data.game_state.phase_number
))
})
.await
.unwrap();
}
msg.reply(
&ctx.http,
format!(
"Phase has been cycled to {}.",
&global_data.game_state.current_phase
),
)
.await
.unwrap();
save_game_state(&global_data).unwrap();
Ok(())
}
#[command]
#[only_in(guilds)]
#[allowed_roles("wolfx host")]
async fn terminate(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await;
let target = args.rest().to_lowercase();
let index = global_data
.game_state
.player_data
.iter()
.position(|p| p.codename.to_lowercase() == target);
if let Some(index) = index {
let player = global_data.game_state.player_data.remove(index);
let player_channel = guild
.channels
.get(&ChannelId::from(player.channel))
.unwrap();
player_channel.delete(&ctx.http).await.unwrap();
msg.reply(
&ctx.http,
format!("{} has been terminated.", player.codename),
)
.await
.unwrap();
} else {
msg.reply(&ctx.http, "No subject found with that codename.")
.await
.unwrap();
}
save_game_state(&global_data).unwrap();
Ok(())
}
#[command]
#[only_in(guilds)]
#[allowed_roles("wolfx host")]
async fn add_time(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await;
global_data.game_state.next_phase();
let duration = match args.single::<u64>() {
Ok(d) => d,
Err(_) => {
msg.reply(&ctx.http, "Error parsing phase duration!")
.await
.unwrap();
return Ok(());
}
};
global_data.game_state.add_time_to_phase(duration);
let broadcast = MessageBuilder::new()
.push_line("EXPERIMENT PHASE HAS BEEN EXTENDED!!!")
.push_line("")
.push(print_game_status(&global_data.game_state))
.build();
let broadcast = build_system_message(&broadcast);
send_msg_to_player_channels(
ctx,
&guild,
&global_data,
MessageSource::Host,
&broadcast,
true,
)
.await;
msg.reply(&ctx.http, "Phase has been updated")
.await
.unwrap();
save_game_state(&global_data).unwrap();
Ok(())
}
#[group]
#[commands(vote, status)]
struct Player;
#[command]
#[only_in(guilds)]
async fn vote(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
let mut data = ctx.data.write().await;
let global_data = data.get_mut::<GlobalData>().unwrap();
let guild = msg.guild(&ctx.cache).await.unwrap();
let mut global_data = global_data.lock().await;
if global_data.game_state.current_phase != Phase::Day {
msg.reply(
&ctx.http,
"You can only select subject for termination during the day!",
)
.await
.unwrap();
return Ok(());
}
if global_data
.game_state
.get_player_from_channel(msg.channel_id.0)
.is_some()
{
let target_player = global_data.game_state.get_player_by_codename(args.rest());
if let Some(target_player) = target_player {
let vote_channel = guild
.channels
.get(&ChannelId::from(global_data.cfg.vote_channel))
.unwrap();
let player_data = global_data
.game_state
.get_player_from_channel_mut(msg.channel_id.0)
.unwrap();
player_data.vote_target = Some(target_player.channel);
vote_channel
.send_message(&ctx.http, |m| {
m.content(format!(
"{} has selected {} for termination",
&player_data.codename, target_player.codename
))
})
.await
.unwrap();
} else {
msg.reply(&ctx.http, "Subject not found!").await.unwrap();
}
} else {
msg.reply(
&ctx.http,
"This command needs to be run in a game channel, goober",
)
.await
.unwrap();
}
save_game_state(&global_data).unwrap();
Ok(())
}
#[command]
#[only_in(guilds)]
async fn status(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
let data = ctx.data.read().await;
let global_data = data.get::<GlobalData>().unwrap();
let global_data = global_data.lock().await;
let mut msg_builder = MessageBuilder::new();
msg_builder.push(print_game_status(&global_data.game_state));
if global_data.game_state.current_phase == Phase::Day {
msg_builder.push_line("");
let vote_tallies = global_data.game_state.get_vote_tallies();
if vote_tallies.is_empty() {
msg_builder.push_line("NO TERMINATION VOTES HAVE BEEN CAST");
} else {
msg_builder.push_line("TERMINATION VOTE TALLIES:");
for (player, tally) in global_data.game_state.get_vote_tallies() {
msg_builder.push_line(format!("{}: {}", player, tally));
}
}
}
msg.reply(&ctx.http, msg_builder.build()).await.unwrap();
Ok(())
}
pub fn command_framework() -> StandardFramework {
StandardFramework::new()
.configure(|c| c.prefix("!"))
.group(&HOST_GROUP)
.group(&PLAYER_GROUP)
}

View File

@ -1,6 +1,8 @@
use config::{Config, File};
use serde::{Deserialize, Serialize};
use serenity::prelude::TypeMapKey;
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@ -18,6 +20,7 @@ pub struct BotConfig {
pub token: String,
pub app_id: u64,
pub host_channel: u64,
pub vote_channel: u64,
pub category: u64,
pub game_state_dir: PathBuf,
pub occupation: Vec<String>,
@ -38,7 +41,7 @@ impl BotConfig {
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)]
pub enum Phase {
Day,
Night,
@ -50,16 +53,30 @@ impl Default for Phase {
}
}
impl Display for Phase {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let phase_name = match self {
Self::Day => "Day",
Self::Night => "Night",
};
write!(f, "{}", phase_name)
}
}
#[derive(Debug, Deserialize, Serialize, Clone, Default, Hash)]
pub struct PlayerData {
pub channel: u64,
pub discord_id: u64,
pub codename: String,
pub vote_target: Option<u64>,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
pub struct GameState {
pub phase_number: u64,
pub current_phase: Phase,
pub phase_end_time: u64,
pub player_data: Vec<PlayerData>,
}
@ -73,11 +90,66 @@ impl GameState {
pub fn clear(&mut self) {
self.player_data.clear();
self.current_phase = Phase::Night;
self.phase_end_time = 0;
self.phase_number = 1;
}
pub fn get_player_from_channel(&self, channel_id: u64) -> Option<&PlayerData> {
self.player_data.iter().find(|p| p.channel == channel_id)
}
pub fn get_player_from_channel_mut(&mut self, channel_id: u64) -> Option<&mut PlayerData> {
self.player_data
.iter_mut()
.find(|p| p.channel == channel_id)
}
pub fn get_player_by_codename(&self, codename: &str) -> Option<PlayerData> {
self.player_data
.iter()
.find(|p| p.codename.to_lowercase() == codename.to_lowercase())
.cloned()
}
pub fn next_phase(&mut self) {
if self.current_phase == Phase::Night {
self.current_phase = Phase::Day
} else {
self.phase_number += 1;
self.current_phase = Phase::Night
}
for mut player in &mut self.player_data {
player.vote_target = None
}
}
pub fn get_phase_end_time(&self) -> String {
format!("<t:{}:f>", self.phase_end_time)
}
pub fn get_phase_countdown(&self) -> String {
format!("<t:{}:R>", self.phase_end_time)
}
pub fn add_time_to_phase(&mut self, hours: u64) {
self.phase_end_time += hours * 60 * 60;
}
pub fn get_vote_tallies(&self) -> HashMap<String, u32> {
let mut vote_set: HashMap<String, u32> = HashMap::new();
for player in &self.player_data {
if let Some(vote_target) = player.vote_target {
let target = self.get_player_from_channel(vote_target);
if let Some(target) = target {
*vote_set.entry(target.codename.clone()).or_insert(0) += 1;
}
}
}
vote_set
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]

View File

@ -1,10 +1,19 @@
use crate::data::{GlobalData, MessageSource};
use std::fs::File;
use std::io::prelude::*;
use rand::Rng;
use serenity::framework::standard::CommandResult;
use serenity::model::channel::{PermissionOverwrite, PermissionOverwriteType};
use serenity::model::guild::Member;
use serenity::model::id::UserId;
use serenity::model::prelude::ChannelId;
use serenity::model::prelude::Guild;
use serenity::model::Permissions;
use serenity::prelude::Context;
use std::fs::File;
use std::io::prelude::*;
use serenity::utils::MessageBuilder;
use crate::data::{BotConfig, GameState, GlobalData, MessageSource, PlayerData};
use std::time::UNIX_EPOCH;
pub async fn send_msg_to_player_channels(
ctx: &Context,
@ -97,3 +106,100 @@ pub fn clear_game_state(global_data: &mut GlobalData) -> std::io::Result<()> {
Ok(())
}
pub fn generate_codename(config: &BotConfig) -> String {
let mut rng = rand::thread_rng();
let occupation = &config.occupation[rng.gen_range(0..config.occupation.len())];
let adj = &config.adjective[rng.gen_range(0..config.adjective.len())];
format!("{} {}", adj, occupation)
}
pub async fn add_user_to_game(
ctx: &Context,
guild: &Guild,
global_data: &mut GlobalData,
discord_user: &Member,
) -> CommandResult {
let mut codename = generate_codename(&global_data.cfg);
while global_data.game_state.codename_exists(&codename) {
codename = generate_codename(&global_data.cfg);
}
let channel = guild
.create_channel(&ctx.http, |c| {
c.category(&ChannelId::from(global_data.cfg.category))
.name(format!("{}'s Channel", discord_user.display_name()))
})
.await?;
let allow = Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY
| Permissions::READ_MESSAGE_HISTORY;
let overwrite = PermissionOverwrite {
allow,
deny: Default::default(),
kind: PermissionOverwriteType::Member(discord_user.user.id),
};
channel.create_permission(&ctx.http, &overwrite).await?;
let msg = channel.send_message(&ctx.http, |m| {
m.content(MessageBuilder::new()
.push("Welcome ")
.mention(discord_user)
.push_line(" to your WOxlf Terminal. You may use this terminal to communicate to other subjects.")
.push_line("You will also use this terminal for choosing one of your fellow subjects for termination.")
.push_line("Happy testing :)")
.push_line("")
.push("SUBJECT CODENAME: ")
.push_line(&codename)
.push(print_game_status(&global_data.game_state))
)
}).await?;
channel.pin(&ctx.http, msg.id).await?;
let player_data = PlayerData {
channel: channel.id.0,
discord_id: discord_user.user.id.0,
vote_target: None,
codename,
};
global_data.game_state.player_data.push(player_data);
Ok(())
}
pub fn build_system_message(msg: &str) -> String {
MessageBuilder::new()
.push_bold_line("\\*\\*IMPORTANT wOxlf SYSTEM MESSAGE\\*\\*")
.push_line("")
.push_line(msg)
.push_line("")
.push_bold_line("\\*\\*END OF SYSTEM MESSAGE\\*\\*")
.build()
}
pub fn print_game_status(game_state: &GameState) -> String {
MessageBuilder::new()
.push_line(format!(
"CURRENT EXPERIMENT PHASE: {} {}",
game_state.current_phase, game_state.phase_number
))
.push_line(format!(
"PHASE END TIME: {}",
game_state.get_phase_end_time()
))
.push_line(format!("PHASE ENDING {}", game_state.get_phase_countdown()))
.build()
}
pub fn get_phase_end_timestamp(hours: u64) -> u64 {
let end_time = std::time::SystemTime::now() + std::time::Duration::from_secs(hours * 60 * 60);
end_time.duration_since(UNIX_EPOCH).unwrap().as_secs()
}