Compare commits

..

No commits in common. "master" and "0.11.0" have entirely different histories.

20 changed files with 88 additions and 480 deletions

6
Cargo.lock generated
View file

@ -2140,7 +2140,7 @@ checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]] [[package]]
name = "squad-quest" name = "squad-quest"
version = "0.12.0" version = "0.11.0"
dependencies = [ dependencies = [
"serde", "serde",
"toml 0.9.10+spec-1.1.0", "toml 0.9.10+spec-1.1.0",
@ -2148,7 +2148,7 @@ dependencies = [
[[package]] [[package]]
name = "squad-quest-cli" name = "squad-quest-cli"
version = "0.12.0" version = "0.11.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@ -2159,7 +2159,7 @@ dependencies = [
[[package]] [[package]]
name = "squad-quest-discord" name = "squad-quest-discord"
version = "0.12.0" version = "0.11.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",

View file

@ -2,7 +2,7 @@
members = ["cli", "discord"] members = ["cli", "discord"]
[workspace.package] [workspace.package]
version = "0.12.0" version = "0.11.0"
edition = "2024" edition = "2024"
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest" repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest" homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest"

View file

@ -9,5 +9,5 @@ license.workspace = true
chrono = "0.4.42" chrono = "0.4.42"
clap = { version = "4.5.53", features = ["derive"] } clap = { version = "4.5.53", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
squad-quest = { version = "0.12.0", path = ".." } squad-quest = { version = "0.11.0", path = ".." }
toml = "0.9.8" toml = "0.9.8"

View file

@ -83,9 +83,6 @@ pub struct QuestCreateArgs {
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(short,long,value_parser = parse_date)] #[arg(short,long,value_parser = parse_date)]
pub deadline: Option<Date>, pub deadline: Option<Date>,
/// Limit on how many users can solve the quest (0 = no limit)
#[arg(short,long)]
pub limit: Option<u8>,
} }
#[derive(Args)] #[derive(Args)]
@ -116,9 +113,6 @@ pub struct QuestUpdateArgs {
/// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24) /// Quest expiration date (format = YYYY-MM-DD, ex. 2025-12-24)
#[arg(long,value_parser = parse_date)] #[arg(long,value_parser = parse_date)]
pub deadline: Option<Date>, pub deadline: Option<Date>,
/// Limit on how many users can solve the quest (0 = no limit)
#[arg(long)]
pub limit: Option<u8>,
} }
#[derive(Args)] #[derive(Args)]

View file

@ -148,7 +148,6 @@ fn main() {
public: args.public, public: args.public,
available_on: args.available.clone(), available_on: args.available.clone(),
deadline: args.deadline.clone(), deadline: args.deadline.clone(),
limit: args.limit.unwrap_or_default(),
..Default::default() ..Default::default()
}; };
@ -172,7 +171,6 @@ fn main() {
public: args.public.unwrap_or(quest.public), public: args.public.unwrap_or(quest.public),
available_on: args.available.clone().or(quest.available_on.clone()), available_on: args.available.clone().or(quest.available_on.clone()),
deadline: args.deadline.clone().or(quest.deadline.clone()), deadline: args.deadline.clone().or(quest.deadline.clone()),
limit: args.limit.unwrap_or_default(),
..Default::default() ..Default::default()
}; };
@ -286,6 +284,10 @@ fn main() {
do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id)); do_and_log(account.save(path), !cli.quiet, format!("Updated balance of account \"{}\".", account.id));
}, },
AccountCommands::Complete(args) => { 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(); let quests = config.load_quests();
@ -297,17 +299,9 @@ fn main() {
}, },
}; };
let result = quest.complete_for_account(&args.account, &mut accounts); match quest.complete_for_account(account) {
match result {
Err(error) if !cli.quiet => println!("Error: {error}"), Err(error) if !cli.quiet => println!("Error: {error}"),
Ok(_) => { Ok(_) => do_and_log(account.save(path), !cli.quiet, format!("Completed quest #{} on account \"{}\".", args.quest, account.id)),
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));
},
_ => {}, _ => {},
} }
}, },

View file

@ -13,6 +13,6 @@ poise = "0.6.1"
rocket = { version = "0.5.1", features = ["json"] } rocket = { version = "0.5.1", features = ["json"] }
serde = "1.0.228" serde = "1.0.228"
serde_json = "1.0.146" serde_json = "1.0.146"
squad-quest = { version = "0.12.0", path = ".." } squad-quest = { version = "0.11.0", path = ".." }
tokio = { version = "1.48.0", features = ["rt-multi-thread"] } tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
toml = "0.9.8" toml = "0.9.8"

View file

@ -3,14 +3,8 @@ use std::collections::HashMap;
use poise::serenity_prelude::{User, UserId}; use poise::serenity_prelude::{User, UserId};
use squad_quest::{account::Account, config::Config, map::Map}; use squad_quest::{account::Account, config::Config, map::Map};
/// Returns Ok(account) if account was found or Err(new_account) if not pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> Account {
pub fn fetch_or_init_account(conf: &Config, id: &str, user: Option<&User>) -> Result<Account, Account> {
let accounts = conf.load_accounts(); 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(); let mut data: HashMap<String, String> = HashMap::new();
if let Some(user) = user { if let Some(user) = user {
@ -20,13 +14,14 @@ pub fn fetch_or_init_account(conf: &Config, id: &str, user: Option<&User>) -> Re
data.insert("name".to_string(), name); data.insert("name".to_string(), name);
} }
let new_account = Account { match accounts.iter().find(|a| a.id == id) {
id: id.to_string(), Some(a) => a.clone(),
data: Some(data), None => Account {
..Default::default() id,
}; data: Some(data),
..Default::default()
Err(new_account) },
}
} }
pub fn account_rooms_value(account: &Account, map: &Map) -> u32 { pub fn account_rooms_value(account: &Account, map: &Map) -> u32 {

View file

@ -1,7 +1,7 @@
use poise::serenity_prelude::User; use poise::serenity_prelude::User;
use squad_quest::{SquadObject, account::Account, map::Map}; use squad_quest::{SquadObject, account::Account, map::Map};
use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild}; use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter};
async fn account_balance_string( async fn account_balance_string(
ctx: &Context<'_>, ctx: &Context<'_>,
@ -26,7 +26,6 @@ async fn account_balance_string(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "сбросить"), name_localized("ru", "сбросить"),
description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"), description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"),
@ -64,7 +63,6 @@ pub async fn reset(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "счет"), name_localized("ru", "счет"),
description_localized("ru", "Отобразить таблицу лидеров"), description_localized("ru", "Отобразить таблицу лидеров"),
)] )]
@ -108,7 +106,6 @@ pub async fn scoreboard(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
subcommands("give", "set"), subcommands("give", "set"),
name_localized("ru", "баланс"), name_localized("ru", "баланс"),
)] )]
@ -123,7 +120,6 @@ pub async fn balance(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "передать"), name_localized("ru", "передать"),
description_localized("ru", "Передать очки другому пользователю"), description_localized("ru", "Передать очки другому пользователю"),
)] )]
@ -146,41 +142,31 @@ pub async fn give(
let mut accounts = config.load_accounts(); let mut accounts = config.load_accounts();
let user_id = format!("{}", ctx.author().id.get()); let user_id = format!("{}", ctx.author().id.get());
let strings = &ctx.data().strings;
let formatter: StringFormatter; let mut user_account = fetch_or_init_account(config, user_id, Some(ctx.author()));
let accounts_path = config.full_accounts_path();
let who_id = format!("{}", who.id.get()); let who_id = format!("{}", who.id.get());
let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else {
if let None = accounts.iter().find(|a| a.id == who_id ) {
return Err(Error::AccountNotFound); return Err(Error::AccountNotFound);
} };
{
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"); if user_account.balance < amount {
return Err(Error::InsufficientFunds(amount));
}
user_account.balance -= amount;
other_account.balance += amount; other_account.balance += amount;
let accounts_path = config.full_accounts_path();
user_account.save(accounts_path.clone())?;
other_account.save(accounts_path)?; 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); let reply_string = formatter.fmt(&strings.account.give_pt);
ctx.reply(reply_string).await?; ctx.reply(reply_string).await?;
@ -192,7 +178,6 @@ pub async fn give(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "установить"), name_localized("ru", "установить"),
description_localized("ru", "Устанавливает текущий баланс пользователя"), description_localized("ru", "Устанавливает текущий баланс пользователя"),

View file

@ -1,14 +1,13 @@
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage}; use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage};
use squad_quest::SquadObject; use squad_quest::SquadObject;
use crate::{account::fetch_or_init_account, commands::{guild, quest::update_quest_message}, Context, Error}; use crate::{Context, Error, account::fetch_or_init_account};
/// Send an answer to the quest for review /// Send an answer to the quest for review
#[poise::command( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "ответить"), name_localized("ru", "ответить"),
description_localized("ru", "Отправить ответ на квест на проверку"), description_localized("ru", "Отправить ответ на квест на проверку"),
)] )]
@ -35,34 +34,19 @@ pub async fn answer(
#[description_localized("ru", "Вложение к ответу на квест")] #[description_localized("ru", "Вложение к ответу на квест")]
file3: Option<Attachment>, file3: Option<Attachment>,
) -> Result<(), Error> { ) -> 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 quests = ctx.data().config.load_quests();
let Some(quest) = quests.iter() let Some(quest) = quests.iter()
.filter(|q| q.public) .filter(|q| q.public)
.find(|q| q.id == quest_id) else { .find(|q| q.id == quest_id) else {
return Err(Error::QuestNotFound(quest_id)); 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(); let mut files: Vec<Attachment> = Vec::new();
for file in [file1, file2, file3] { for file in [file1, file2, file3] {
if let Some(f) = file { if let Some(f) = file {
@ -150,20 +134,16 @@ pub async fn answer(
let content: String; let content: String;
if is_approved { if is_approved {
let mut no_errors = true; let mut no_errors = true;
let mut accounts = ctx.data().config.load_accounts(); if let Err(error) = quest.complete_for_account(&mut account) {
if let Err(error) = quest.complete_for_account(&ctx.author().id.to_string(), &mut accounts) {
eprintln!("{error}"); eprintln!("{error}");
no_errors = false; 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(); let path = ctx.data().config.full_accounts_path();
if let Err(error) = account.save(path) { if let Err(error) = account.save(path) {
eprintln!("{error}"); eprintln!("{error}");
no_errors = false; no_errors = false;
}; };
update_quest_message(ctx, &quest).await?;
formatter = formatter.current_balance(&account); formatter = formatter.current_balance(&account);
if no_errors { if no_errors {

View file

@ -4,7 +4,7 @@ use poise::{CreateReply, serenity_prelude::ChannelId};
use squad_quest::SquadObject; use squad_quest::SquadObject;
use toml::value::Time; use toml::value::Time;
use crate::{Context, Error, timer::DailyTimer, commands::guild}; use crate::{Context, Error, timer::DailyTimer};
/// Set channels to post quests and answers to /// Set channels to post quests and answers to
#[poise::command( #[poise::command(
@ -12,7 +12,6 @@ use crate::{Context, Error, timer::DailyTimer, commands::guild};
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "инит"), name_localized("ru", "инит"),
description_localized("ru", "Установить каналы для публикации квестов и ответов"), description_localized("ru", "Установить каналы для публикации квестов и ответов"),
)] )]
@ -76,7 +75,6 @@ fn seconds(time: Time) -> u64 {
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "таймер"), name_localized("ru", "таймер"),
description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"), description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"),
)] )]

View file

@ -1,14 +1,12 @@
use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply};
use squad_quest::{SquadObject, map::Map}; use squad_quest::{SquadObject, map::Map};
use crate::{Context, account::fetch_or_init_account, error::Error, commands::guild}; use crate::{Context, account::fetch_or_init_account, error::Error};
/// Unlock specified room if it is reachable and you have required amount of points /// Unlock specified room if it is reachable and you have required amount of points
#[poise::command( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "открыть"), name_localized("ru", "открыть"),
description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"), description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"),
)] )]
@ -29,10 +27,7 @@ pub async fn unlock(
}; };
let acc_id = format!("{}", ctx.author().id.get()); let acc_id = format!("{}", ctx.author().id.get());
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) { let mut account = fetch_or_init_account(conf, acc_id, Some(ctx.author()));
Ok(account) => account,
Err(account) => account,
};
if account.balance < room.value { if account.balance < room.value {
return Err(Error::InsufficientFunds(room.value)); return Err(Error::InsufficientFunds(room.value));
@ -60,7 +55,6 @@ pub async fn unlock(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
name_localized("ru", "пойти"), name_localized("ru", "пойти"),
description_localized("ru", "Переместиться в другую разблокированную комнату"), description_localized("ru", "Переместиться в другую разблокированную комнату"),
)] )]
@ -74,10 +68,7 @@ pub async fn r#move(
let conf = &ctx.data().config; let conf = &ctx.data().config;
let acc_id = format!("{}", ctx.author().id.get()); let acc_id = format!("{}", ctx.author().id.get());
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) { let mut account = 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) { if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) {
return Err(Error::CannotReach(id)); return Err(Error::CannotReach(id));
@ -97,99 +88,3 @@ pub async fn r#move(
Ok(()) 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<String>,
#[description = "Attachment to use as avatar"]
#[name_localized("ru", "вложение")]
#[description_localized("ru", "Вложение, используемое как аватар")]
attachment: Option<Attachment>,
) -> 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(())
}

View file

@ -1,8 +1,7 @@
use std::error::Error as StdError; use std::error::Error as StdError;
use poise::{CreateReply, serenity_prelude::GuildId}; use poise::CreateReply;
use squad_quest::quest::Quest;
use crate::{error, Context, Data, Error}; use crate::{Context, Data, Error};
pub mod quest; pub mod quest;
pub mod init; pub mod init;
@ -11,18 +10,6 @@ pub mod social;
pub mod account; pub mod account;
pub mod map; pub mod map;
pub async fn guild(ctx: Context<'_>) -> Result<bool, Error> {
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)] #[poise::command(prefix_command)]
pub async fn register(ctx: Context<'_>) -> Result<(), Error> { pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
poise::builtins::register_application_commands_buttons(ctx).await?; poise::builtins::register_application_commands_buttons(ctx).await?;
@ -49,45 +36,10 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) {
eprintln!("ERROR:"); eprintln!("ERROR:");
print_error_recursively(&error); print_error_recursively(&error);
if let Some(ctx) = error.ctx() { if let Some(ctx) = error.ctx() {
let user = ctx.author().display_name(); let response = match error {
poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"),
eprintln!("User: {user} ({id})", id = ctx.author().id); _ => format!("Internal server error: {error}"),
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 { if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await {
eprintln!("Couldn't send error message: {error}"); eprintln!("Couldn't send error message: {error}");
} }

View file

@ -1,10 +1,9 @@
use std::{future, str::FromStr}; use std::{future, str::FromStr};
use poise::serenity_prelude as serenity; use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt};
use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt}; use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}};
use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject};
use toml::value::Date; use toml::value::Date;
use crate::{Context, Error,commands::guild}; use crate::{Context, Error};
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
ctx.defer().await?; ctx.defer().await?;
@ -25,42 +24,16 @@ async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>
Ok(messages.first().cloned()) Ok(messages.first().cloned())
} }
fn make_quest_message_content(ctx: Context<'_>, quest: &Quest, accounts: &Option<Vec<Account>>) -> String { fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String {
let strings = &ctx.data().strings; let strings = &ctx.data().strings;
let formatter = match accounts { let formatter = strings.formatter().quest(quest);
Some(accounts) => strings.formatter().quest_full(quest, accounts),
None => strings.formatter().quest(quest),
};
formatter.fmt(&strings.quest.message_format) 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( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
subcommands("list", "create", "update", "publish", "delete"), subcommands("list", "create", "update", "publish", "delete"),
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "квест"), name_localized("ru", "квест"),
@ -76,7 +49,6 @@ pub async fn quest(
prefix_command, prefix_command,
slash_command, slash_command,
guild_only, guild_only,
check = "guild",
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
name_localized("ru", "список"), name_localized("ru", "список"),
description_localized("ru", "Вывести все квесты") description_localized("ru", "Вывести все квесты")
@ -142,7 +114,6 @@ impl From<DateWrapper> for Date {
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "создать"), name_localized("ru", "создать"),
description_localized("ru", "Создать квест и получить его идентификатор"), description_localized("ru", "Создать квест и получить его идентификатор"),
)] )]
@ -172,10 +143,6 @@ pub async fn create(
#[name_localized("ru", "доступен")] #[name_localized("ru", "доступен")]
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
available: Option<DateWrapper>, 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)"] #[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "дедлайн")] #[name_localized("ru", "дедлайн")]
@ -212,7 +179,6 @@ pub async fn create(
answer, answer,
public: false, public: false,
available_on, available_on,
limit: limit.unwrap_or_default(),
//deadline, //deadline,
..Default::default() ..Default::default()
}; };
@ -236,7 +202,6 @@ pub async fn create(
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "обновить"), name_localized("ru", "обновить"),
description_localized("ru", "Обновить выбранные значения указанного квеста"), description_localized("ru", "Обновить выбранные значения указанного квеста"),
)] )]
@ -270,10 +235,6 @@ pub async fn update(
#[name_localized("ru", "доступен")] #[name_localized("ru", "доступен")]
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")] #[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")]
available: Option<DateWrapper>, 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)"] #[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
#[name_localized("ru", "дедлайн")] #[name_localized("ru", "дедлайн")]
@ -299,18 +260,15 @@ pub async fn update(
}; };
let available_on: Option<Date>; let available_on: Option<Date>;
let new_limit: u8;
//let dead_line: Option<Date>; //let dead_line: Option<Date>;
match reset.unwrap_or(false) { match reset.unwrap_or(false) {
true => { true => {
available_on = None; available_on = None;
new_limit = limit.unwrap_or_default();
//dead_line = None; //dead_line = None;
}, },
false => { false => {
available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into())); 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())); //dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
}, },
} }
@ -324,7 +282,6 @@ pub async fn update(
answer: answer.unwrap_or(quest.answer.clone()), answer: answer.unwrap_or(quest.answer.clone()),
public: quest.public, public: quest.public,
available_on, available_on,
limit: new_limit,
//deadline: dead_line, //deadline: dead_line,
..Default::default() ..Default::default()
}; };
@ -333,7 +290,16 @@ pub async fn update(
let formatter = strings.formatter().quest(&new_quest); let formatter = strings.formatter().quest(&new_quest);
if new_quest.public { if new_quest.public {
update_quest_message(ctx, &new_quest).await?; 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?;
}
} }
let path = conf.full_quests_path(); let path = conf.full_quests_path();
@ -344,14 +310,13 @@ pub async fn update(
Ok(()) Ok(())
} }
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Message, Error> { pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> {
quest.public = true; quest.public = true;
let quests_path = ctx.data().config.full_quests_path(); let quests_path = ctx.data().config.full_quests_path();
quest.save(quests_path)?; quest.save(quests_path)?;
let accounts = ctx.data().config.load_accounts(); let content = make_quest_message_content(ctx, &quest);
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
let builder = CreateMessage::new() let builder = CreateMessage::new()
.content(content); .content(content);
@ -362,10 +327,8 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Messag
guard.quests_channel guard.quests_channel
}; };
match channel.send_message(ctx, builder).await { channel.send_message(ctx, builder).await?;
Ok(m) => Ok(m), Ok(())
Err(error) => Err(Error::SerenityError(error)),
}
} }
/// Mark quest as public and send its message in quests channel /// Mark quest as public and send its message in quests channel
@ -374,7 +337,6 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Messag
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "опубликовать"), name_localized("ru", "опубликовать"),
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"), description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
)] )]
@ -395,12 +357,10 @@ pub async fn publish(
return Err(Error::QuestIsPublic(id)); return Err(Error::QuestIsPublic(id));
} }
let message = publish_inner(ctx, quest).await?; publish_inner(ctx, quest).await?;
let strings = &ctx.data().strings; let strings = &ctx.data().strings;
let formatter = strings.formatter() let formatter = strings.formatter().quest(&quest);
.quest(&quest)
.message(&message);
let reply_string = formatter.fmt(&strings.quest.publish); let reply_string = formatter.fmt(&strings.quest.publish);
ctx.reply(reply_string).await?; ctx.reply(reply_string).await?;
@ -414,7 +374,6 @@ pub async fn publish(
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "удалить"), name_localized("ru", "удалить"),
description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"), description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"),
)] )]

View file

@ -1,13 +1,12 @@
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, MessageId, UserId}; use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, MessageId, UserId};
use crate::{Context, Error, commands::guild}; use crate::{Context, Error};
#[poise::command( #[poise::command(
prefix_command, prefix_command,
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
subcommands("msg", "edit", "undo"), subcommands("msg", "edit", "undo"),
name_localized("ru", "сообщение"), name_localized("ru", "сообщение"),
)] )]
@ -21,7 +20,6 @@ pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> {
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "написать"), name_localized("ru", "написать"),
description_localized("ru", "Отправить сообщение пользователю или в канал"), description_localized("ru", "Отправить сообщение пользователю или в канал"),
)] )]
@ -100,7 +98,6 @@ pub async fn msg (
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "редактировать"), name_localized("ru", "редактировать"),
description_localized("ru", "Редактировать сообщение в канале или в ЛС"), description_localized("ru", "Редактировать сообщение в канале или в ЛС"),
)] )]
@ -180,7 +177,6 @@ pub async fn edit (
slash_command, slash_command,
required_permissions = "ADMINISTRATOR", required_permissions = "ADMINISTRATOR",
guild_only, guild_only,
check = "guild",
name_localized("ru", "удалить"), name_localized("ru", "удалить"),
description_localized("ru", "Удалить сообщение в канале или в ЛС"), description_localized("ru", "Удалить сообщение в канале или в ЛС"),
)] )]

View file

@ -3,8 +3,6 @@ use std::fmt::Display;
use poise::serenity_prelude as serenity; use poise::serenity_prelude as serenity;
use squad_quest::error::MapError; use squad_quest::error::MapError;
use crate::strings::ErrorStrings;
#[non_exhaustive] #[non_exhaustive]
#[derive(Debug)] #[derive(Debug)]
pub enum Error { pub enum Error {
@ -23,11 +21,6 @@ pub enum Error {
RoomAlreadyUnlocked(u16), RoomAlreadyUnlocked(u16),
CannotReach(u16), CannotReach(u16),
TimerSet, TimerSet,
NotThisGuild,
QuestLimitExceeded(u16),
BothUrlAndAttachment,
NoUrlOrAttachment,
NonImageAttachment,
} }
impl From<serenity::Error> for Error { impl From<serenity::Error> for Error {
@ -60,9 +53,9 @@ impl Display for Error {
Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"), Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"),
Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"), Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"),
Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"), Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"),
Self::NoContent => write!(f, "no text or attachment were specified"), Self::NoContent => write!(f, "no text or attachment was specified"),
Self::NoChannelOrUser => write!(f, "no channel or user were specified"), Self::NoChannelOrUser => write!(f, "no channel or user was specified"),
Self::BothChannelAndUser => write!(f, "both channel and user were specified"), Self::BothChannelAndUser => write!(f, "both channel and user was specified"),
Self::SerenityError(_) => write!(f, "discord interaction error"), Self::SerenityError(_) => write!(f, "discord interaction error"),
Self::SquadQuestError(_) => write!(f, "internal logic error"), Self::SquadQuestError(_) => write!(f, "internal logic error"),
Self::AccountNotFound => write!(f, "account not found"), Self::AccountNotFound => write!(f, "account not found"),
@ -72,11 +65,6 @@ impl Display for Error {
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"), Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"), Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"),
Self::TimerSet => write!(f, "timer is already set"), 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"),
} }
} }
} }
@ -96,41 +84,11 @@ impl std::error::Error for Error {
Self::RoomNotFound(_) | Self::RoomNotFound(_) |
Self::RoomAlreadyUnlocked(_) | Self::RoomAlreadyUnlocked(_) |
Self::CannotReach(_) | Self::CannotReach(_) |
Self::TimerSet | Self::TimerSet => None,
Self::NotThisGuild |
Self::QuestLimitExceeded(_) |
Self::BothUrlAndAttachment |
Self::NoUrlOrAttachment |
Self::NonImageAttachment => None,
Self::SerenityError(error) => Some(error), Self::SerenityError(error) => Some(error),
Self::SquadQuestError(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,
}
}
}

View file

@ -92,7 +92,6 @@ async fn main() {
commands::account::reset(), commands::account::reset(),
commands::map::unlock(), commands::map::unlock(),
commands::map::r#move(), commands::map::r#move(),
commands::map::avatar(),
], ],
..Default::default() ..Default::default()
}) })

View file

@ -45,20 +45,11 @@ impl StringFormatter {
let name = ("{q.name}".to_string(), quest.name.clone()); let name = ("{q.name}".to_string(), quest.name.clone());
let description = ("{q.description}".to_string(), quest.description.clone()); let description = ("{q.description}".to_string(), quest.description.clone());
let answer = ("{q.answer}".to_string(), quest.answer.clone()); let answer = ("{q.answer}".to_string(), quest.answer.clone());
let limit = ("{q.limit}".to_string(), quest.limit.to_string()); let new_tags = vec![ id, difficulty, reward, name, description, answer ];
let new_tags = vec![ id, difficulty, reward, name, description, answer, limit ];
self.with_tags(new_tags) 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 { pub fn user(self, user: &User) -> Self {
let mention = ("{u.mention}".to_string(), user.mention().to_string()); let mention = ("{u.mention}".to_string(), user.mention().to_string());
let name = ("{u.name}".to_string(), user.display_name().to_string()); let name = ("{u.name}".to_string(), user.display_name().to_string());
@ -142,7 +133,6 @@ pub struct Strings {
pub scoreboard: Scoreboard, pub scoreboard: Scoreboard,
pub social: Social, pub social: Social,
pub quest: QuestStrings, pub quest: QuestStrings,
pub error: ErrorStrings,
} }
impl Default for Strings { impl Default for Strings {
@ -161,7 +151,6 @@ impl Default for Strings {
social: Social::default(), social: Social::default(),
account: AccountReplies::default(), account: AccountReplies::default(),
map: MapReplies::default(), map: MapReplies::default(),
error: ErrorStrings::default(),
} }
} }
} }
@ -339,7 +328,7 @@ impl Default for QuestStrings {
list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(), list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(),
create: "Created quest {q.id}".to_string(), create: "Created quest {q.id}".to_string(),
update: "Updated quest {q.id}".to_string(), update: "Updated quest {q.id}".to_string(),
publish: "Published quest {q.id}: {m.link}".to_string(), publish: "Published quest {q.id}: {text}".to_string(),
delete: "Deleted quest {q.id}".to_string(), delete: "Deleted quest {q.id}".to_string(),
message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\ message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\
Difficulty: *{q.difficulty}*{n}\ Difficulty: *{q.difficulty}*{n}\
@ -393,8 +382,6 @@ impl Default for AccountReplies {
pub struct MapReplies { pub struct MapReplies {
pub room_unlocked: String, pub room_unlocked: String,
pub moved_to_room: String, pub moved_to_room: String,
pub updated_avatar: String,
pub processing_url: String,
} }
impl Default for MapReplies { impl Default for MapReplies {
@ -402,62 +389,6 @@ impl Default for MapReplies {
Self { Self {
room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(), room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(),
moved_to_room: "Moved to room #{value}".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(),
} }
} }
} }

View file

@ -4,7 +4,7 @@ use std::{collections::HashMap, fs, io::Write, path::PathBuf};
use serde::{ Serialize, Deserialize }; use serde::{ Serialize, Deserialize };
use crate::{error::Error, quest::Quest, SquadObject}; use crate::{SquadObject, error::Error};
fn default_id() -> String { fn default_id() -> String {
"none".to_string() "none".to_string()
@ -99,10 +99,3 @@ impl SquadObject for Account {
Ok(()) 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)
}
}

View file

@ -49,18 +49,12 @@ impl std::error::Error for Error {
pub enum QuestError { pub enum QuestError {
/// Quest (self.0) is already completed for given account (self.1) /// Quest (self.0) is already completed for given account (self.1)
AlreadyCompleted(u16, String), AlreadyCompleted(u16, String),
/// Account (self.0) not found
AccountNotFound(String),
/// Limit for quest (self.0) exceeded
LimitExceeded(u16),
} }
impl fmt::Display for QuestError { impl fmt::Display for QuestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::AlreadyCompleted(quest_id, account_id) => write!(f, "quest #{quest_id} is already completed for account \"{account_id}\""), 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}"),
} }
} }
} }

View file

@ -70,10 +70,6 @@ pub struct Quest {
/// Additional implementation-defined data /// Additional implementation-defined data
pub data: Option<HashMap<String, String>>, pub data: Option<HashMap<String, String>>,
/// Limit how many users can complete the quest.
/// If set to 0, quest is not limited.
pub limit: u8,
} }
impl Default for Quest { impl Default for Quest {
@ -89,7 +85,6 @@ impl Default for Quest {
available_on: None, available_on: None,
deadline: None, deadline: None,
data: None, data: None,
limit: 0,
} }
} }
} }
@ -164,17 +159,7 @@ impl Quest {
/// // handle error /// // handle error
/// } /// }
/// ``` /// ```
pub fn complete_for_account(&self, id: &str, accounts: &mut Vec<Account>) -> Result<(),QuestError> { pub fn complete_for_account(&self, account: &mut Account) -> 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) { match account.quests_completed.iter().find(|qid| **qid == self.id) {
Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())), Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())),
None => { None => {