Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1db7ce877e | |||
| 2640821a05 | |||
| d188bba16e | |||
| d584340f01 | |||
| c22787792d | |||
| 81a9ec0c50 | |||
| 0ab777d898 | |||
| 66cbd23013 | |||
| 9d1261b74d | |||
| 46af205aef |
30 changed files with 1688 additions and 303 deletions
1152
Cargo.lock
generated
1152
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,9 +2,10 @@
|
|||
members = ["cli", "discord"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
repository = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
|
||||
homepage = "https://2ndbeam.ru/git/2ndbeam/squad-quest"
|
||||
license = "MIT"
|
||||
|
||||
[package]
|
||||
|
|
@ -13,6 +14,7 @@ edition.workspace = true
|
|||
version.workspace = true
|
||||
repository.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
|
|
|||
2
LICENSE
2
LICENSE
|
|
@ -1,4 +1,4 @@
|
|||
Copyright 2025 (c) 2ndbeam
|
||||
Copyright 2025 (c) Alexey Mirenkov <2ndbeam@disroot.org>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
|
|
|||
23
build-deb.sh
Executable file
23
build-deb.sh
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
#!/bin/sh
|
||||
cargo build --workspace --release
|
||||
|
||||
install -dvm755 target/release/dpkg/etc/squad_quest target/release/dpkg/usr/bin target/release/dpkg/DEBIAN target/release/dpkg/usr/share/doc/squad-quest
|
||||
|
||||
strip target/release/squad-quest-cli
|
||||
strip target/release/squad-quest-discord
|
||||
install -vm755 target/release/squad-quest-cli target/release/squad-quest-discord target/release/dpkg/usr/bin
|
||||
|
||||
install -vm 644 LICENSE target/release/dpkg/usr/share/doc/squad-quest/copyright
|
||||
|
||||
target/release/squad-quest-cli -qc nil init -i discord.toml -p target/release/dpkg/etc/squad_quest
|
||||
cargo build --bin deb --release
|
||||
target/release/deb > target/release/dpkg/DEBIAN/control
|
||||
|
||||
echo -n "" > target/release/dpkg/DEBIAN/conffiles
|
||||
for file in $(ls target/release/dpkg/etc/squad_quest); do
|
||||
if [ -f target/release/dpkg/etc/squad_quest/$file ]; then
|
||||
echo "/etc/squad_quest/$file" >> target/release/dpkg/DEBIAN/conffiles
|
||||
fi
|
||||
done
|
||||
|
||||
dpkg-deb --root-owner-group --build target/release/dpkg target/release/squad-quest.deb
|
||||
|
|
@ -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.10.0", path = ".." }
|
||||
squad-quest = { version = "0.12.0", path = ".." }
|
||||
toml = "0.9.8"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ pub enum MapCommands {
|
|||
Delete(MapDeleteArgs),
|
||||
/// Update room data
|
||||
Update(MapUpdateArgs),
|
||||
/// Get room implementation data
|
||||
Data(MapDataArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
@ -55,3 +57,9 @@ pub struct MapUpdateArgs {
|
|||
#[arg(short,long)]
|
||||
pub value: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct MapDataArgs {
|
||||
/// Room ID
|
||||
pub id: u16,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,4 +40,6 @@ pub enum Objects {
|
|||
pub struct InitArgs {
|
||||
#[arg(long,short)]
|
||||
pub path: Option<PathBuf>,
|
||||
#[arg(long,short)]
|
||||
pub implpath: Option<PathBuf>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Date>,
|
||||
/// Limit on how many users can solve the quest (0 = no limit)
|
||||
#[arg(short,long)]
|
||||
pub limit: Option<u8>,
|
||||
}
|
||||
|
||||
#[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<Date>,
|
||||
/// Limit on how many users can solve the quest (0 = no limit)
|
||||
#[arg(long)]
|
||||
pub limit: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ fn main() {
|
|||
|
||||
let config = Config {
|
||||
path: path.clone(),
|
||||
impl_path: args.implpath.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
|
@ -146,7 +147,9 @@ fn main() {
|
|||
answer: args.answer.clone(),
|
||||
public: args.public,
|
||||
available_on: args.available.clone(),
|
||||
deadline: args.deadline.clone()
|
||||
deadline: args.deadline.clone(),
|
||||
limit: args.limit.unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
do_and_log(quest.save(path), !cli.quiet, format!("Created quest #{}.", quest.id));
|
||||
|
|
@ -168,7 +171,9 @@ fn main() {
|
|||
answer: args.answer.clone().unwrap_or(quest.answer.clone()),
|
||||
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())
|
||||
deadline: args.deadline.clone().or(quest.deadline.clone()),
|
||||
limit: args.limit.unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
do_and_log(quest.save(path), !cli.quiet, format!("Updated quest #{}.", quest.id));
|
||||
|
|
@ -281,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();
|
||||
|
||||
|
|
@ -296,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));
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
|
|
@ -444,6 +453,15 @@ fn main() {
|
|||
let connected = if connect { "Connected" } else { "Disconnected" };
|
||||
do_and_log(map_save(map, map_path), !cli.quiet, format!("{connected} rooms #{} <-> #{}.", args.first, args.second));
|
||||
},
|
||||
MapCommands::Data(args) => {
|
||||
if let Some(room) = map.room.iter().find(|r| r.id == args.id) {
|
||||
if let Some(data) = &room.data {
|
||||
for (key, value) in data {
|
||||
println!("{key} = {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ chrono = "0.4.42"
|
|||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
dotenvy = "0.15.7"
|
||||
poise = "0.6.1"
|
||||
rocket = { version = "0.5.1", features = ["json"] }
|
||||
serde = "1.0.228"
|
||||
squad-quest = { version = "0.10.0", path = ".." }
|
||||
serde_json = "1.0.146"
|
||||
squad-quest = { version = "0.12.0", path = ".." }
|
||||
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
|
||||
toml = "0.9.8"
|
||||
|
|
|
|||
8
discord/Rocket.toml
Normal file
8
discord/Rocket.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[default]
|
||||
address = "127.0.0.1" # should be local only because frontend runs on the same machine
|
||||
port = 2526
|
||||
log_level = "critical"
|
||||
|
||||
[default.shutdown]
|
||||
ctrlc = false
|
||||
|
||||
|
|
@ -1,15 +1,32 @@
|
|||
use poise::serenity_prelude::UserId;
|
||||
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) -> 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();
|
||||
match accounts.iter().find(|a| a.id == id) {
|
||||
Some(a) => a.clone(),
|
||||
None => Account {
|
||||
id,
|
||||
..Default::default()
|
||||
},
|
||||
|
||||
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 {
|
||||
let avatar = user.avatar_url().unwrap_or("null".to_string());
|
||||
let name = user.display_name().to_string();
|
||||
data.insert("avatar".to_string(), avatar);
|
||||
data.insert("name".to_string(), name);
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
|||
119
discord/src/api.rs
Normal file
119
discord/src/api.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
use rocket::{Build, Response, Rocket, State, http::{Header, hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN}, response::Responder, serde::json::Json};
|
||||
use serde::Serialize;
|
||||
use squad_quest::{SquadObject, account::Account, config::Config, map::{Map, Room}};
|
||||
|
||||
struct RocketData {
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UserData {
|
||||
pub id: String,
|
||||
pub avatar: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RoomData {
|
||||
pub id: u16,
|
||||
pub value: u32,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub markers: Vec<UserData>,
|
||||
}
|
||||
|
||||
struct RoomDataResponse {
|
||||
pub data: Vec<RoomData>
|
||||
}
|
||||
|
||||
impl From<Vec<RoomData>> for RoomDataResponse {
|
||||
fn from(value: Vec<RoomData>) -> Self {
|
||||
Self {
|
||||
data: value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Responder<'r, 'static> for RoomDataResponse {
|
||||
fn respond_to(self, request: &'r rocket::Request<'_>) -> rocket::response::Result<'static> {
|
||||
Response::build_from(Json(&self.data).respond_to(request)?)
|
||||
.header(Header::new(ACCESS_CONTROL_ALLOW_ORIGIN.as_str(), "http://localhost:5173"))
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Room> for RoomData {
|
||||
fn from(value: &Room) -> Self {
|
||||
let data = value.data.clone().unwrap_or_default();
|
||||
let keys = [ "x", "y", "w", "h" ];
|
||||
let mut values = [ 0f32, 0f32, 0f32, 0f32 ];
|
||||
let mut counter = 0usize;
|
||||
for key in keys {
|
||||
values[counter] = data.get(key).map_or(0f32, |v| v.parse::<f32>().unwrap_or_default());
|
||||
counter += 1;
|
||||
}
|
||||
RoomData {
|
||||
id: value.id,
|
||||
value: value.value,
|
||||
name: value.name.clone(),
|
||||
description: value.description.clone().unwrap_or(String::new()),
|
||||
x: values[0],
|
||||
y: values[1],
|
||||
w: values[2],
|
||||
h: values[3],
|
||||
markers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn acc_filt_map(account: &Account, room_id: u16) -> Option<UserData> {
|
||||
if account.location == room_id {
|
||||
let data = account.data.clone().unwrap_or_default();
|
||||
let keys = [ "avatar", "name" ];
|
||||
let empty = String::new();
|
||||
let mut values = [ &String::new(), &String::new() ];
|
||||
let mut counter = 0usize;
|
||||
for key in keys {
|
||||
values[counter] = data.get(key).unwrap_or(&empty);
|
||||
counter += 1;
|
||||
}
|
||||
Some(UserData {
|
||||
id: account.id.clone(),
|
||||
avatar: values[0].clone(),
|
||||
name: values[1].clone(),
|
||||
})
|
||||
} else { None }
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
fn index(rd: &State<RocketData>) -> RoomDataResponse {
|
||||
let map_path = rd.config.full_map_path();
|
||||
let Ok(map) = Map::load(map_path) else {
|
||||
return Vec::new().into();
|
||||
};
|
||||
let accounts = rd.config.load_accounts();
|
||||
|
||||
let rooms_vec: Vec<RoomData> = map.room.iter()
|
||||
.map(|r| {
|
||||
let mut rd = RoomData::from(r);
|
||||
let markers = accounts.iter()
|
||||
.filter_map(|a| acc_filt_map(a, r.id))
|
||||
.collect::<Vec<UserData>>();
|
||||
rd.markers = markers;
|
||||
rd
|
||||
})
|
||||
.collect();
|
||||
rooms_vec.into()
|
||||
}
|
||||
|
||||
pub fn rocket(config: Config) -> Rocket<Build> {
|
||||
rocket::build()
|
||||
.mount("/", routes![index])
|
||||
.manage(RocketData{config})
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
use poise::serenity_prelude::User;
|
||||
use squad_quest::{SquadObject, account::Account, map::Map};
|
||||
|
||||
use crate::{Context, Error, account::{account_full_balance, account_user_id, fetch_or_init_account}, strings::StringFormatter};
|
||||
use crate::{Context, Error, account::{account_full_balance, account_user_id}, strings::StringFormatter, commands::guild};
|
||||
|
||||
async fn account_balance_string(
|
||||
ctx: &Context<'_>,
|
||||
|
|
@ -26,6 +26,7 @@ async fn account_balance_string(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
name_localized("ru", "сбросить"),
|
||||
description_localized("ru", "Сбросить аккаунт пользователя, вкл. баланс, открытые комнаты и пройденные квесты"),
|
||||
|
|
@ -63,7 +64,8 @@ pub async fn reset(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
name_localized("ru", "счёт"),
|
||||
check = "guild",
|
||||
name_localized("ru", "счет"),
|
||||
description_localized("ru", "Отобразить таблицу лидеров"),
|
||||
)]
|
||||
pub async fn scoreboard(
|
||||
|
|
@ -106,6 +108,7 @@ pub async fn scoreboard(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
subcommands("give", "set"),
|
||||
name_localized("ru", "баланс"),
|
||||
)]
|
||||
|
|
@ -120,6 +123,7 @@ pub async fn balance(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "передать"),
|
||||
description_localized("ru", "Передать очки другому пользователю"),
|
||||
)]
|
||||
|
|
@ -142,29 +146,40 @@ 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);
|
||||
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?;
|
||||
|
|
@ -177,6 +192,7 @@ pub async fn give(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
name_localized("ru", "установить"),
|
||||
description_localized("ru", "Устанавливает текущий баланс пользователя"),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
use poise::serenity_prelude::{Attachment, ComponentInteractionCollector, CreateActionRow, CreateAttachment, CreateButton, CreateMessage, EditMessage};
|
||||
use squad_quest::SquadObject;
|
||||
|
||||
use crate::{Context, Error, account::fetch_or_init_account};
|
||||
use crate::{account::fetch_or_init_account, commands::{guild, quest::update_quest_message}, Context, Error};
|
||||
|
||||
/// Send an answer to the quest for review
|
||||
#[poise::command(
|
||||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "ответить"),
|
||||
description_localized("ru", "Отправить ответ на квест на проверку"),
|
||||
)]
|
||||
|
|
@ -34,18 +35,33 @@ 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());
|
||||
|
||||
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] {
|
||||
|
|
@ -134,16 +150,20 @@ pub async fn answer(
|
|||
let content: String;
|
||||
if is_approved {
|
||||
let mut no_errors = true;
|
||||
if let Err(error) = quest.complete_for_account(&mut account) {
|
||||
let mut accounts = ctx.data().config.load_accounts();
|
||||
if let Err(error) = quest.complete_for_account(&ctx.author().id.to_string(), &mut accounts) {
|
||||
eprintln!("{error}");
|
||||
no_errors = false;
|
||||
};
|
||||
let account = accounts.iter_mut().find(|a| a.id == user_id).expect("we done fetch_or_init earlier");
|
||||
let path = ctx.data().config.full_accounts_path();
|
||||
if let Err(error) = account.save(path) {
|
||||
eprintln!("{error}");
|
||||
no_errors = false;
|
||||
};
|
||||
|
||||
update_quest_message(ctx, &quest).await?;
|
||||
|
||||
formatter = formatter.current_balance(&account);
|
||||
|
||||
if no_errors {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use poise::{CreateReply, serenity_prelude::ChannelId};
|
|||
use squad_quest::SquadObject;
|
||||
use toml::value::Time;
|
||||
|
||||
use crate::{Context, Error, timer::DailyTimer};
|
||||
use crate::{Context, Error, timer::DailyTimer, commands::guild};
|
||||
|
||||
/// Set channels to post quests and answers to
|
||||
#[poise::command(
|
||||
|
|
@ -12,6 +12,7 @@ use crate::{Context, Error, timer::DailyTimer};
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "инит"),
|
||||
description_localized("ru", "Установить каналы для публикации квестов и ответов"),
|
||||
)]
|
||||
|
|
@ -75,11 +76,15 @@ fn seconds(time: Time) -> u64 {
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "таймер"),
|
||||
description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК-3)"),
|
||||
description_localized("ru", "Включить таймер публикации по заданной временной метке UTC (МСК -3)"),
|
||||
)]
|
||||
pub async fn timer(
|
||||
ctx: Context<'_>,
|
||||
#[description = "UTC time (in format HH:MM:SS, e.g. 9:00:00)"]
|
||||
#[name_localized("ru", "время")]
|
||||
#[description_localized("ru", "Время по UTC (МСК -3) в формате ЧЧ:ММ:СС, напр. 9:00:00")]
|
||||
time: TimeWrapper,
|
||||
) -> Result<(), Error> {
|
||||
if ctx.data().has_timer() {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
use poise::{serenity_prelude::{Attachment, CreateAttachment, CreateMessage}, CreateReply};
|
||||
use squad_quest::{SquadObject, map::Map};
|
||||
|
||||
use crate::{Context, account::fetch_or_init_account, error::Error};
|
||||
use crate::{Context, account::fetch_or_init_account, error::Error, commands::guild};
|
||||
|
||||
/// Unlock specified room if it is reachable and you have required amount of points
|
||||
#[poise::command(
|
||||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "открыть"),
|
||||
description_localized("ru", "Открывает указанную комнату, если хватает очков и до нее можно добраться"),
|
||||
)]
|
||||
|
|
@ -27,7 +29,10 @@ pub async fn unlock(
|
|||
};
|
||||
|
||||
let acc_id = format!("{}", ctx.author().id.get());
|
||||
let mut account = fetch_or_init_account(conf, acc_id);
|
||||
let mut account = match fetch_or_init_account(conf, &acc_id, Some(ctx.author())) {
|
||||
Ok(account) => account,
|
||||
Err(account) => account,
|
||||
};
|
||||
|
||||
if account.balance < room.value {
|
||||
return Err(Error::InsufficientFunds(room.value));
|
||||
|
|
@ -55,6 +60,7 @@ pub async fn unlock(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "пойти"),
|
||||
description_localized("ru", "Переместиться в другую разблокированную комнату"),
|
||||
)]
|
||||
|
|
@ -68,10 +74,13 @@ 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);
|
||||
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::RoomNotFound(id));
|
||||
return Err(Error::CannotReach(id));
|
||||
}
|
||||
|
||||
account.location = id;
|
||||
|
|
@ -88,3 +97,99 @@ pub async fn r#move(
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change avatar on web map
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
prefix_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "аватар"),
|
||||
description_localized("ru", "Сменить аватар на веб карте"),
|
||||
)]
|
||||
pub async fn avatar(
|
||||
ctx: Context<'_>,
|
||||
#[description = "URL to the avatar"]
|
||||
#[name_localized("ru", "ссылка")]
|
||||
#[description_localized("ru", "Ссылка на аватар")]
|
||||
url: Option<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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
use std::error::Error as StdError;
|
||||
use poise::CreateReply;
|
||||
use poise::{CreateReply, serenity_prelude::GuildId};
|
||||
use squad_quest::quest::Quest;
|
||||
|
||||
use crate::{Context, Data, Error};
|
||||
use crate::{error, Context, Data, Error};
|
||||
|
||||
pub mod quest;
|
||||
pub mod init;
|
||||
|
|
@ -10,6 +11,18 @@ pub mod social;
|
|||
pub mod account;
|
||||
pub mod map;
|
||||
|
||||
pub async fn guild(ctx: Context<'_>) -> Result<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)]
|
||||
pub async fn register(ctx: Context<'_>) -> Result<(), Error> {
|
||||
poise::builtins::register_application_commands_buttons(ctx).await?;
|
||||
|
|
@ -36,10 +49,45 @@ pub async fn error_handler(error: poise::FrameworkError<'_, Data, Error>) {
|
|||
eprintln!("ERROR:");
|
||||
print_error_recursively(&error);
|
||||
if let Some(ctx) = error.ctx() {
|
||||
let response = match error {
|
||||
poise::FrameworkError::Command { error, .. } => format!("Internal server error: {error}"),
|
||||
_ => format!("Internal server error: {error}"),
|
||||
let user = ctx.author().display_name();
|
||||
|
||||
eprintln!("User: {user} ({id})", id = ctx.author().id);
|
||||
eprintln!("Invokation string: {}", ctx.invocation_string());
|
||||
|
||||
let strings = &ctx.data().strings;
|
||||
|
||||
let response = if let poise::FrameworkError::Command { error, .. } = error {
|
||||
let formatter = match error {
|
||||
error::Error::QuestNotFound(id) |
|
||||
error::Error::QuestIsPublic(id) |
|
||||
error::Error::QuestIsCompleted(id) |
|
||||
error::Error::QuestLimitExceeded(id) =>
|
||||
strings.formatter().quest(&Quest { id, ..Default::default() }),
|
||||
|
||||
error::Error::InsufficientFunds(amount) =>
|
||||
strings.formatter().value(amount),
|
||||
|
||||
error::Error::RoomNotFound(value) |
|
||||
error::Error::RoomAlreadyUnlocked(value) |
|
||||
error::Error::CannotReach(value) =>
|
||||
strings.formatter().value(value),
|
||||
|
||||
error::Error::SerenityError(ref error) =>
|
||||
strings.formatter().text(error),
|
||||
|
||||
error::Error::SquadQuestError(ref error) =>
|
||||
strings.formatter().text(error),
|
||||
|
||||
_ => strings.formatter(),
|
||||
};
|
||||
|
||||
let error_string = error.formattable_string(&strings.error);
|
||||
formatter.fmt(error_string)
|
||||
} else {
|
||||
let formatter = strings.formatter().text(&error);
|
||||
formatter.fmt(&strings.error.non_command_error)
|
||||
};
|
||||
|
||||
if let Err(error) = ctx.send(CreateReply::default().content(response).ephemeral(true)).await {
|
||||
eprintln!("Couldn't send error message: {error}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
use std::{future, str::FromStr};
|
||||
|
||||
use poise::serenity_prelude::{CreateMessage, EditMessage, Message, futures::StreamExt};
|
||||
use squad_quest::{SquadObject, quest::{Quest, QuestDifficulty}};
|
||||
use poise::serenity_prelude as serenity;
|
||||
use serenity::{CreateMessage, EditMessage, Message, futures::StreamExt};
|
||||
use squad_quest::{account::Account, quest::{Quest, QuestDifficulty}, SquadObject};
|
||||
use toml::value::Date;
|
||||
use crate::{Context, Error};
|
||||
use crate::{Context, Error,commands::guild};
|
||||
|
||||
async fn find_quest_message(ctx: Context<'_>, id: u16) -> Result<Option<Message>, Error>{
|
||||
ctx.defer().await?;
|
||||
|
|
@ -24,19 +25,45 @@ 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,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
subcommands("list", "create", "update", "publish", "delete"),
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
name_localized("ru", "квесты"),
|
||||
name_localized("ru", "квест"),
|
||||
)]
|
||||
pub async fn quest(
|
||||
_ctx: Context<'_>,
|
||||
|
|
@ -49,6 +76,7 @@ pub async fn quest(
|
|||
prefix_command,
|
||||
slash_command,
|
||||
guild_only,
|
||||
check = "guild",
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
name_localized("ru", "список"),
|
||||
description_localized("ru", "Вывести все квесты")
|
||||
|
|
@ -114,6 +142,7 @@ impl From<DateWrapper> for Date {
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "создать"),
|
||||
description_localized("ru", "Создать квест и получить его идентификатор"),
|
||||
)]
|
||||
|
|
@ -143,6 +172,10 @@ pub async fn create(
|
|||
#[name_localized("ru", "доступен")]
|
||||
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24)")]
|
||||
available: Option<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", "дедлайн")]
|
||||
|
|
@ -179,6 +212,7 @@ pub async fn create(
|
|||
answer,
|
||||
public: false,
|
||||
available_on,
|
||||
limit: limit.unwrap_or_default(),
|
||||
//deadline,
|
||||
..Default::default()
|
||||
};
|
||||
|
|
@ -202,6 +236,7 @@ pub async fn create(
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "обновить"),
|
||||
description_localized("ru", "Обновить выбранные значения указанного квеста"),
|
||||
)]
|
||||
|
|
@ -235,6 +270,10 @@ pub async fn update(
|
|||
#[name_localized("ru", "доступен")]
|
||||
#[description_localized("ru", "Дата публикации (в формате ГГГГ-ММ-ДД, напр. 2025-12-24")]
|
||||
available: Option<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", "дедлайн")]
|
||||
|
|
@ -260,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()));
|
||||
},
|
||||
}
|
||||
|
|
@ -282,6 +324,7 @@ pub async fn update(
|
|||
answer: answer.unwrap_or(quest.answer.clone()),
|
||||
public: quest.public,
|
||||
available_on,
|
||||
limit: new_limit,
|
||||
//deadline: dead_line,
|
||||
..Default::default()
|
||||
};
|
||||
|
|
@ -290,16 +333,7 @@ pub async fn update(
|
|||
let formatter = strings.formatter().quest(&new_quest);
|
||||
|
||||
if new_quest.public {
|
||||
let content = make_quest_message_content(ctx, &new_quest);
|
||||
let builder = EditMessage::new().content(content);
|
||||
|
||||
let message = find_quest_message(ctx, id).await?;
|
||||
if let Some(mut message) = message {
|
||||
message.edit(ctx, builder).await?;
|
||||
} else {
|
||||
let reply_string = formatter.fmt(&strings.quest.message_not_found);
|
||||
ctx.reply(reply_string).await?;
|
||||
}
|
||||
update_quest_message(ctx, &new_quest).await?;
|
||||
}
|
||||
|
||||
let path = conf.full_quests_path();
|
||||
|
|
@ -310,13 +344,14 @@ pub async fn update(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Error> {
|
||||
pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<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 accounts = ctx.data().config.load_accounts();
|
||||
let content = make_quest_message_content(ctx, &quest, &Some(accounts));
|
||||
|
||||
let builder = CreateMessage::new()
|
||||
.content(content);
|
||||
|
|
@ -327,8 +362,10 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er
|
|||
guard.quests_channel
|
||||
};
|
||||
|
||||
channel.send_message(ctx, builder).await?;
|
||||
Ok(())
|
||||
match channel.send_message(ctx, builder).await {
|
||||
Ok(m) => Ok(m),
|
||||
Err(error) => Err(Error::SerenityError(error)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark quest as public and send its message in quests channel
|
||||
|
|
@ -337,6 +374,7 @@ pub async fn publish_inner(ctx: Context<'_>, quest: &mut Quest) -> Result<(), Er
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "опубликовать"),
|
||||
description_localized("ru", "Отметить квест как публичный и отправить его сообщение в канал квестов"),
|
||||
)]
|
||||
|
|
@ -357,10 +395,12 @@ pub async fn publish(
|
|||
return Err(Error::QuestIsPublic(id));
|
||||
}
|
||||
|
||||
publish_inner(ctx, quest).await?;
|
||||
let message = publish_inner(ctx, quest).await?;
|
||||
|
||||
let strings = &ctx.data().strings;
|
||||
let formatter = strings.formatter().quest(&quest);
|
||||
let formatter = strings.formatter()
|
||||
.quest(&quest)
|
||||
.message(&message);
|
||||
|
||||
let reply_string = formatter.fmt(&strings.quest.publish);
|
||||
ctx.reply(reply_string).await?;
|
||||
|
|
@ -374,6 +414,7 @@ pub async fn publish(
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "удалить"),
|
||||
description_localized("ru", "Удалить квест (и его сообщение, если он был опубликован)"),
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
use poise::serenity_prelude::{Attachment, ChannelId, CreateAttachment, CreateMessage, EditMessage, MessageId, UserId};
|
||||
|
||||
use crate::{Context, Error};
|
||||
use crate::{Context, Error, commands::guild};
|
||||
|
||||
#[poise::command(
|
||||
prefix_command,
|
||||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
subcommands("msg", "edit", "undo"),
|
||||
name_localized("ru", "сообщение"),
|
||||
)]
|
||||
|
|
@ -20,6 +21,7 @@ pub async fn social( _ctx: Context<'_> ) -> Result<(), Error> {
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "написать"),
|
||||
description_localized("ru", "Отправить сообщение пользователю или в канал"),
|
||||
)]
|
||||
|
|
@ -98,6 +100,7 @@ pub async fn msg (
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "редактировать"),
|
||||
description_localized("ru", "Редактировать сообщение в канале или в ЛС"),
|
||||
)]
|
||||
|
|
@ -177,6 +180,7 @@ pub async fn edit (
|
|||
slash_command,
|
||||
required_permissions = "ADMINISTRATOR",
|
||||
guild_only,
|
||||
check = "guild",
|
||||
name_localized("ru", "удалить"),
|
||||
description_localized("ru", "Удалить сообщение в канале или в ЛС"),
|
||||
)]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ use std::fmt::Display;
|
|||
use poise::serenity_prelude as serenity;
|
||||
use squad_quest::error::MapError;
|
||||
|
||||
use crate::strings::ErrorStrings;
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
|
|
@ -21,6 +23,11 @@ pub enum Error {
|
|||
RoomAlreadyUnlocked(u16),
|
||||
CannotReach(u16),
|
||||
TimerSet,
|
||||
NotThisGuild,
|
||||
QuestLimitExceeded(u16),
|
||||
BothUrlAndAttachment,
|
||||
NoUrlOrAttachment,
|
||||
NonImageAttachment,
|
||||
}
|
||||
|
||||
impl From<serenity::Error> for Error {
|
||||
|
|
@ -53,9 +60,9 @@ impl Display for Error {
|
|||
Self::QuestNotFound(u16) => write!(f, "quest #{u16} not found"),
|
||||
Self::QuestIsPublic(u16) => write!(f, "quest #{u16} is already public"),
|
||||
Self::QuestIsCompleted(u16) => write!(f, "quest #{u16} is already completed for this user"),
|
||||
Self::NoContent => write!(f, "no text or attachment was specified"),
|
||||
Self::NoChannelOrUser => write!(f, "no channel or user was specified"),
|
||||
Self::BothChannelAndUser => write!(f, "both channel and user was specified"),
|
||||
Self::NoContent => write!(f, "no text or attachment were specified"),
|
||||
Self::NoChannelOrUser => write!(f, "no channel or user were specified"),
|
||||
Self::BothChannelAndUser => write!(f, "both channel and user were specified"),
|
||||
Self::SerenityError(_) => write!(f, "discord interaction error"),
|
||||
Self::SquadQuestError(_) => write!(f, "internal logic error"),
|
||||
Self::AccountNotFound => write!(f, "account not found"),
|
||||
|
|
@ -65,6 +72,11 @@ impl Display for Error {
|
|||
Self::RoomAlreadyUnlocked(id) => write!(f, "room #{id} is already unlocked for this user"),
|
||||
Self::CannotReach(id) => write!(f, "user cannot reach room #{id}"),
|
||||
Self::TimerSet => write!(f, "timer is already set"),
|
||||
Self::NotThisGuild => write!(f, "cannot be used in this guild"),
|
||||
Self::QuestLimitExceeded(id) => write!(f, "exceeded limit for quest #{id}"),
|
||||
Self::BothUrlAndAttachment => write!(f, "both url and attachment were specified"),
|
||||
Self::NoUrlOrAttachment => write!(f, "no url or attachment were specified"),
|
||||
Self::NonImageAttachment => write!(f, "attachment is not an image"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -84,11 +96,41 @@ impl std::error::Error for Error {
|
|||
Self::RoomNotFound(_) |
|
||||
Self::RoomAlreadyUnlocked(_) |
|
||||
Self::CannotReach(_) |
|
||||
Self::TimerSet => None,
|
||||
Self::TimerSet |
|
||||
Self::NotThisGuild |
|
||||
Self::QuestLimitExceeded(_) |
|
||||
Self::BothUrlAndAttachment |
|
||||
Self::NoUrlOrAttachment |
|
||||
Self::NonImageAttachment => None,
|
||||
Self::SerenityError(error) => Some(error),
|
||||
Self::SquadQuestError(error) => Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl<'a> Error {
|
||||
pub fn formattable_string(&self, errors: &'a ErrorStrings) -> &'a str {
|
||||
match self {
|
||||
Self::QuestNotFound(_) => &errors.quest_not_found,
|
||||
Self::QuestIsPublic(_) => &errors.quest_is_public,
|
||||
Self::QuestIsCompleted(_) => &errors.quest_is_completed,
|
||||
Self::NoContent => &errors.no_content,
|
||||
Self::NoChannelOrUser => &errors.no_channel_or_user,
|
||||
Self::BothChannelAndUser => &errors.both_channel_and_user,
|
||||
Self::SerenityError(_) => &errors.discord_error,
|
||||
Self::SquadQuestError(_) => &errors.library_error,
|
||||
Self::AccountNotFound => &errors.account_not_found,
|
||||
Self::AccountIsSelf => &errors.account_is_self,
|
||||
Self::InsufficientFunds(_) => &errors.insufficient_funds,
|
||||
Self::RoomNotFound(_) => &errors.room_not_found,
|
||||
Self::RoomAlreadyUnlocked(_) => &errors.room_already_unlocked,
|
||||
Self::CannotReach(_) => &errors.cannot_reach,
|
||||
Self::TimerSet => &errors.timer_set,
|
||||
Self::NotThisGuild => &errors.not_this_guild,
|
||||
Self::QuestLimitExceeded(_) => &errors.quest_limit_exceeded,
|
||||
Self::BothUrlAndAttachment => &errors.both_url_and_attachment,
|
||||
Self::NoUrlOrAttachment => &errors.no_url_or_attachment,
|
||||
Self::NonImageAttachment => &errors.non_image_attachment,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
#[macro_use] extern crate rocket;
|
||||
|
||||
use std::{sync::{Arc, Mutex}};
|
||||
|
||||
use clap::Parser;
|
||||
|
|
@ -5,8 +7,9 @@ use dotenvy::dotenv;
|
|||
use poise::serenity_prelude as serenity;
|
||||
use squad_quest::config::Config;
|
||||
|
||||
use crate::{commands::error_handler, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings};
|
||||
use crate::{commands::{error_handler, print_error_recursively}, config::{ConfigImpl, DiscordConfig}, error::Error, strings::Strings};
|
||||
|
||||
mod api;
|
||||
mod commands;
|
||||
mod cli;
|
||||
mod config;
|
||||
|
|
@ -65,6 +68,14 @@ async fn main() {
|
|||
let token = std::env::var(DISCORD_TOKEN).expect("missing DISCORD_TOKEN");
|
||||
let intents = serenity::GatewayIntents::non_privileged();
|
||||
|
||||
let conf1 = config.clone();
|
||||
tokio::spawn(async {
|
||||
if let Err(error) = api::rocket(conf1).launch().await {
|
||||
eprintln!("ERROR ON API LAUNCH");
|
||||
print_error_recursively(&error);
|
||||
}
|
||||
});
|
||||
|
||||
let framework = poise::Framework::builder()
|
||||
.options(poise::FrameworkOptions {
|
||||
on_error: |err| Box::pin(error_handler(err)),
|
||||
|
|
@ -81,12 +92,15 @@ async fn main() {
|
|||
commands::account::reset(),
|
||||
commands::map::unlock(),
|
||||
commands::map::r#move(),
|
||||
commands::map::avatar(),
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.setup(|_ctx, _ready, _framework| {
|
||||
Box::pin(async move {
|
||||
//poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||
|
||||
|
||||
Ok(Data {
|
||||
config,
|
||||
discord: Arc::new(Mutex::new(discord)),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
@ -133,6 +142,7 @@ pub struct Strings {
|
|||
pub scoreboard: Scoreboard,
|
||||
pub social: Social,
|
||||
pub quest: QuestStrings,
|
||||
pub error: ErrorStrings,
|
||||
}
|
||||
|
||||
impl Default for Strings {
|
||||
|
|
@ -151,6 +161,7 @@ impl Default for Strings {
|
|||
social: Social::default(),
|
||||
account: AccountReplies::default(),
|
||||
map: MapReplies::default(),
|
||||
error: ErrorStrings::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -328,7 +339,7 @@ impl Default for QuestStrings {
|
|||
list_item: "{n}{q.id}: {q.name}{n} Description: {q.description}".to_string(),
|
||||
create: "Created quest {q.id}".to_string(),
|
||||
update: "Updated quest {q.id}".to_string(),
|
||||
publish: "Published quest {q.id}: {text}".to_string(),
|
||||
publish: "Published quest {q.id}: {m.link}".to_string(),
|
||||
delete: "Deleted quest {q.id}".to_string(),
|
||||
message_format: "### `{q.id}` {q.name} (+{q.reward}){n}\
|
||||
Difficulty: *{q.difficulty}*{n}\
|
||||
|
|
@ -382,6 +393,8 @@ impl Default for AccountReplies {
|
|||
pub struct MapReplies {
|
||||
pub room_unlocked: String,
|
||||
pub moved_to_room: String,
|
||||
pub updated_avatar: String,
|
||||
pub processing_url: String,
|
||||
}
|
||||
|
||||
impl Default for MapReplies {
|
||||
|
|
@ -389,6 +402,62 @@ impl Default for MapReplies {
|
|||
Self {
|
||||
room_unlocked: "Unlocked room #{value}. Your balance: {b.current}".to_string(),
|
||||
moved_to_room: "Moved to room #{value}".to_string(),
|
||||
updated_avatar: "Successfully changed avatar".to_string(),
|
||||
processing_url: "Processing URL...".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct ErrorStrings {
|
||||
pub non_command_error: String,
|
||||
pub quest_not_found: String,
|
||||
pub quest_is_public: String,
|
||||
pub quest_is_completed: String,
|
||||
pub no_content: String,
|
||||
pub no_channel_or_user: String,
|
||||
pub both_channel_and_user: String,
|
||||
pub discord_error: String,
|
||||
pub library_error: String,
|
||||
pub account_not_found: String,
|
||||
pub account_is_self: String,
|
||||
pub insufficient_funds: String,
|
||||
pub room_not_found: String,
|
||||
pub room_already_unlocked: String,
|
||||
pub cannot_reach: String,
|
||||
pub timer_set: String,
|
||||
pub not_this_guild: String,
|
||||
pub quest_limit_exceeded: String,
|
||||
pub both_url_and_attachment: String,
|
||||
pub no_url_or_attachment: String,
|
||||
pub non_image_attachment: String,
|
||||
}
|
||||
|
||||
impl Default for ErrorStrings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
non_command_error: "Internal server error: {text}".to_string(),
|
||||
quest_not_found: "Quest {q.id} not found".to_string(),
|
||||
quest_is_public: "Quest {q.id} is already public".to_string(),
|
||||
quest_is_completed: "Quest {q.id} is already completed for this user".to_string(),
|
||||
no_content: "No text or attachment were specified".to_string(),
|
||||
no_channel_or_user: "No channel or user were specified".to_string(),
|
||||
both_channel_and_user: "Both channel and user were specified".to_string(),
|
||||
discord_error: "Discord interaction error: {text}".to_string(),
|
||||
library_error: "Some internal logic error: {text}".to_string(),
|
||||
account_not_found: "Given account was not found".to_string(),
|
||||
account_is_self: "Given account is the same as command invoker".to_string(),
|
||||
insufficient_funds: "You don't have {value} points".to_string(),
|
||||
room_not_found: "Room #{value} not found".to_string(),
|
||||
room_already_unlocked: "Room #{value} is already unlocked for this account".to_string(),
|
||||
cannot_reach: "You cannot reach room #{value}".to_string(),
|
||||
timer_set: "Timer is already set".to_string(),
|
||||
not_this_guild: "Bot cannot be used in this guild".to_string(),
|
||||
quest_limit_exceeded: "Exceeded limit for quest {q.id}".to_string(),
|
||||
both_url_and_attachment: "Both URL and attachment were specified".to_string(),
|
||||
no_url_or_attachment: "No URL or attachment were specified".to_string(),
|
||||
non_image_attachment: "Given attachment is not an image".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
//! User accounts
|
||||
|
||||
use std::{fs, io::Write, path::PathBuf};
|
||||
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()
|
||||
|
|
@ -29,6 +29,9 @@ pub struct Account {
|
|||
|
||||
/// Vec of rooms unlocked by this user
|
||||
pub rooms_unlocked: Vec<u16>,
|
||||
|
||||
/// Additional implementation-defined data
|
||||
pub data: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Default for Account {
|
||||
|
|
@ -39,6 +42,7 @@ impl Default for Account {
|
|||
location: u16::default(),
|
||||
quests_completed: Vec::new(),
|
||||
rooms_unlocked: Vec::new(),
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -95,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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
src/bin/deb.rs
Normal file
25
src/bin/deb.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! This binary generates DEBIAN/control text for use in debian package
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let version = env!("CARGO_PKG_VERSION");
|
||||
let homepage = env!("CARGO_PKG_HOMEPAGE");
|
||||
let dpkg_arch = {
|
||||
let output = match Command::new("dpkg")
|
||||
.arg("--print-architecture")
|
||||
.output() {
|
||||
Ok(out) => out,
|
||||
Err(error) => panic!("error running dpkg: {error}"),
|
||||
};
|
||||
String::from_utf8(output.stdout).expect("dpkg returned ill UTF-8")
|
||||
};
|
||||
println!("Package: squad-quest\n\
|
||||
Version: {version}-1\n\
|
||||
Architecture: {dpkg_arch}\
|
||||
Section: misc\n\
|
||||
Priority: optional\n\
|
||||
Homepage: {homepage}\n\
|
||||
Description: Simple RPG-like system for hosting events\n \
|
||||
Includes discord bot and CLI\n\
|
||||
Maintainer: Alexey Mirenkov <2ndbeam@disroot.org>");
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
|||
use crate::{SquadObject, account::Account, error::Error, quest::Quest};
|
||||
|
||||
/// Struct for containing paths to other (de-)serializable things
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// Path to config directory
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Map, a.k.a. a graph of rooms
|
||||
|
||||
use std::{fs, io::Write, path::PathBuf};
|
||||
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ use crate::{SquadObject, account::Account, error::{Error, MapError}};
|
|||
#[serde(default)]
|
||||
pub struct Map {
|
||||
/// Rooms go here
|
||||
pub room: Vec<Room>
|
||||
pub room: Vec<Room>,
|
||||
}
|
||||
|
||||
impl Default for Map {
|
||||
|
|
@ -131,6 +131,8 @@ pub struct Room {
|
|||
pub name: String,
|
||||
/// Room description
|
||||
pub description: Option<String>,
|
||||
/// Additional implementation-based data
|
||||
pub data: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
|
|
@ -145,6 +147,7 @@ impl Default for Room {
|
|||
value: u32::default(),
|
||||
name: default_name(),
|
||||
description: None,
|
||||
data: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Text-based quests and user solutions for them
|
||||
|
||||
use std::{fs, io::Write, path::PathBuf};
|
||||
use std::{collections::HashMap, fs, io::Write, path::PathBuf};
|
||||
|
||||
use serde::{ Serialize, Deserialize };
|
||||
use crate::{SquadObject, account::Account, error::{Error, QuestError}};
|
||||
|
|
@ -66,7 +66,14 @@ pub struct Quest {
|
|||
pub available_on: Option<Date>,
|
||||
|
||||
/// When quest expires
|
||||
pub deadline: Option<Date>
|
||||
pub deadline: Option<Date>,
|
||||
|
||||
/// Additional implementation-defined data
|
||||
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 {
|
||||
|
|
@ -80,7 +87,9 @@ impl Default for Quest {
|
|||
answer: default_answer(),
|
||||
public: false,
|
||||
available_on: None,
|
||||
deadline: None
|
||||
deadline: None,
|
||||
data: None,
|
||||
limit: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -155,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<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) {
|
||||
Some(_) => Err(QuestError::AlreadyCompleted(self.id, account.id.clone())),
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ fn quest_one() {
|
|||
answer: "Accept the answer if it has no attachments and an empty comment".to_owned(),
|
||||
public: false,
|
||||
available_on: None,
|
||||
deadline: None
|
||||
deadline: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(*quest, expected);
|
||||
|
|
@ -73,7 +74,8 @@ fn account_test() {
|
|||
balance: 150,
|
||||
location: 0,
|
||||
quests_completed: vec![0],
|
||||
rooms_unlocked: Vec::new()
|
||||
rooms_unlocked: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let accounts = config.load_accounts();
|
||||
|
|
@ -92,6 +94,7 @@ fn load_map() {
|
|||
value: 0,
|
||||
name: "Entrance".to_string(),
|
||||
description: Some("Enter the dungeon".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let room1 = Room {
|
||||
|
|
@ -100,6 +103,7 @@ fn load_map() {
|
|||
value: 100,
|
||||
name: "Kitchen hall".to_string(),
|
||||
description: None,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let room2 = Room {
|
||||
|
|
@ -108,6 +112,7 @@ fn load_map() {
|
|||
value: 250,
|
||||
name: "Room".to_string(),
|
||||
description: Some("Simple room with no furniture".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let room3 = Room {
|
||||
|
|
@ -116,6 +121,7 @@ fn load_map() {
|
|||
value: 175,
|
||||
name: "Kitchen".to_string(),
|
||||
description: Some("Knives are stored here".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let expected = Map {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue