feat: Added API for web map in discord bot

- Bump version to 0.11.0
- Added data table to quests, accounts and rooms
- Discord bot now adds "avatar" and "name" data to accounts on init
- Added CLI "map data" command
This commit is contained in:
Alexey 2025-12-24 14:30:40 +03:00
commit c22787792d
18 changed files with 1161 additions and 224 deletions

View file

@ -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.11.0", path = ".." }
tokio = { version = "1.48.0", features = ["rt-multi-thread"] }
toml = "0.9.8"

8
discord/Rocket.toml Normal file
View 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

View file

@ -1,12 +1,24 @@
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 {
pub fn fetch_or_init_account(conf: &Config, id: String, user: Option<&User>) -> Account {
let accounts = conf.load_accounts();
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);
}
match accounts.iter().find(|a| a.id == id) {
Some(a) => a.clone(),
None => Account {
id,
data: Some(data),
..Default::default()
},
}

119
discord/src/api.rs Normal file
View 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})
}

View file

@ -142,7 +142,8 @@ 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 mut user_account = fetch_or_init_account(config, user_id, Some(ctx.author()));
let who_id = format!("{}", who.id.get());
let Some(other_account) = accounts.iter_mut().find(|a| a.id == who_id ) else {

View file

@ -34,7 +34,7 @@ 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());
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));

View file

@ -27,7 +27,7 @@ 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 = fetch_or_init_account(conf, acc_id, Some(ctx.author()));
if account.balance < room.value {
return Err(Error::InsufficientFunds(room.value));
@ -68,7 +68,7 @@ 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 = fetch_or_init_account(conf, acc_id, Some(ctx.author()));
if let None = account.rooms_unlocked.iter().find(|rid| **rid == id) {
return Err(Error::CannotReach(id));

View file

@ -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)),
@ -87,6 +98,8 @@ async fn main() {
.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)),