diff --git a/discord/src/account.rs b/discord/src/account.rs index 50ebb03..bc433e3 100644 --- a/discord/src/account.rs +++ b/discord/src/account.rs @@ -1,4 +1,5 @@ -use squad_quest::{account::Account, config::Config}; +use poise::serenity_prelude::UserId; +use squad_quest::{account::Account, config::Config, map::Map}; pub fn fetch_or_init_account(conf: &Config, id: String) -> Account { let accounts = conf.load_accounts(); @@ -10,3 +11,23 @@ pub fn fetch_or_init_account(conf: &Config, id: String) -> Account { }, } } + +pub fn account_rooms_value(account: &Account, map: &Map) -> u32 { + map.room.iter().filter_map(|r| { + if account.rooms_unlocked.contains(&r.id) { + Some(r.value) + } else { + None + } + }) + .sum() +} + +pub fn account_full_balance(account: &Account, map: &Map) -> u32 { + let rooms_value = account_rooms_value(account, map); + account.balance + rooms_value +} + +pub fn account_user_id(account: &Account) -> UserId { + UserId::new(account.id.clone().parse::().expect("automatically inserted")) +} diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 5027f79..ebaede1 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::UserId; use squad_quest::{SquadObject, account::Account, map::Map}; -use crate::{Context, Error, account::fetch_or_init_account}; +use crate::{Context, Error, account::{account_full_balance, account_rooms_value, account_user_id, fetch_or_init_account}}; async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) -> String { let rooms_value = account_rooms_value(account, map); @@ -20,26 +20,6 @@ async fn account_balance_string(ctx: &Context<'_>, account: &Account, map: &Map) ) } -fn account_rooms_value(account: &Account, map: &Map) -> u32 { - map.room.iter().filter_map(|r| { - if account.rooms_unlocked.contains(&r.id) { - Some(r.value) - } else { - None - } - }) - .sum() -} - -fn account_full_balance(account: &Account, map: &Map) -> u32 { - let rooms_value = account_rooms_value(account, map); - account.balance + rooms_value -} - -fn account_user_id(account: &Account) -> UserId { - UserId::new(account.id.clone().parse::().expect("automatically inserted")) -} - #[poise::command( prefix_command, slash_command, diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index 608ee48..bc59244 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -21,12 +21,10 @@ pub async fn register(ctx: Context<'_>) -> Result<(), Error> { slash_command, )] pub async fn info(ctx: Context<'_>) -> Result<(), Error> { - let reply_string = format!("\ - SquadQuest version {ver}\n\ - Find the map here: {url}", - ver = env!("CARGO_PKG_VERSION"), - url = "not implemented yet!", - ); + let strings = &ctx.data().strings; + let formatter = strings.formatter(); + let reply_string = formatter.fmt(&strings.info); + ctx.say(reply_string).await?; Ok(()) } diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index af3af5a..fd6c8b6 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -1,4 +1,4 @@ -use std::{future, path::Path, str::FromStr}; +use std::{future, str::FromStr}; use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt}; use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}}; @@ -24,16 +24,10 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result Ok(messages.first().cloned()) } -fn make_quest_message_content(quest: &Quest) -> String { - format!("### `#{id}` {name} (+{reward})\n\ - Difficulty: *{difficulty:?}*\n\ - {description}", - id = quest.id, - name = quest.name, - reward = quest.reward, - difficulty = quest.difficulty, - description = quest.description, - ) +fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(quest); + formatter.fmt(&strings.quest.message_format) } #[poise::command( @@ -60,13 +54,12 @@ pub async fn list( ) -> Result<(), Error> { let conf = &ctx.data().config; let quests = conf.load_quests(); - let mut reply_string = format!("Listing {} quests:", quests.len()); + let strings = &ctx.data().strings; + let mut formatter = strings.formatter().value(quests.len()); + let mut reply_string = formatter.fmt(&strings.quest.list); for quest in quests { - reply_string.push_str(format!("\n#{}: {}\n\tDescription: {}", - quest.id, - quest.name, - quest.description, - ).as_str()); + formatter = formatter.quest(&quest); + reply_string.push_str(formatter.fmt(&strings.quest.list_item).as_str()); } ctx.reply(reply_string).await?; Ok(()) @@ -168,7 +161,10 @@ pub async fn create( let path = conf.full_quests_path(); quest.save(path)?; - let reply_string = format!("Created quest #{}", quest.id); + + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&quest); + let reply_string = formatter.fmt(&strings.quest.create); ctx.reply(reply_string).await?; @@ -228,7 +224,6 @@ pub async fn update( }, } - let new_quest = Quest { id, difficulty, @@ -241,23 +236,25 @@ pub async fn update( deadline: dead_line, }; + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&new_quest); + if new_quest.public { - let content = make_quest_message_content(&new_quest); + let content = make_quest_message_content(ctx, &new_quest); let builder = EditMessage::new().content(content); let message = find_quest_message(ctx, id).await?; if let Some(mut message) = message { message.edit(ctx, builder).await?; } else { - let reply_string = format!("Quest #{id} is public, but its message was not found in the quest channel", - ); + let reply_string = formatter.fmt(&strings.quest.message_not_found); ctx.reply(reply_string).await?; } } let path = conf.full_quests_path(); new_quest.save(path)?; - let reply_string = format!("Updated quest #{id}"); + let reply_string = formatter.fmt(&strings.quest.update); ctx.reply(reply_string).await?; Ok(()) @@ -286,7 +283,7 @@ pub async fn publish( quest.public = true; - let content = make_quest_message_content(&quest); + let content = make_quest_message_content(ctx, &quest); let builder = CreateMessage::new() .content(content); @@ -297,19 +294,15 @@ pub async fn publish( guard.quests_channel }; - let message = channel.send_message(ctx, builder).await?; - - { - let mut guard = dc.lock().expect("shouldn't be locked"); - guard.quests_messages.push(message.id); - let path = ctx.data().config.full_impl_path().unwrap(); - guard.save(path.parent().unwrap_or(Path::new("")).to_owned())? - }; + channel.send_message(ctx, builder).await?; let quests_path = ctx.data().config.full_quests_path(); quest.save(quests_path)?; - let reply_string = format!("Published quest #{id}"); + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&quest); + + let reply_string = formatter.fmt(&strings.quest.publish); ctx.reply(reply_string).await?; Ok(()) @@ -342,7 +335,15 @@ pub async fn delete( account.save(accounts_path.clone())?; } - let reply_string = format!("Successfully deleted quest #{id}"); + let mock_quest = Quest { + id, + ..Default::default() + }; + + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&mock_quest); + + let reply_string = formatter.fmt(&strings.quest.delete); ctx.reply(reply_string).await?; Ok(()) diff --git a/discord/src/config.rs b/discord/src/config.rs index fee6498..cb82169 100644 --- a/discord/src/config.rs +++ b/discord/src/config.rs @@ -1,35 +1,55 @@ use std::{io::Write, path::{Path, PathBuf}}; -use poise::serenity_prelude::{ChannelId, GuildId, MessageId}; +use poise::serenity_prelude::{ChannelId, GuildId}; use serde::{Serialize, Deserialize}; use squad_quest::{SquadObject, config::Config, error::Error}; -#[derive(Serialize, Deserialize, Default, Clone, Debug)] +use crate::strings::Strings; + +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct DiscordConfig { pub guild: GuildId, pub quests_channel: ChannelId, pub answers_channel: ChannelId, - pub quests_messages: Vec, + pub strings_path: PathBuf, +} + +impl Default for DiscordConfig { + fn default() -> Self { + Self { + guild: GuildId::default(), + quests_channel: ChannelId::default(), + answers_channel: ChannelId::default(), + strings_path: "strings.toml".into(), + } + } } pub trait ConfigImpl { - fn discord_impl(&self) -> Result; + fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error>; fn init_impl(&self) -> Result<(), Error>; } impl ConfigImpl for Config { - fn discord_impl(&self) -> Result { - let Some(path) = &self.full_impl_path() else { + fn discord_impl(&self) -> Result<(DiscordConfig, Strings), Error> { + let Some(path) = self.full_impl_path() else { return Err(Error::IsNotImplemented); }; - DiscordConfig::load(path.clone()) + let discord = DiscordConfig::load(path.clone())?; + let mut strings_path: PathBuf = path.parent().unwrap_or(Path::new("")).to_owned(); + strings_path.push(discord.strings_path.clone()); + let strings = Strings::load(strings_path)?; + Ok((discord, strings)) } fn init_impl(&self) -> Result<(), Error> { let Some(path) = self.full_impl_path() else { return Err(Error::IsNotImplemented); }; + let folder = path.parent().unwrap_or(Path::new("")).to_owned(); let dc = DiscordConfig::default(); - dc.save(path.parent().unwrap_or(Path::new("")).to_owned()) + dc.save(folder.clone())?; + let strings = Strings::default(); + strings.save(folder) } } @@ -46,17 +66,8 @@ impl SquadObject for DiscordConfig { } } - fn delete(path: PathBuf) -> Result<(), Error> { - match Self::load(path.clone()) { - Ok(_) => { - if let Err(error) = std::fs::remove_file(path) { - return Err(Error::IoError(error)); - } - - Ok(()) - }, - Err(error) => Err(error) - } + fn delete(_path: PathBuf) -> Result<(), Error> { + unimplemented!() } fn save(&self, path: PathBuf) -> Result<(), Error> { diff --git a/discord/src/main.rs b/discord/src/main.rs index d37aa0d..70b6625 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -5,20 +5,23 @@ use dotenvy::dotenv; use poise::serenity_prelude as serenity; use squad_quest::config::Config; -use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error}; +use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings}; mod commands; mod cli; mod config; mod account; mod error; +mod strings; const CONFIG_PATH: &str = "cfg/config.toml"; +const DISCORD_TOKEN: &str = "DISCORD_TOKEN"; #[derive(Debug)] struct Data { pub config: Config, pub discord: Arc>, + pub strings: Strings, } type Context<'a> = poise::Context<'a, Data, Error>; @@ -28,12 +31,12 @@ async fn main() { let cli = cli::Cli::parse(); let config = Config::load(cli.config.clone().unwrap_or(CONFIG_PATH.into())); - let discord = config.discord_impl().unwrap_or_else(|_| { + let (discord, strings) = config.discord_impl().unwrap_or_else(|_| { config.init_impl().unwrap(); config.discord_impl().unwrap() }); - let token = std::env::var("DISCORD_TOKEN").expect("missing DISCORD_TOKEN"); + let token = std::env::var(DISCORD_TOKEN).expect("missing DISCORD_TOKEN"); let intents = serenity::GatewayIntents::non_privileged(); let framework = poise::Framework::builder() @@ -60,6 +63,7 @@ async fn main() { Ok(Data { config, discord: Arc::new(Mutex::new(discord)), + strings, }) }) }) diff --git a/discord/src/strings.rs b/discord/src/strings.rs new file mode 100644 index 0000000..d31c6e0 --- /dev/null +++ b/discord/src/strings.rs @@ -0,0 +1,287 @@ +use std::{collections::HashMap, fmt::Display, io::Write, path::PathBuf}; + +use poise::serenity_prelude::{Mentionable, User}; +use serde::{Deserialize, Serialize}; +use squad_quest::{SquadObject, account::Account, map::Map, quest::{Quest, QuestDifficulty}, error::Error}; + +use crate::account::{account_full_balance, account_rooms_value}; + +#[derive(Default, Debug, Clone)] +pub struct StringFormatter { + tags: HashMap, + difficulty: Difficulty, +} + +impl StringFormatter { + pub fn new() -> Self { + let newline = ("{n}".to_string(), '\n'.to_string()); + let version = ("{v}".to_string(), env!("CARGO_PKG_VERSION").to_string()); + let new_tags = vec![ newline, version ]; + + Self::default().with_tags(new_tags).to_owned() + } + + pub fn with_tags(mut self, tags: Vec<(String, String)>) -> Self { + for tag in tags { + self.tags.insert(tag.0, tag.1); + } + self + } + + pub fn strings(mut self, strings: &Strings) -> Self { + self.difficulty = strings.difficulty.clone(); + + let url = ("{url}".to_string(), strings.url.clone()); + let points = ("{pt}".to_string(), strings.points.clone()); + let new_tags = vec![ url, points ]; + + self.with_tags(new_tags) + } + + pub fn quest(self, quest: &Quest) -> Self { + let id = ("{q.id}".to_string(), id(quest.id)); + let difficulty = ("{q.difficulty}".to_string(), self.difficulty.as_string(&quest.difficulty)); + let reward = ("{q.reward}".to_string(), self.points(quest.reward.to_string())); + let name = ("{q.name}".to_string(), quest.name.clone()); + let description = ("{q.description}".to_string(), quest.description.clone()); + let answer = ("{q.answer}".to_string(), quest.answer.clone()); + let new_tags = vec![ id, difficulty, reward, name, description, answer ]; + + self.with_tags(new_tags) + } + + pub fn user(self, user: &User) -> Self { + let mention = ("{u.mention}".to_string(), user.mention().to_string()); + let name = ("{u.name}".to_string(), user.display_name().to_string()); + let new_tags = vec![ mention, name ]; + + self.with_tags(new_tags) + } + + pub fn balance(self, account: &Account, map: &Map) -> Self { + let balance = ("{b.current}".to_string(), self.points(account.balance)); + let full_balance = ( + "{b.full}".to_string(), + self.points(account_full_balance(account, map)), + ); + let rooms_balance = ( + "{b.rooms}".to_string(), + self.points(account_rooms_value(account, map)), + ); + let new_tags = vec![ balance, full_balance, rooms_balance ]; + + self.with_tags(new_tags) + } + + pub fn text(self, text: impl ToString) -> Self { + let text = ("{text}".to_string(), text.to_string()); + + self.with_tags(vec![text]) + } + + pub fn value(self, value: impl ToString) -> Self { + let value = ("{value}".to_string(), value.to_string()); + + self.with_tags(vec![value]) + } + + + fn points(&self, str: impl Display) -> String { + let template = format!("{str} {pt}", pt = "{pt}"); + self.fmt(&template) + } + + pub fn fmt(&self, string: &str) -> String { + let mut formatted = string.to_string(); + for (tag, replacement) in self.tags.iter() { + formatted = formatted.replace(tag, replacement); + } + formatted + } +} + +fn id(str: impl Display) -> String { + format!("#{str}") +} + + +#[derive(Deserialize, Serialize, Debug)] +pub struct Strings { + pub url: String, + pub points: String, + pub info: String, + pub answer: Answer, + pub difficulty: Difficulty, + pub scoreboard: Scoreboard, + pub quest: QuestStrings, +} + +impl Default for Strings { + fn default() -> Self { + Self { + url: "not implemented!".to_string(), + points: "points".to_string(), + info: "SquadQuest version {v}\ + {n}Find the map here: {url}".to_string(), + answer: Answer::default(), + difficulty: Difficulty::default(), + scoreboard: Scoreboard::default(), + quest: QuestStrings::default(), + } + } +} + +impl SquadObject for Strings { + fn load(path: PathBuf) -> Result { + match std::fs::read_to_string(path) { + Ok(string) => { + match toml::from_str::(&string) { + Ok(object) => Ok(object), + Err(error) => Err(Error::TomlDeserializeError(error)) + } + }, + Err(error) => Err(Error::IoError(error)) + } + } + + fn delete(_path: PathBuf) -> Result<(), Error> { + unimplemented!() + } + + fn save(&self, path: PathBuf) -> Result<(), Error> { + let filename = "strings.toml".to_string(); + let mut full_path = path; + full_path.push(filename); + + let str = match toml::to_string_pretty(&self) { + Ok(string) => string, + Err(error) => { + return Err(Error::TomlSerializeError(error)); + } + }; + + let mut file = match std::fs::File::create(full_path) { + Ok(f) => f, + Err(error) => { + return Err(Error::IoError(error)); + } + }; + + if let Err(error) = file.write_all(str.as_bytes()) { + return Err(Error::IoError(error)); + } + + Ok(()) + } +} + +impl Strings { + pub fn formatter(&self) -> StringFormatter { + StringFormatter::new().strings(self).to_owned() + } +} + + +#[derive(Deserialize, Serialize, Debug)] +#[serde(default)] +pub struct Answer { + pub from: String, + pub quest: String, + pub expected: String, + pub text: String, + pub attachment_notice: String, + pub accepted_by: String, + pub rejected_by: String, +} + +impl Default for Answer { + fn default() -> Self { + Self { + from: "## From: {u.mention}{n}".to_string(), + quest: "### Quest {q.id}: {q.name}{n}".to_string(), + expected: "### Expected answer:{n}||{q.answer}||".to_string(), + text: "### Passed answer:{n}{text}".to_string(), + attachment_notice: "Passed answer has attachments.".to_string(), + accepted_by: "{text}{n}Accepted by: {u.mention}".to_string(), + rejected_by: "~~{text}~~{n}Rejected by: {u.mention}".to_string(), + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(default)] +pub struct Difficulty { + pub easy: String, + pub normal: String, + pub hard: String, + pub secret: String, +} + +impl Default for Difficulty { + fn default() -> Self { + Self { + easy: "Easy".to_string(), + normal: "Normal".to_string(), + hard: "Hard".to_string(), + secret: "Secret".to_string(), + } + } +} + +impl Difficulty { + pub fn as_string(&self, difficulty: &QuestDifficulty) -> String { +match difficulty { + QuestDifficulty::Easy => self.easy.clone(), + QuestDifficulty::Normal => self.normal.clone(), + QuestDifficulty::Hard => self.hard.clone(), + QuestDifficulty::Secret => self.secret.clone(), + } + } +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct Scoreboard { + pub header: String, + pub line_format: String, + pub you_format: String, +} + +impl Default for Scoreboard { + fn default() -> Self { + Self { + header: "Current scoreboard:".to_string(), + line_format: "{n}{u.name}: **{b.full}** (**{b.current}** on balance\ + + **{b.rooms}** unlocked rooms networth)".to_string(), + you_format: "__{text}__ << You".to_string(), + } + } +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct QuestStrings { + pub list: String, + pub list_item: String, + pub create: String, + pub update: String, + pub publish: String, + pub delete: String, + pub message_format: String, + pub message_not_found: String, +} + +impl Default for QuestStrings { + fn default() -> Self { + Self { + list: "Listing {value} quests:".to_string(), + list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(), + create: "Created quest {q.id}".to_string(), + update: "Updated quest {q.id}".to_string(), + publish: "Published quest {q.id}: {text}".to_string(), + delete: "Deleted quest {q.id}".to_string(), + message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\ + Difficulty: *{q.difficulty}*{n}\ + {q.description}".to_string(), + message_not_found: "Warning: quest {q.id} message not found".to_string(), + } + } +}