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
This commit is contained in:
Alexey 2025-12-30 15:44:23 +03:00
commit 2640821a05
16 changed files with 192 additions and 69 deletions

View file

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

View file

@ -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<Account, Account> {
let accounts = conf.load_accounts();
if let Some(account) = accounts.iter().find(|a| a.id == id) {
return Ok(account.clone());
}
let mut data: HashMap<String, String> = 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 {

View file

@ -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?;

View file

@ -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<Attachment>,
) -> 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<Attachment> = 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 {

View file

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

View file

@ -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<Option<Message>
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<Vec<Account>>) -> 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<DateWrapper>,
#[description = "Limit how many users are allowed to complete the quest"]
#[name_localized("ru", "лимит")]
#[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")]
limit: Option<u8>,
/*
#[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<DateWrapper>,
#[description = "Limit how many users are allowed to complete the quest"]
#[name_localized("ru", "лимит")]
#[description_localized("ru", "Ограничение количества участников, которые могут выполнить квест")]
limit: Option<u8>,
/*
#[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<Date>;
let new_limit: u8;
//let dead_line: Option<Date>;
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<Messag
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);

View file

@ -22,6 +22,7 @@ pub enum Error {
CannotReach(u16),
TimerSet,
NotThisGuild,
QuestLimitExceeded(u16),
}
impl From<serenity::Error> 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),
}

View file

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