diff --git a/Cargo.lock b/Cargo.lock index 388a656..1be0a5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2140,7 +2140,7 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] name = "squad-quest" -version = "0.11.0" +version = "0.12.0" dependencies = [ "serde", "toml 0.9.10+spec-1.1.0", @@ -2148,7 +2148,7 @@ dependencies = [ [[package]] name = "squad-quest-cli" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "clap", @@ -2159,7 +2159,7 @@ dependencies = [ [[package]] name = "squad-quest-discord" -version = "0.11.0" +version = "0.12.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index bffc69b..7cc9deb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ members = ["cli", "discord"] [workspace.package] -version = "0.11.0" +version = "0.12.0" edition = "2024" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index ac8581c..9585de5 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,5 +9,5 @@ license.workspace = true chrono = "0.4.42" clap = { version = "4.5.53", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] } -squad-quest = { version = "0.11.0", path = ".." } +squad-quest = { version = "0.12.0", path = ".." } toml = "0.9.8" diff --git a/cli/src/cli/quest.rs b/cli/src/cli/quest.rs index 1ed541c..41c5a7e 100644 --- a/cli/src/cli/quest.rs +++ b/cli/src/cli/quest.rs @@ -83,6 +83,9 @@ pub struct QuestCreateArgs { /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) #[arg(short,long,value_parser = parse_date)] pub deadline: Option, + /// Limit on how many users can solve the quest (0 = no limit) + #[arg(short,long)] + pub limit: Option, } #[derive(Args)] @@ -113,6 +116,9 @@ pub struct QuestUpdateArgs { /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) #[arg(long,value_parser = parse_date)] pub deadline: Option, + /// Limit on how many users can solve the quest (0 = no limit) + #[arg(long)] + pub limit: Option, } #[derive(Args)] diff --git a/cli/src/main.rs b/cli/src/main.rs index eb6dceb..1a022e9 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -148,6 +148,7 @@ fn main() { public: args.public, available_on: args.available.clone(), deadline: args.deadline.clone(), + limit: args.limit.unwrap_or_default(), ..Default::default() }; @@ -171,6 +172,7 @@ fn main() { public: args.public.unwrap_or(quest.public), available_on: args.available.clone().or(quest.available_on.clone()), deadline: args.deadline.clone().or(quest.deadline.clone()), + limit: args.limit.unwrap_or_default(), ..Default::default() }; @@ -284,10 +286,6 @@ fn main() { do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id)); }, AccountCommands::Complete(args) => { - let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else { - if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); } - return; - }; let quests = config.load_quests(); @@ -299,9 +297,17 @@ fn main() { }, }; - match quest.complete_for_account(account) { + let result = quest.complete_for_account(&args.account, &mut accounts); + + match result { Err(error) if !cli.quiet => println!("Error: {error}"), - Ok(_) => do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id)), + Ok(_) => { + let Some(account) = accounts.iter_mut().find(|a| a.id == args.account) else { + if !cli.quiet { eprintln!("Error: account \"{}\" not found.", args.account); } + return; + }; + do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id)); + }, _ => {}, } }, diff --git a/discord/Cargo.toml b/discord/Cargo.toml index 510a366..384d709 100644 --- a/discord/Cargo.toml +++ b/discord/Cargo.toml @@ -13,6 +13,6 @@ poise = "0.6.1" rocket = { version = "0.5.1", features = ["json"] } serde = "1.0.228" serde_json = "1.0.146" -squad-quest = { version = "0.11.0", path = ".." } +squad-quest = { version = "0.12.0", path = ".." } tokio = { version = "1.48.0", features = ["rt-multi-thread"] } toml = "0.9.8" diff --git a/discord/src/account.rs b/discord/src/account.rs index 0551ec6..8950210 100644 --- a/discord/src/account.rs +++ b/discord/src/account.rs @@ -3,8 +3,14 @@ use std::collections::HashMap; use poise::serenity_prelude::{User, UserId}; use squad_quest::{account::Account, config::Config, map::Map}; -pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> Account { +/// Returns Ok(account) if account was found or Err(new_account) if not +pub fn fetch_or_init_account(conf: &Config, id: &str, user: Option<&User>) -> Result { let accounts = conf.load_accounts(); + + if let Some(account) = accounts.iter().find(|a| a.id == id) { + return Ok(account.clone()); + } + let mut data: HashMap = HashMap::new(); if let Some(user) = user { @@ -14,14 +20,13 @@ pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> data.insert("name".to_string(), name); } - match accounts.iter().find(|a| a.id == id) { - Some(a) => a.clone(), - None => Account { - id, - data: Some(data), - ..Default::default() - }, - } + let new_account = Account { + id: id.to_string(), + data: Some(data), + ..Default::default() + }; + + Err(new_account) } pub fn account_rooms_value(account: &Account, map: &Map) -> u32 { diff --git a/discord/src/commands/account.rs b/discord/src/commands/account.rs index 2d5d6f3..91ec72d 100644 --- a/discord/src/commands/account.rs +++ b/discord/src/commands/account.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::User; use squad_quest::{SquadObject, account::Account, map::Map}; -use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter}; +use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild}; async fn account_balance_string( ctx: &Context<'_>, @@ -26,6 +26,7 @@ async fn account_balance_string( prefix_command, slash_command, guild_only, + check = "guild", required_permissions = "ADMINISTRATOR", name_localized("ru", "сбросить"), description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"), @@ -63,6 +64,7 @@ pub async fn reset( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "счет"), description_localized("ru", "Отобразить таблицу лидеров"), )] @@ -106,6 +108,7 @@ pub async fn scoreboard( prefix_command, slash_command, guild_only, + check = "guild", subcommands("give", "set"), name_localized("ru", "баланс"), )] @@ -120,6 +123,7 @@ pub async fn balance( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "передать"), description_localized("ru", "Передать очки другому пользователю"), )] @@ -142,31 +146,41 @@ pub async fn give( let mut accounts = config.load_accounts(); let user_id = format!("{}", ctx.author().id.get()); - - let mut user_account = fetch_or_init_account(config, user_id, Some(ctx.author())); - + let strings = &ctx.data().strings; + let formatter: StringFormatter; + let accounts_path = config.full_accounts_path(); + let who_id = format!("{}", who.id.get()); - let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else { - return Err(Error::AccountNotFound); - }; - if user_account.balance < amount { - return Err(Error::InsufficientFunds(amount)); + if let None = accounts.iter().find(|a| a.id == who_id ) { + return Err(Error::AccountNotFound); } - user_account.balance -= amount; + { + let Some(user_account) = accounts.iter_mut().find(|a| a.id == user_id) else { + return Err(Error::AccountNotFound); + }; + + if user_account.balance < amount { + return Err(Error::InsufficientFunds(amount)); + } + + user_account.balance -= amount; + user_account.save(accounts_path.clone())?; + + formatter = strings.formatter() + .value(amount) + .user(&who) + .current_balance(&user_account); + } + + let other_account = accounts.iter_mut().find(|a| a.id == who_id ).expect("We already checked its existence earlier"); + other_account.balance += amount; - let accounts_path = config.full_accounts_path(); - user_account.save(accounts_path.clone())?; other_account.save(accounts_path)?; - let strings = &ctx.data().strings; - let formatter = strings.formatter() - .user(&who) - .value(amount) - .current_balance(&user_account); - + let reply_string = formatter.fmt(&strings.account.give_pt); ctx.reply(reply_string).await?; @@ -178,6 +192,7 @@ pub async fn give( prefix_command, slash_command, guild_only, + check = "guild", required_permissions = "ADMINISTRATOR", name_localized("ru", "установить"), description_localized("ru", "Устанавливает текущий баланс пользователя"), diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index 523db8f..964ea6a 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -1,13 +1,14 @@ use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage}; use squad_quest::SquadObject; -use crate::{Context, Error, account::fetch_or_init_account}; +use crate::{account::fetch_or_init_account, commands::{guild, quest::update_quest_message}, Context, Error}; /// Send an answer to the quest for review #[poise::command( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "ответить"), description_localized("ru", "Отправить ответ на квест на проверку"), )] @@ -34,19 +35,34 @@ pub async fn answer( #[description_localized("ru", "Вложение к ответу на квест")] file3: Option, ) -> Result<(), Error> { - let mut account = fetch_or_init_account(&ctx.data().config, ctx.author().id.to_string(), Some(ctx.author())); - - if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) { - return Err(Error::QuestIsCompleted(quest_id)); - } - let quests = ctx.data().config.load_quests(); let Some(quest) = quests.iter() .filter(|q| q.public) .find(|q| q.id == quest_id) else { return Err(Error::QuestNotFound(quest_id)); }; + { + let accounts = ctx.data().config.load_accounts(); + let completed_times = accounts.iter().filter(|a| a.has_completed_quest(&quest)).count() as u8; + if quest.limit > 0 && completed_times >= quest.limit { + return Err(Error::QuestLimitExceeded(quest.id)); + } + } + let user_id = ctx.author().id.to_string(); + match fetch_or_init_account(&ctx.data().config, &user_id, Some(ctx.author())) { + Ok(account) => { + if let Some(_) = account.quests_completed.iter().find(|qid| **qid == quest_id) { + return Err(Error::QuestIsCompleted(quest_id)); + } + }, + Err(new_account) => { + let path = ctx.data().config.full_accounts_path(); + new_account.save(path)? + } + } + + let mut files: Vec = Vec::new(); for file in [file1, file2, file3] { if let Some(f) = file { @@ -134,16 +150,20 @@ pub async fn answer( let content: String; if is_approved { let mut no_errors = true; - if let Err(error) = quest.complete_for_account(&mut account) { + let mut accounts = ctx.data().config.load_accounts(); + if let Err(error) = quest.complete_for_account(&ctx.author().id.to_string(), &mut accounts) { eprintln!("{error}"); no_errors = false; }; + let account = accounts.iter_mut().find(|a| a.id == user_id).expect("we done fetch_or_init earlier"); let path = ctx.data().config.full_accounts_path(); if let Err(error) = account.save(path) { eprintln!("{error}"); no_errors = false; }; + update_quest_message(ctx, &quest).await?; + formatter = formatter.current_balance(&account); if no_errors { diff --git a/discord/src/commands/init.rs b/discord/src/commands/init.rs index fb6e6c3..b4fd02e 100644 --- a/discord/src/commands/init.rs +++ b/discord/src/commands/init.rs @@ -4,7 +4,7 @@ use poise::{CreateReply, serenity_prelude::ChannelId}; use squad_quest::SquadObject; use toml::value::Time; -use crate::{Context, Error, timer::DailyTimer}; +use crate::{Context, Error, timer::DailyTimer, commands::guild}; /// Set channels to post quests and answers to #[poise::command( @@ -12,6 +12,7 @@ use crate::{Context, Error, timer::DailyTimer}; slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "инит"), description_localized("ru", "Установить каналы для публикации квестов и ответов"), )] @@ -75,6 +76,7 @@ fn seconds(time: Time) -> u64 { slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "таймер"), description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"), )] diff --git a/discord/src/commands/map.rs b/discord/src/commands/map.rs index 064595d..0b6dfb5 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -1,12 +1,14 @@ +use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply}; use squad_quest::{SquadObject, map::Map}; -use crate::{Context, account::fetch_or_init_account, error::Error}; +use crate::{Context, account::fetch_or_init_account, error::Error, commands::guild}; /// Unlock specified room if it is reachable and you have required amount of points #[poise::command( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "открыть"), description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"), )] @@ -27,7 +29,10 @@ pub async fn unlock( }; let acc_id = format!("{}", ctx.author().id.get()); - let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author())); + let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) { + Ok(account) => account, + Err(account) => account, + }; if account.balance < room.value { return Err(Error::InsufficientFunds(room.value)); @@ -55,6 +60,7 @@ pub async fn unlock( prefix_command, slash_command, guild_only, + check = "guild", name_localized("ru", "пойти"), description_localized("ru", "Переместиться в другую разблокированную комнату"), )] @@ -68,7 +74,10 @@ pub async fn r#move( let conf = &ctx.data().config; let acc_id = format!("{}", ctx.author().id.get()); - let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author())); + let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) { + Ok(account) => account, + Err(account) => account, + }; if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) { return Err(Error::CannotReach(id)); @@ -88,3 +97,99 @@ pub async fn r#move( Ok(()) } + +/// Change avatar on web map +#[poise::command( + slash_command, + prefix_command, + guild_only, + check = "guild", + name_localized("ru", "аватар"), + description_localized("ru", "Сменить аватар на веб карте"), +)] +pub async fn avatar( + ctx: Context<'_>, + #[description = "URL to the avatar"] + #[name_localized("ru", "ссылка")] + #[description_localized("ru", "Ссылка на аватар")] + url: Option, + #[description = "Attachment to use as avatar"] + #[name_localized("ru", "вложение")] + #[description_localized("ru", "Вложение, используемое как аватар")] + attachment: Option, +) -> Result<(), Error> { + let user_id = ctx.author().id.to_string(); + let mut accounts = ctx.data().config.load_accounts(); + let Some(account) = accounts.iter_mut().find(|a| a.id == user_id) else { + return Err(Error::AccountNotFound); + }; + + if url.is_none() && attachment.is_none() { + return Err(Error::NoUrlOrAttachment); + } + + if url.is_some() && attachment.is_some() { + return Err(Error::BothUrlAndAttachment); + } + + let strings = &ctx.data().strings; + let formatter = strings.formatter(); + + if let Some(url) = url { + let attachment = CreateAttachment::url(ctx, &url).await?; + let reply_string = formatter.fmt(&strings.map.processing_url); + let builder = CreateMessage::new() + .content(reply_string) + .add_file(attachment.clone()); + let message = ctx.channel_id().send_message(ctx, builder).await?; + + let attachment_check = message.attachments.first().expect("we just sent it"); + if attachment_check.width.is_none() + || !attachment_check.content_type + .as_ref() + .is_some_and(|t| t.starts_with("image/")) { + message.delete(ctx).await?; + return Err(Error::NonImageAttachment); + } + let data = account.data.as_mut().expect("automatically created"); + data.insert("avatar".to_string(), url); + + message.delete(ctx).await?; + + let reply_string = formatter.fmt(&strings.map.updated_avatar); + let builder = CreateReply::default() + .content(reply_string) + .attachment(attachment) + .reply(true); + ctx.send(builder).await?; + + } else if let Some(attachment) = attachment { + if attachment.width.is_none() + || !attachment.content_type + .as_ref() + .is_some_and(|t| t.starts_with("image/")) { + return Err(Error::NonImageAttachment); + } + + let reply_string = formatter.fmt(&strings.map.updated_avatar); + let copied_attachment = CreateAttachment::url(ctx, &attachment.url).await?; + + let data = account.data.as_mut().expect("automatically created"); + data.insert("avatar".to_string(), attachment.url); + + let path = ctx.data().config.full_accounts_path(); + account.save(path)?; + + let builder = CreateReply::default() + .content(reply_string) + .attachment(copied_attachment) + .reply(true); + + ctx.send(builder).await?; + } + + let path = ctx.data().config.full_accounts_path(); + account.save(path)?; + + Ok(()) +} diff --git a/discord/src/commands/mod.rs b/discord/src/commands/mod.rs index 85d3ee8..bba7684 100644 --- a/discord/src/commands/mod.rs +++ b/discord/src/commands/mod.rs @@ -1,7 +1,8 @@ use std::error::Error as StdError; -use poise::CreateReply; +use poise::{CreateReply, serenity_prelude::GuildId}; +use squad_quest::quest::Quest; -use crate::{Context, Data, Error}; +use crate::{error, Context, Data, Error}; pub mod quest; pub mod init; @@ -10,6 +11,18 @@ pub mod social; pub mod account; pub mod map; +pub async fn guild(ctx: Context<'_>) -> Result { + let id = ctx.guild_id().expect("guild-only command"); + let guard = ctx.data().discord.lock().expect("shouldn't be locked"); + let expected_id = guard.guild; + + if expected_id != GuildId::default() && id != expected_id { + return Err(Error::NotThisGuild); + } + + Ok(true) +} + #[poise::command(prefix_command)] pub async fn register(ctx: Context<'_>) -> Result<(), Error> { poise::builtins::register_application_commands_buttons(ctx).await?; @@ -36,10 +49,45 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) { eprintln!("ERROR:"); print_error_recursively(&error); if let Some(ctx) = error.ctx() { - let response = match error { - poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"), - _ => format!("Internal server error: {error}"), + let user = ctx.author().display_name(); + + eprintln!("User: {user} ({id})", id = ctx.author().id); + eprintln!("Invokation string: {}", ctx.invocation_string()); + + let strings = &ctx.data().strings; + + let response = if let poise::FrameworkError::Command { error, .. } = error { + let formatter = match error { + error::Error::QuestNotFound(id) | + error::Error::QuestIsPublic(id) | + error::Error::QuestIsCompleted(id) | + error::Error::QuestLimitExceeded(id) => + strings.formatter().quest(&Quest { id, ..Default::default() }), + + error::Error::InsufficientFunds(amount) => + strings.formatter().value(amount), + + error::Error::RoomNotFound(value) | + error::Error::RoomAlreadyUnlocked(value) | + error::Error::CannotReach(value) => + strings.formatter().value(value), + + error::Error::SerenityError(ref error) => + strings.formatter().text(error), + + error::Error::SquadQuestError(ref error) => + strings.formatter().text(error), + + _ => strings.formatter(), + }; + + let error_string = error.formattable_string(&strings.error); + formatter.fmt(error_string) + } else { + let formatter = strings.formatter().text(&error); + formatter.fmt(&strings.error.non_command_error) }; + if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await { eprintln!("Couldn't send error message: {error}"); } diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index ee6089d..a04b0ef 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -1,9 +1,10 @@ use std::{future, str::FromStr}; -use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt}; -use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}}; +use poise::serenity_prelude as serenity; +use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt}; +use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject}; use toml::value::Date; -use crate::{Context, Error}; +use crate::{Context, Error,commands::guild}; async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result, Error>{ ctx.defer().await?; @@ -24,16 +25,42 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result Ok(messages.first().cloned()) } -fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String { +fn make_quest_message_content(ctx: Context<'_>, quest: &Quest, accounts: &Option>) -> String { let strings = &ctx.data().strings; - let formatter = strings.formatter().quest(quest); + let formatter = match accounts { + Some(accounts) => strings.formatter().quest_full(quest, accounts), + None => strings.formatter().quest(quest), + }; formatter.fmt(&strings.quest.message_format) } +pub async fn update_quest_message(ctx: Context<'_>, quest: &Quest) -> Result<(), Error> { + let strings = &ctx.data().strings; + let formatter = strings.formatter().quest(&quest); + let accounts = ctx.data().config.load_accounts(); + let content = make_quest_message_content(ctx, &quest, &Some(accounts)); + let builder = EditMessage::new().content(content); + + let message = find_quest_message(ctx, quest.id).await?; + if let Some(mut message) = message { + return match message.edit(ctx, builder).await { + Ok(_) => Ok(()), + Err(error) => Err(error.into()), + } + } else { + let reply_string = formatter.fmt(&strings.quest.message_not_found); + match ctx.reply(reply_string).await { + Ok(_) => Ok(()), + Err(error) => Err(error.into()), + } + } +} + #[poise::command( prefix_command, slash_command, guild_only, + check = "guild", subcommands("list", "create", "update", "publish", "delete"), required_permissions = "ADMINISTRATOR", name_localized("ru", "квест"), @@ -49,6 +76,7 @@ pub async fn quest( prefix_command, slash_command, guild_only, + check = "guild", required_permissions = "ADMINISTRATOR", name_localized("ru", "список"), description_localized("ru", "Вывести все квесты") @@ -114,6 +142,7 @@ impl From for Date { slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "создать"), description_localized("ru", "Создать квест и получить его идентификатор"), )] @@ -143,6 +172,10 @@ pub async fn create( #[name_localized("ru", "доступен")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")] available: Option, + #[description = "Limit how many users are allowed to complete the quest"] + #[name_localized("ru", "лимит")] + #[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")] + limit: Option, /* #[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"] #[name_localized("ru", "дедлайн")] @@ -179,6 +212,7 @@ pub async fn create( answer, public: false, available_on, + limit: limit.unwrap_or_default(), //deadline, ..Default::default() }; @@ -202,6 +236,7 @@ pub async fn create( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "обновить"), description_localized("ru", "Обновить выбранные значения указанного квеста"), )] @@ -235,6 +270,10 @@ pub async fn update( #[name_localized("ru", "доступен")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")] available: Option, + #[description = "Limit how many users are allowed to complete the quest"] + #[name_localized("ru", "лимит")] + #[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")] + limit: Option, /* #[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"] #[name_localized("ru", "дедлайн")] @@ -260,15 +299,18 @@ pub async fn update( }; let available_on: Option; + let new_limit: u8; //let dead_line: Option; match reset.unwrap_or(false) { true => { available_on = None; + new_limit = limit.unwrap_or_default(); //dead_line = None; }, false => { available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into())); + new_limit = limit.unwrap_or(quest.limit); //dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into())); }, } @@ -282,6 +324,7 @@ pub async fn update( answer: answer.unwrap_or(quest.answer.clone()), public: quest.public, available_on, + limit: new_limit, //deadline: dead_line, ..Default::default() }; @@ -290,16 +333,7 @@ pub async fn update( let formatter = strings.formatter().quest(&new_quest); if new_quest.public { - 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 = formatter.fmt(&strings.quest.message_not_found); - ctx.reply(reply_string).await?; - } + update_quest_message(ctx, &new_quest).await?; } let path = conf.full_quests_path(); @@ -310,13 +344,14 @@ pub async fn update( Ok(()) } -pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> { +pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result { quest.public = true; let quests_path = ctx.data().config.full_quests_path(); quest.save(quests_path)?; - - let content = make_quest_message_content(ctx, &quest); + + let accounts = ctx.data().config.load_accounts(); + let content = make_quest_message_content(ctx, &quest, &Some(accounts)); let builder = CreateMessage::new() .content(content); @@ -327,8 +362,10 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er guard.quests_channel }; - channel.send_message(ctx, builder).await?; - Ok(()) + match channel.send_message(ctx, builder).await { + Ok(m) => Ok(m), + Err(error) => Err(Error::SerenityError(error)), + } } /// Mark quest as public and send its message in quests channel @@ -337,6 +374,7 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "опубликовать"), description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"), )] @@ -357,10 +395,12 @@ pub async fn publish( return Err(Error::QuestIsPublic(id)); } - publish_inner(ctx, quest).await?; + let message = publish_inner(ctx, quest).await?; let strings = &ctx.data().strings; - let formatter = strings.formatter().quest(&quest); + let formatter = strings.formatter() + .quest(&quest) + .message(&message); let reply_string = formatter.fmt(&strings.quest.publish); ctx.reply(reply_string).await?; @@ -374,6 +414,7 @@ pub async fn publish( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "удалить"), description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"), )] diff --git a/discord/src/commands/social.rs b/discord/src/commands/social.rs index d2f22cb..3739ada 100644 --- a/discord/src/commands/social.rs +++ b/discord/src/commands/social.rs @@ -1,12 +1,13 @@ use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, MessageId, UserId}; -use crate::{Context, Error}; +use crate::{Context, Error, commands::guild}; #[poise::command( prefix_command, slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", subcommands("msg", "edit", "undo"), name_localized("ru", "сообщение"), )] @@ -20,6 +21,7 @@ pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> { slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "написать"), description_localized("ru", "Отправить сообщение пользователю или в канал"), )] @@ -98,6 +100,7 @@ pub async fn msg ( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "редактировать"), description_localized("ru", "Редактировать сообщение в канале или в ЛС"), )] @@ -177,6 +180,7 @@ pub async fn edit ( slash_command, required_permissions = "ADMINISTRATOR", guild_only, + check = "guild", name_localized("ru", "удалить"), description_localized("ru", "Удалить сообщение в канале или в ЛС"), )] diff --git a/discord/src/error.rs b/discord/src/error.rs index 8d8b442..df5fb7f 100644 --- a/discord/src/error.rs +++ b/discord/src/error.rs @@ -3,6 +3,8 @@ use std::fmt::Display; use poise::serenity_prelude as serenity; use squad_quest::error::MapError; +use crate::strings::ErrorStrings; + #[non_exhaustive] #[derive(Debug)] pub enum Error { @@ -21,6 +23,11 @@ pub enum Error { RoomAlreadyUnlocked(u16), CannotReach(u16), TimerSet, + NotThisGuild, + QuestLimitExceeded(u16), + BothUrlAndAttachment, + NoUrlOrAttachment, + NonImageAttachment, } impl From for Error { @@ -53,9 +60,9 @@ impl Display for Error { Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"), Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"), Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"), - Self::NoContent => write!(f, "no text or attachment was specified"), - Self::NoChannelOrUser => write!(f, "no channel or user was specified"), - Self::BothChannelAndUser => write!(f, "both channel and user was specified"), + Self::NoContent => write!(f, "no text or attachment were specified"), + Self::NoChannelOrUser => write!(f, "no channel or user were specified"), + Self::BothChannelAndUser => write!(f, "both channel and user were specified"), Self::SerenityError(_) => write!(f, "discord interaction error"), Self::SquadQuestError(_) => write!(f, "internal logic error"), Self::AccountNotFound => write!(f, "account not found"), @@ -65,6 +72,11 @@ impl Display for Error { Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"), Self::TimerSet => write!(f, "timer is already set"), + Self::NotThisGuild => write!(f, "cannot be used in this guild"), + Self::QuestLimitExceeded(id) => write!(f, "exceeded limit for quest #{id}"), + Self::BothUrlAndAttachment => write!(f, "both url and attachment were specified"), + Self::NoUrlOrAttachment => write!(f, "no url or attachment were specified"), + Self::NonImageAttachment => write!(f, "attachment is not an image"), } } } @@ -84,11 +96,41 @@ impl std::error::Error for Error { Self::RoomNotFound(_) | Self::RoomAlreadyUnlocked(_) | Self::CannotReach(_) | - Self::TimerSet => None, + Self::TimerSet | + Self::NotThisGuild | + Self::QuestLimitExceeded(_) | + Self::BothUrlAndAttachment | + Self::NoUrlOrAttachment | + Self::NonImageAttachment => None, Self::SerenityError(error) => Some(error), Self::SquadQuestError(error) => Some(error), } } } - +impl<'a> Error { + pub fn formattable_string(&self, errors: &'a ErrorStrings) -> &'a str { + match self { + Self::QuestNotFound(_) => &errors.quest_not_found, + Self::QuestIsPublic(_) => &errors.quest_is_public, + Self::QuestIsCompleted(_) => &errors.quest_is_completed, + Self::NoContent => &errors.no_content, + Self::NoChannelOrUser => &errors.no_channel_or_user, + Self::BothChannelAndUser => &errors.both_channel_and_user, + Self::SerenityError(_) => &errors.discord_error, + Self::SquadQuestError(_) => &errors.library_error, + Self::AccountNotFound => &errors.account_not_found, + Self::AccountIsSelf => &errors.account_is_self, + Self::InsufficientFunds(_) => &errors.insufficient_funds, + Self::RoomNotFound(_) => &errors.room_not_found, + Self::RoomAlreadyUnlocked(_) => &errors.room_already_unlocked, + Self::CannotReach(_) => &errors.cannot_reach, + Self::TimerSet => &errors.timer_set, + Self::NotThisGuild => &errors.not_this_guild, + Self::QuestLimitExceeded(_) => &errors.quest_limit_exceeded, + Self::BothUrlAndAttachment => &errors.both_url_and_attachment, + Self::NoUrlOrAttachment => &errors.no_url_or_attachment, + Self::NonImageAttachment => &errors.non_image_attachment, + } + } +} diff --git a/discord/src/main.rs b/discord/src/main.rs index ffe6922..acb3fea 100644 --- a/discord/src/main.rs +++ b/discord/src/main.rs @@ -92,6 +92,7 @@ async fn main() { commands::account::reset(), commands::map::unlock(), commands::map::r#move(), + commands::map::avatar(), ], ..Default::default() }) diff --git a/discord/src/strings.rs b/discord/src/strings.rs index a3d485f..c965f8c 100644 --- a/discord/src/strings.rs +++ b/discord/src/strings.rs @@ -45,11 +45,20 @@ impl StringFormatter { 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 ]; + let limit = ("{q.limit}".to_string(), quest.limit.to_string()); + let new_tags = vec![ id, difficulty, reward, name, description, answer, limit ]; self.with_tags(new_tags) } + pub fn quest_full(mut self, quest: &Quest, accounts: &Vec) -> Self { + self = self.quest(&quest); + let completed_times = accounts.iter().filter(|a| a.has_completed_quest(&quest)).count(); + let completions = ("{q.completions}".to_string(), completed_times.to_string()); + + self.with_tags(vec![completions]) + } + 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()); @@ -133,6 +142,7 @@ pub struct Strings { pub scoreboard: Scoreboard, pub social: Social, pub quest: QuestStrings, + pub error: ErrorStrings, } impl Default for Strings { @@ -151,6 +161,7 @@ impl Default for Strings { social: Social::default(), account: AccountReplies::default(), map: MapReplies::default(), + error: ErrorStrings::default(), } } } @@ -328,7 +339,7 @@ impl Default for QuestStrings { 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(), + publish: "Published quest {q.id}: {m.link}".to_string(), delete: "Deleted quest {q.id}".to_string(), message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\ Difficulty: *{q.difficulty}*{n}\ @@ -382,6 +393,8 @@ impl Default for AccountReplies { pub struct MapReplies { pub room_unlocked: String, pub moved_to_room: String, + pub updated_avatar: String, + pub processing_url: String, } impl Default for MapReplies { @@ -389,6 +402,62 @@ impl Default for MapReplies { Self { room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(), moved_to_room: "Moved to room #{value}".to_string(), + updated_avatar: "Successfully changed avatar".to_string(), + processing_url: "Processing URL...".to_string(), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(default)] +pub struct ErrorStrings { + pub non_command_error: String, + pub quest_not_found: String, + pub quest_is_public: String, + pub quest_is_completed: String, + pub no_content: String, + pub no_channel_or_user: String, + pub both_channel_and_user: String, + pub discord_error: String, + pub library_error: String, + pub account_not_found: String, + pub account_is_self: String, + pub insufficient_funds: String, + pub room_not_found: String, + pub room_already_unlocked: String, + pub cannot_reach: String, + pub timer_set: String, + pub not_this_guild: String, + pub quest_limit_exceeded: String, + pub both_url_and_attachment: String, + pub no_url_or_attachment: String, + pub non_image_attachment: String, +} + +impl Default for ErrorStrings { + fn default() -> Self { + Self { + non_command_error: "Internal server error: {text}".to_string(), + quest_not_found: "Quest {q.id} not found".to_string(), + quest_is_public: "Quest {q.id} is already public".to_string(), + quest_is_completed: "Quest {q.id} is already completed for this user".to_string(), + no_content: "No text or attachment were specified".to_string(), + no_channel_or_user: "No channel or user were specified".to_string(), + both_channel_and_user: "Both channel and user were specified".to_string(), + discord_error: "Discord interaction error: {text}".to_string(), + library_error: "Some internal logic error: {text}".to_string(), + account_not_found: "Given account was not found".to_string(), + account_is_self: "Given account is the same as command invoker".to_string(), + insufficient_funds: "You don't have {value} points".to_string(), + room_not_found: "Room #{value} not found".to_string(), + room_already_unlocked: "Room #{value} is already unlocked for this account".to_string(), + cannot_reach: "You cannot reach room #{value}".to_string(), + timer_set: "Timer is already set".to_string(), + not_this_guild: "Bot cannot be used in this guild".to_string(), + quest_limit_exceeded: "Exceeded limit for quest {q.id}".to_string(), + both_url_and_attachment: "Both URL and attachment were specified".to_string(), + no_url_or_attachment: "No URL or attachment were specified".to_string(), + non_image_attachment: "Given attachment is not an image".to_string(), } } } diff --git a/src/account/mod.rs b/src/account/mod.rs index bfb6157..2d8b296 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -4,7 +4,7 @@ use std::{collections::HashMap, fs, io::Write, path::PathBuf}; use serde::{ Serialize, Deserialize }; -use crate::{SquadObject, error::Error}; +use crate::{error::Error, quest::Quest, SquadObject}; fn default_id() -> String { "none".to_string() @@ -99,3 +99,10 @@ impl SquadObject for Account { Ok(()) } } + +impl Account { + /// Returns true if given quest is completed on this account + pub fn has_completed_quest(&self, quest: &Quest) -> bool { + self.quests_completed.contains(&quest.id) + } +} diff --git a/src/error.rs b/src/error.rs index f4f1bc3..6bf532a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -49,12 +49,18 @@ impl std::error::Error for Error { pub enum QuestError { /// Quest (self.0) is already completed for given account (self.1) AlreadyCompleted(u16, String), + /// Account (self.0) not found + AccountNotFound(String), + /// Limit for quest (self.0) exceeded + LimitExceeded(u16), } impl fmt::Display for QuestError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""), + Self::AccountNotFound(account_id) => write!(f, "account \"{account_id}\""), + Self::LimitExceeded(quest_id) => write!(f, "exceeded limit for quest #{quest_id}"), } } } diff --git a/src/quest/mod.rs b/src/quest/mod.rs index da28415..dc6c7ce 100644 --- a/src/quest/mod.rs +++ b/src/quest/mod.rs @@ -70,6 +70,10 @@ pub struct Quest { /// Additional implementation-defined data pub data: Option>, + + /// Limit how many users can complete the quest. + /// If set to 0, quest is not limited. + pub limit: u8, } impl Default for Quest { @@ -85,6 +89,7 @@ impl Default for Quest { available_on: None, deadline: None, data: None, + limit: 0, } } } @@ -159,7 +164,17 @@ impl Quest { /// // handle error /// } /// ``` - pub fn complete_for_account(&self, account: &mut Account) -> Result<(),QuestError> { + pub fn complete_for_account(&self, id: &str, accounts: &mut Vec) -> Result<(),QuestError> { + let completed_times = accounts.iter().filter(|a| a.has_completed_quest(self)).count(); + + if self.limit > 0 && completed_times as u8 >= self.limit { + return Err(QuestError::LimitExceeded(self.id)); + } + + let Some(account) = accounts.iter_mut().find(|a| a.id == id) else { + return Err(QuestError::AccountNotFound(id.to_string())); + }; + match account.quests_completed.iter().find(|qid| **qid == self.id) { Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())), None => {