426 lines
14 KiB
Rust
426 lines
14 KiB
Rust
use std::{future, str::FromStr};
|
||
|
||
use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt};
|
||
use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}};
|
||
use toml::value::Date;
|
||
use crate::{Context, Error,commands::guild};
|
||
|
||
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
|
||
ctx.defer().await?;
|
||
let dc = ctx.data().discord.clone();
|
||
let channel = {
|
||
let guard = dc.lock().expect("shouldn't be locked");
|
||
guard.quests_channel
|
||
};
|
||
let messages = channel.messages_iter(ctx)
|
||
.filter_map(|m| async move { if m.is_ok() { Some(m.unwrap()) } else {
|
||
eprintln!("{}", m.unwrap_err());
|
||
None
|
||
} })
|
||
.filter(|m| {
|
||
future::ready(m.content.contains(&format!("#{id}")))
|
||
})
|
||
.collect::<Vec<_>>().await;
|
||
Ok(messages.first().cloned())
|
||
}
|
||
|
||
fn make_quest_message_content(ctx: Context<'_>, quest: &Quest) -> String {
|
||
let strings = &ctx.data().strings;
|
||
let formatter = strings.formatter().quest(quest);
|
||
formatter.fmt(&strings.quest.message_format)
|
||
}
|
||
|
||
#[poise::command(
|
||
prefix_command,
|
||
slash_command,
|
||
guild_only,
|
||
check = "guild",
|
||
subcommands("list", "create", "update", "publish", "delete"),
|
||
required_permissions = "ADMINISTRATOR",
|
||
name_localized("ru", "квест"),
|
||
)]
|
||
pub async fn quest(
|
||
_ctx: Context<'_>,
|
||
) -> Result<(), Error> {
|
||
Ok(())
|
||
}
|
||
|
||
/// List all quests
|
||
#[poise::command(
|
||
prefix_command,
|
||
slash_command,
|
||
guild_only,
|
||
check = "guild",
|
||
required_permissions = "ADMINISTRATOR",
|
||
name_localized("ru", "список"),
|
||
description_localized("ru", "Вывести все квесты")
|
||
)]
|
||
pub async fn list(
|
||
ctx: Context<'_>,
|
||
) -> Result<(), Error> {
|
||
let conf = &ctx.data().config;
|
||
let quests = conf.load_quests();
|
||
let strings = &ctx.data().strings;
|
||
let mut formatter = strings.formatter().value(quests.len());
|
||
let mut reply_string = formatter.fmt(&strings.quest.list);
|
||
for quest in quests {
|
||
formatter = formatter.quest(&quest);
|
||
reply_string.push_str(formatter.fmt(&strings.quest.list_item).as_str());
|
||
}
|
||
ctx.reply(reply_string).await?;
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Debug, poise::ChoiceParameter)]
|
||
pub enum DifficultyWrapper {
|
||
Easy,
|
||
Normal,
|
||
Hard,
|
||
Secret,
|
||
}
|
||
|
||
impl From<DifficultyWrapper> for QuestDifficulty {
|
||
fn from(value: DifficultyWrapper) -> Self {
|
||
match &value {
|
||
DifficultyWrapper::Easy => Self::Easy,
|
||
DifficultyWrapper::Normal => Self::Normal,
|
||
DifficultyWrapper::Hard => Self::Hard,
|
||
DifficultyWrapper::Secret => Self::Secret,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(serde::Deserialize)]
|
||
struct DateWrapper {
|
||
date: Date,
|
||
}
|
||
|
||
impl FromStr for DateWrapper {
|
||
type Err = toml::de::Error;
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
let toml_str = format!("date = {s}");
|
||
let wrapper: Self = toml::from_str(&toml_str)?;
|
||
Ok(wrapper)
|
||
}
|
||
}
|
||
|
||
impl From<DateWrapper> for Date {
|
||
fn from(value: DateWrapper) -> Self {
|
||
value.date
|
||
}
|
||
}
|
||
|
||
/// Create quest and print its identifier
|
||
#[poise::command(
|
||
prefix_command,
|
||
slash_command,
|
||
required_permissions = "ADMINISTRATOR",
|
||
guild_only,
|
||
check = "guild",
|
||
name_localized("ru", "создать"),
|
||
description_localized("ru", "Создать квест и получить его идентификатор"),
|
||
)]
|
||
pub async fn create(
|
||
ctx: Context<'_>,
|
||
#[description = "Quest difficulty"]
|
||
#[name_localized("ru", "сложность")]
|
||
#[description_localized("ru", "Сложность квеста")]
|
||
difficulty: DifficultyWrapper,
|
||
#[description = "Reward for the quest"]
|
||
#[name_localized("ru", "награда")]
|
||
#[description_localized("ru", "Награда за квест")]
|
||
reward: u32,
|
||
#[description = "Quest name"]
|
||
#[name_localized("ru", "название")]
|
||
#[description_localized("ru", "Название квеста")]
|
||
name: String,
|
||
#[description = "Quest description"]
|
||
#[name_localized("ru", "описание")]
|
||
#[description_localized("ru", "Описание квеста")]
|
||
description: String,
|
||
#[description = "Expected answer, visible when user posts their answer for review"]
|
||
#[name_localized("ru", "ответ")]
|
||
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
|
||
answer: String,
|
||
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||
#[name_localized("ru", "доступен")]
|
||
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
|
||
available: Option<DateWrapper>,
|
||
/*
|
||
#[description = "Deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||
#[name_localized("ru", "дедлайн")]
|
||
#[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД), напр. 2025-12-24")]
|
||
deadline: Option<DateWrapper>,
|
||
*/
|
||
) -> Result<(), Error> {
|
||
let conf = &ctx.data().config;
|
||
let mut quests = conf.load_quests();
|
||
quests.sort_by(|a,b| a.id.cmp(&b.id));
|
||
let next_id = match quests.last() {
|
||
Some(quest) => quest.id + 1u16,
|
||
None => 0u16
|
||
};
|
||
|
||
let available_on = match available {
|
||
Some(avail) => Some(avail.into()),
|
||
None => None,
|
||
};
|
||
|
||
/*
|
||
let deadline = match deadline {
|
||
Some(dl) => Some(dl.into()),
|
||
None => None,
|
||
};
|
||
*/
|
||
|
||
let quest = Quest {
|
||
id: next_id,
|
||
difficulty: difficulty.into(),
|
||
reward,
|
||
name,
|
||
description,
|
||
answer,
|
||
public: false,
|
||
available_on,
|
||
//deadline,
|
||
..Default::default()
|
||
};
|
||
|
||
let path = conf.full_quests_path();
|
||
|
||
quest.save(path)?;
|
||
|
||
let strings = &ctx.data().strings;
|
||
let formatter = strings.formatter().quest(&quest);
|
||
let reply_string = formatter.fmt(&strings.quest.create);
|
||
|
||
ctx.reply(reply_string).await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Update quest values by its identifier and new given values
|
||
#[poise::command(
|
||
prefix_command,
|
||
slash_command,
|
||
required_permissions = "ADMINISTRATOR",
|
||
guild_only,
|
||
check = "guild",
|
||
name_localized("ru", "обновить"),
|
||
description_localized("ru", "Обновить выбранные значения указанного квеста"),
|
||
)]
|
||
pub async fn update(
|
||
ctx: Context<'_>,
|
||
#[description = "Quest identifier"]
|
||
#[name_localized("ru", "идентификатор")]
|
||
#[description_localized("ru", "Идентификатор квеста")]
|
||
id: u16,
|
||
#[description = "Quest difficulty"]
|
||
#[name_localized("ru", "сложность")]
|
||
#[description_localized("ru", "Сложность квеста")]
|
||
difficulty: Option<DifficultyWrapper>,
|
||
#[description = "Reward for the quest"]
|
||
#[name_localized("ru", "награда")]
|
||
#[description_localized("ru", "Награда за квест")]
|
||
reward: Option<u32>,
|
||
#[description = "Quest name"]
|
||
#[name_localized("ru", "название")]
|
||
#[description_localized("ru", "Название квеста")]
|
||
name: Option<String>,
|
||
#[description = "Quest description"]
|
||
#[name_localized("ru", "описание")]
|
||
#[description_localized("ru", "Описание квеста")]
|
||
description: Option<String>,
|
||
#[description = "Expected answer, visible when user posts their answer for review"]
|
||
#[name_localized("ru", "ответ")]
|
||
#[description_localized("ru", "Ожидаемый результат, отображаемый при проверке ответа игрока")]
|
||
answer: Option<String>,
|
||
#[description = "Date of publication (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||
#[name_localized("ru", "доступен")]
|
||
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")]
|
||
available: Option<DateWrapper>,
|
||
/*
|
||
#[description = "Quest deadline (in format of YYYY-MM-DD, e.g. 2025-12-24)"]
|
||
#[name_localized("ru", "дедлайн")]
|
||
#[description_localized("ru", "Дедлайн (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
|
||
deadline: Option<DateWrapper>,
|
||
#[description = "Reset availability and deadline if checked"]
|
||
#[description_localized("ru", "Если выбрано, сбросить доступность и дедлайн")]
|
||
*/
|
||
#[description = "Reset availability if checked"]
|
||
#[description_localized("ru", "Если выбрано, сбросить доступность")]
|
||
#[name_localized("ru", "сброс")]
|
||
reset: Option<bool>,
|
||
) -> Result<(), Error> {
|
||
let conf = &ctx.data().config;
|
||
let quests = conf.load_quests();
|
||
let Some(quest) = quests.iter().find(|q| q.id == id) else {
|
||
return Err(Error::QuestNotFound(id));
|
||
};
|
||
|
||
let difficulty = match difficulty {
|
||
Some(d) => d.into(),
|
||
None => quest.difficulty
|
||
};
|
||
|
||
let available_on: Option<Date>;
|
||
//let dead_line: Option<Date>;
|
||
|
||
match reset.unwrap_or(false) {
|
||
true => {
|
||
available_on = None;
|
||
//dead_line = None;
|
||
},
|
||
false => {
|
||
available_on = available.map_or_else(|| quest.available_on.clone(), |v| Some(v.into()));
|
||
//dead_line = deadline.map_or_else(|| quest.deadline.clone(), |v| Some(v.into()));
|
||
},
|
||
}
|
||
|
||
let new_quest = Quest {
|
||
id,
|
||
difficulty,
|
||
reward: reward.unwrap_or(quest.reward),
|
||
name: name.unwrap_or(quest.name.clone()),
|
||
description: description.unwrap_or(quest.description.clone()),
|
||
answer: answer.unwrap_or(quest.answer.clone()),
|
||
public: quest.public,
|
||
available_on,
|
||
//deadline: dead_line,
|
||
..Default::default()
|
||
};
|
||
|
||
let strings = &ctx.data().strings;
|
||
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?;
|
||
}
|
||
}
|
||
|
||
let path = conf.full_quests_path();
|
||
new_quest.save(path)?;
|
||
let reply_string = formatter.fmt(&strings.quest.update);
|
||
ctx.reply(reply_string).await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<Message, Error> {
|
||
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 builder = CreateMessage::new()
|
||
.content(content);
|
||
|
||
let dc = ctx.data().discord.clone();
|
||
let channel = {
|
||
let guard = dc.lock().expect("shouldn't be locked");
|
||
guard.quests_channel
|
||
};
|
||
|
||
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
|
||
#[poise::command(
|
||
prefix_command,
|
||
slash_command,
|
||
required_permissions = "ADMINISTRATOR",
|
||
guild_only,
|
||
check = "guild",
|
||
name_localized("ru", "опубликовать"),
|
||
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
|
||
)]
|
||
pub async fn publish(
|
||
ctx: Context<'_>,
|
||
#[description = "Quest identifier"]
|
||
#[name_localized("ru", "идентификатор")]
|
||
#[description_localized("ru", "Идентификатор квеста")]
|
||
id: u16,
|
||
) -> Result<(), Error> {
|
||
let mut quests = ctx.data().config.load_quests();
|
||
|
||
let Some(quest) = quests.iter_mut().find(|q| q.id == id) else {
|
||
return Err(Error::QuestNotFound(id));
|
||
};
|
||
|
||
if quest.public {
|
||
return Err(Error::QuestIsPublic(id));
|
||
}
|
||
|
||
let message = publish_inner(ctx, quest).await?;
|
||
|
||
let strings = &ctx.data().strings;
|
||
let formatter = strings.formatter()
|
||
.quest(&quest)
|
||
.message(&message);
|
||
|
||
let reply_string = formatter.fmt(&strings.quest.publish);
|
||
ctx.reply(reply_string).await?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Delete quest (and its message, if published)
|
||
#[poise::command(
|
||
prefix_command,
|
||
slash_command,
|
||
required_permissions = "ADMINISTRATOR",
|
||
guild_only,
|
||
check = "guild",
|
||
name_localized("ru", "удалить"),
|
||
description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"),
|
||
)]
|
||
pub async fn delete(
|
||
ctx: Context<'_>,
|
||
#[description = "Quest identifier"]
|
||
#[name_localized("ru", "идентификатор")]
|
||
#[description_localized("ru", "Идентификатор квеста")]
|
||
id: u16,
|
||
) -> Result<(), Error> {
|
||
if let Some(msg) = find_quest_message(ctx, id).await? {
|
||
msg.delete(ctx).await?;
|
||
}
|
||
|
||
let mut path = ctx.data().config.full_quests_path();
|
||
path.push(format!("{id}.toml"));
|
||
Quest::delete(path)?;
|
||
|
||
let mut accounts = ctx.data().config.load_accounts();
|
||
let accounts_path = ctx.data().config.full_accounts_path();
|
||
|
||
for account in accounts.iter_mut().filter(|a| a.quests_completed.contains(&id)) {
|
||
let index = account.quests_completed.iter().position(|qid| *qid == id).expect("We just filtered it");
|
||
account.quests_completed.remove(index);
|
||
account.save(accounts_path.clone())?;
|
||
}
|
||
|
||
let mock_quest = Quest {
|
||
id,
|
||
..Default::default()
|
||
};
|
||
|
||
let strings = &ctx.data().strings;
|
||
let formatter = strings.formatter().quest(&mock_quest);
|
||
|
||
let reply_string = formatter.fmt(&strings.quest.delete);
|
||
ctx.reply(reply_string).await?;
|
||
|
||
Ok(())
|
||
}
|