From 2640821a05f5b6efd0af9ebb9de1e8c3832b8a40 Mon Sep 17 00:00:00 2001 From: 2ndbeam <2ndbeam@disroot.org> Date: Tue, 30 Dec 2025 15:44:23 +0300 Subject: [PATCH] feat!: Added limit field to quests - Bump version to 0.12.0 - lib: Changed Quest::complete_for_account behavior - cli: Added limit field for quest create and quest update - discord: Quests are checked for limit on /answer - discord: Added limit field for /quest create and /quest update - discord: Changed behavior of fetch_or_init_account --- Cargo.lock | 6 ++-- Cargo.toml | 2 +- cli/Cargo.toml | 2 +- cli/src/cli/quest.rs | 6 ++++ cli/src/main.rs | 18 ++++++---- discord/Cargo.toml | 2 +- discord/src/account.rs | 23 +++++++----- discord/src/commands/account.rs | 46 ++++++++++++++---------- discord/src/commands/answer.rs | 35 +++++++++++++----- discord/src/commands/map.rs | 10 ++++-- discord/src/commands/quest.rs | 63 ++++++++++++++++++++++++--------- discord/src/error.rs | 5 ++- discord/src/strings.rs | 11 +++++- src/account/mod.rs | 9 ++++- src/error.rs | 6 ++++ src/quest/mod.rs | 17 ++++++++- 16 files changed, 192 insertions(+), 69 deletions(-) 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 2b81750..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, commands::guild}; +use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild}; async fn account_balance_string( ctx: &Context<'_>, @@ -146,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?; diff --git a/discord/src/commands/answer.rs b/discord/src/commands/answer.rs index 2dd280a..964ea6a 100644 --- a/discord/src/commands/answer.rs +++ b/discord/src/commands/answer.rs @@ -1,7 +1,7 @@ use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage}; use squad_quest::SquadObject; -use crate::{Context, Error, account::fetch_or_init_account, commands::guild}; +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( @@ -35,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 { @@ -135,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/map.rs b/discord/src/commands/map.rs index e22f2e4..e23134d 100644 --- a/discord/src/commands/map.rs +++ b/discord/src/commands/map.rs @@ -28,7 +28,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)); @@ -70,7 +73,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)); diff --git a/discord/src/commands/quest.rs b/discord/src/commands/quest.rs index 489d70c..a04b0ef 100644 --- a/discord/src/commands/quest.rs +++ b/discord/src/commands/quest.rs @@ -1,7 +1,8 @@ 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,commands::guild}; @@ -24,12 +25,37 @@ 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, @@ -146,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", "дедлайн")] @@ -182,6 +212,7 @@ pub async fn create( answer, public: false, available_on, + limit: limit.unwrap_or_default(), //deadline, ..Default::default() }; @@ -239,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", "дедлайн")] @@ -264,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())); }, } @@ -286,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() }; @@ -294,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(); @@ -319,8 +349,9 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result for Error { @@ -67,6 +68,7 @@ impl Display for Error { 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}"), } } } @@ -87,7 +89,8 @@ impl std::error::Error for Error { Self::RoomAlreadyUnlocked(_) | Self::CannotReach(_) | Self::TimerSet | - Self::NotThisGuild => None, + Self::NotThisGuild | + Self::QuestLimitExceeded(_) => None, Self::SerenityError(error) => Some(error), Self::SquadQuestError(error) => Some(error), } diff --git a/discord/src/strings.rs b/discord/src/strings.rs index afa2ac8..43505d4 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()); 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 => {