fmt: repo-wide baseline (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 11:53:59 +02:00
parent 899fde8fc3
commit 337e9bcf2c
19 changed files with 847 additions and 335 deletions

View file

@ -3,9 +3,9 @@
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
pub mod middleware;
pub mod routes;
pub mod ws;
pub mod middleware;
#[tokio::main]
async fn main() {

View file

@ -1,6 +1,6 @@
use axum::{
extract::Request,
http::{header, StatusCode},
http::{StatusCode, header},
middleware::Next,
response::Response,
};
@ -13,19 +13,14 @@ pub struct AuthUser {
pub role: String,
}
pub async fn auth_middleware(
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
pub async fn auth_middleware(mut request: Request, next: Next) -> Result<Response, StatusCode> {
let auth_header = request
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let token = auth_header
.strip_prefix("Bearer ")
.unwrap_or(auth_header);
let token = auth_header.strip_prefix("Bearer ").unwrap_or(auth_header);
if token.is_empty() {
return Err(StatusCode::UNAUTHORIZED);

View file

@ -0,0 +1 @@

View file

@ -1,26 +1,23 @@
#![allow(private_interfaces)]
use crate::middleware::auth::AuthUser;
use axum::extract::{Path, Query, State};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use crate::middleware::auth::AuthUser;
use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS};
use sb_core::combat::{self, CombatStats};
use sb_core::combat::parse_attack_type;
use sb_core::combat::{self, CombatStats};
use sb_core::data;
use sb_core::services as domain;
use sb_core::items;
use sb_core::services as domain;
use sb_core::songs;
use sb_core::spells;
use sb_shared::{
Character, Class, CreateCharacter, Item, Role, User,
ApiInfoResponse, RacesResponse, RaceInfo,
ClassesResponse, ClassInfo, EnemiesResponse, EnemySummary,
AdventuresResponse, ZoneSummary,
GymResponse, TrainStatResponse, TrainingStatResponse, ShopResponse,
InventoryItemResponse, ShopItemResponse, ShopActionResponse,
SongsResponse, SongSummary, SetSongResponse,
LevelUpResponse,
AdventuresResponse, ApiInfoResponse, Character, Class, ClassInfo, ClassesResponse,
CreateCharacter, EnemiesResponse, EnemySummary, GymResponse, InventoryItemResponse, Item,
LevelUpResponse, RaceInfo, RacesResponse, Role, SetSongResponse, ShopActionResponse,
ShopItemResponse, ShopResponse, SongSummary, SongsResponse, TrainStatResponse,
TrainingStatResponse, User, ZoneSummary,
};
use std::sync::Arc;
use tokio::sync::RwLock;
@ -93,7 +90,10 @@ pub fn build(state: Arc<AppState>) -> Router {
.route("/api/classes", get(get_classes))
.route("/api/enemies", get(get_enemies))
.route("/api/adventures", get(get_adventures))
.route("/api/users/{user_id}/characters", get(get_characters).post(create_character))
.route(
"/api/users/{user_id}/characters",
get(get_characters).post(create_character),
)
.route("/api/characters/{id}", get(get_character))
.route("/api/characters/{id}/adventure", get(run_adventure))
.route("/api/characters/{id}/equip", post(equip_item))
@ -102,7 +102,10 @@ pub fn build(state: Arc<AppState>) -> Router {
.route("/api/characters/{id}/shop", get(get_shop).post(shop_action))
.route("/api/characters/{id}/heal", post(heal_character))
.route("/api/characters/{id}/songs", get(get_songs).post(set_song))
.route("/api/characters/{id}/spells", get(get_spells).post(learn_spell_level))
.route(
"/api/characters/{id}/spells",
get(get_spells).post(learn_spell_level),
)
.route("/api/characters/{id}/level-up", post(level_up))
.route_layer(axum::middleware::from_fn(
crate::middleware::auth::auth_middleware,
@ -264,7 +267,11 @@ async fn get_characters(
for c in chars.iter_mut() {
sb_core::character::apply_regen(c, now);
}
let user_chars: Vec<Character> = chars.iter().filter(|c| c.user_id == user_id).cloned().collect();
let user_chars: Vec<Character> = chars
.iter()
.filter(|c| c.user_id == user_id)
.cloned()
.collect();
Ok(Json(user_chars))
}
@ -317,8 +324,10 @@ async fn get_character(
let char = chars.iter().find(|c| c.id == id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let now = std::time::SystemTime::now()
@ -326,7 +335,9 @@ async fn get_character(
.unwrap()
.as_millis() as i64;
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == id)
let char = chars
.iter_mut()
.find(|c| c.id == id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
sb_core::character::apply_regen(char, now);
Ok(Json(char.clone()))
@ -343,7 +354,10 @@ async fn equip_item(
Path(char_id): Path<u32>,
Json(input): Json<EquipRequest>,
) -> Result<Json<Character>, axum::http::StatusCode> {
let item = state.items.iter().find(|i| i.id == input.item_id)
let item = state
.items
.iter()
.find(|i| i.id == input.item_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
{
@ -351,14 +365,18 @@ async fn equip_item(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
domain::equip_item(char, item, &input.item_id)
@ -387,17 +405,20 @@ async fn unequip_item(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
domain::unequip_item(char, &input.slot)
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
domain::unequip_item(char, &input.slot).map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
char.clone()
};
@ -419,12 +440,17 @@ async fn run_adventure(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let zone = state.zones.iter().find(|z| z.id == zone_id)
let zone = state
.zones
.iter()
.find(|z| z.id == zone_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let now = std::time::SystemTime::now()
@ -436,7 +462,9 @@ async fn run_adventure(
let (char, enemy_def) = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
sb_core::character::apply_regen(char, now);
@ -500,7 +528,10 @@ async fn run_adventure(
enemy_def.gold_min,
enemy_def.gold_max,
char.active_song.as_deref().and_then(|sid| {
songs::BARD_SONGS.iter().find(|s| s.id == sid).map(|s| s.combat_effect)
songs::BARD_SONGS
.iter()
.find(|s| s.id == sid)
.map(|s| s.combat_effect)
}),
spell_effect.as_ref(),
&mut rng,
@ -514,7 +545,9 @@ async fn run_adventure(
if log.winner == c.name {
c.xp += log.xp_gained as i64;
c.gold += log.gold_gained;
let char_hp = log.rounds.iter()
let char_hp = log
.rounds
.iter()
.rfind(|r| r.defender == c.name)
.map(|r| r.defender_hp);
if let Some(hp) = char_hp {
@ -548,9 +581,16 @@ async fn run_adventure(
let updated = chars.iter().find(|c| c.id == char_id).cloned();
drop(chars);
let loot: Vec<String> = dropped_ids.iter().filter_map(|id| {
state.items.iter().find(|i| &i.id == id).map(|i| i.name.clone())
}).collect();
let loot: Vec<String> = dropped_ids
.iter()
.filter_map(|id| {
state
.items
.iter()
.find(|i| &i.id == id)
.map(|i| i.name.clone())
})
.collect();
(loot, updated)
};
@ -576,14 +616,19 @@ async fn get_gym(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let char = {
let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
chars
.iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
};
let stats = [
("strength", char.stats.strength),
@ -592,16 +637,22 @@ async fn get_gym(
("constitution", char.stats.constitution),
("luck", char.stats.luck),
];
let stat_responses: Vec<TrainingStatResponse> = stats.iter().map(|(name, val)| {
let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *name).expect("valid stat");
TrainingStatResponse {
stat: name.to_string(),
name: ground.name.to_string(),
description: ground.description.to_string(),
value: *val,
cost: character::training_cost(*val),
}
}).collect();
let stat_responses: Vec<TrainingStatResponse> = stats
.iter()
.map(|(name, val)| {
let ground = TRAINING_GROUNDS
.iter()
.find(|g| g.stat == *name)
.expect("valid stat");
TrainingStatResponse {
stat: name.to_string(),
name: ground.name.to_string(),
description: ground.description.to_string(),
value: *val,
cost: character::training_cost(*val),
}
})
.collect();
Ok(Json(GymResponse {
energy: char.energy,
max_energy: char.max_energy,
@ -626,8 +677,10 @@ async fn train_stat(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let now = std::time::SystemTime::now()
@ -635,18 +688,20 @@ async fn train_stat(
.unwrap()
.as_millis() as i64;
let (updated, _value) = { let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let (updated, _value) = {
let mut chars = state.characters.write().await;
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
sb_core::character::apply_regen(char, now);
let new_val = domain::train_stat(char, &input.stat)
.map_err(|e| match e {
"not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
_ => axum::http::StatusCode::BAD_REQUEST,
})?;
let new_val = domain::train_stat(char, &input.stat).map_err(|e| match e {
"not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
"not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY,
_ => axum::http::StatusCode::BAD_REQUEST,
})?;
(char.clone(), new_val)
};
@ -669,48 +724,76 @@ async fn get_shop(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let char = {
let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
chars
.iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
};
let shop_stock = state.shop_stock.read().await;
let shop_items: Vec<ShopItemResponse> = state.items.iter().filter(|item| {
item.always_in_shop || shop_stock.contains_key(&item.id)
}).map(|item| {
ShopItemResponse {
let shop_items: Vec<ShopItemResponse> = state
.items
.iter()
.filter(|item| item.always_in_shop || shop_stock.contains_key(&item.id))
.map(|item| ShopItemResponse {
id: item.id.clone(),
name: item.name.clone(),
item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(),
quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(),
item_type: serde_json::to_value(&item.r#type)
.unwrap()
.as_str()
.unwrap()
.to_string(),
quality: serde_json::to_value(&item.quality)
.unwrap()
.as_str()
.unwrap()
.to_string(),
slot: Some(item.slot.clone()),
stats: item.stats.clone(),
description: item.description.clone(),
attack_type: item.attack_type.as_ref().map(|at| format!("{:?}", at)),
value: items::item_price(item),
stock: shop_stock.get(&item.id).copied().unwrap_or(0),
}
}).collect();
let bag_items: Vec<InventoryItemResponse> = char.inventory.iter().filter_map(|id| {
state.items.iter().find(|i| &i.id == id).map(|item| {
InventoryItemResponse {
id: item.id.clone(),
name: item.name.clone(),
item_type: serde_json::to_value(&item.r#type).unwrap().as_str().unwrap().to_string(),
quality: serde_json::to_value(&item.quality).unwrap().as_str().unwrap().to_string(),
slot: Some(item.slot.clone()),
stats: item.stats.clone(),
description: item.description.clone(),
attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)),
value: item.value,
equipped: false,
}
})
}).collect();
.collect();
let bag_items: Vec<InventoryItemResponse> = char
.inventory
.iter()
.filter_map(|id| {
state
.items
.iter()
.find(|i| &i.id == id)
.map(|item| InventoryItemResponse {
id: item.id.clone(),
name: item.name.clone(),
item_type: serde_json::to_value(&item.r#type)
.unwrap()
.as_str()
.unwrap()
.to_string(),
quality: serde_json::to_value(&item.quality)
.unwrap()
.as_str()
.unwrap()
.to_string(),
slot: Some(item.slot.clone()),
stats: item.stats.clone(),
description: item.description.clone(),
attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)),
value: item.value,
equipped: false,
})
})
.collect();
Ok(Json(ShopResponse {
gold: char.gold,
inventory: bag_items,
@ -735,8 +818,10 @@ async fn shop_action(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
match input.action.as_str() {
@ -751,14 +836,19 @@ async fn buy_item(
Path(char_id): Path<u32>,
input: ShopAction,
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
let item = state.items.iter().find(|i| i.id == input.item_id)
let item = state
.items
.iter()
.find(|i| i.id == input.item_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let price = items::item_price(item);
{
let chars = state.characters.read().await;
let char = chars.iter().find(|c| c.id == char_id)
let char = chars
.iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
if char.gold < price {
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
@ -779,7 +869,9 @@ async fn buy_item(
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
char.gold -= price;
char.inventory.push(input.item_id.clone());
@ -803,17 +895,25 @@ async fn sell_item(
Path(char_id): Path<u32>,
input: ShopAction,
) -> Result<Json<ShopActionResponse>, axum::http::StatusCode> {
let item = state.items.iter().find(|i| i.id == input.item_id)
let item = state
.items
.iter()
.find(|i| i.id == input.item_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let sell_price = items::item_sell_price(item);
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let pos = char.inventory.iter().position(|id| id == &input.item_id)
let pos = char
.inventory
.iter()
.position(|id| id == &input.item_id)
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
char.inventory.remove(pos);
@ -855,13 +955,17 @@ async fn heal_character(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let missing = char.max_hp - char.hp;
@ -906,28 +1010,34 @@ async fn get_songs(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let char = {
let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
chars
.iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
};
if char.class != Class::Bard {
return Err(axum::http::StatusCode::FORBIDDEN);
}
let available = songs::available_songs(char.level);
let songs_json: Vec<SongSummary> = available.iter().map(|s| {
SongSummary {
let songs_json: Vec<SongSummary> = available
.iter()
.map(|s| SongSummary {
id: s.id.to_string(),
name: s.name.to_string(),
description: s.description.to_string(),
required_level: s.required_level,
combat_effect: s.combat_effect.to_string(),
}
}).collect();
})
.collect();
Ok(Json(SongsResponse {
active_song: char.active_song.clone(),
available_songs: songs_json,
@ -950,13 +1060,17 @@ async fn set_song(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
if char.class != Class::Bard {
@ -964,9 +1078,13 @@ async fn set_song(
}
match &input.song_id {
None => { char.active_song = None; }
None => {
char.active_song = None;
}
Some(song_id) => {
let song = songs::BARD_SONGS.iter().find(|s| s.id == *song_id)
let song = songs::BARD_SONGS
.iter()
.find(|s| s.id == *song_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
if song.required_level > char.level {
return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
@ -981,7 +1099,10 @@ async fn set_song(
Ok(Json(SetSongResponse {
success: true,
message: format!("Song set to {}", updated.active_song.as_deref().unwrap_or("none")),
message: format!(
"Song set to {}",
updated.active_song.as_deref().unwrap_or("none")
),
active_song: updated.active_song.clone(),
}))
}
@ -996,14 +1117,19 @@ async fn get_spells(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let char = {
let chars = state.characters.read().await;
chars.iter().find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?.clone()
chars
.iter()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?
.clone()
};
let class_str = char.class.as_str().to_string();
if !spells::is_caster_class(&class_str) {
@ -1011,23 +1137,26 @@ async fn get_spells(
}
let spell_level = (char.level / 3) + 1;
let available = spells::spells_for_class(&class_str, spell_level);
let spells_json: Vec<serde_json::Value> = available.iter().map(|s| {
serde_json::json!({
"id": &s.id,
"code": &s.code,
"name": &s.name,
"spell_level": s.spell_level,
"spell_points": s.spell_points,
"range": &s.range,
"description": &s.description,
"combat_effect": &s.combat_effect,
"combat_damage_min": s.combat_damage_min,
"combat_damage_max": s.combat_damage_max,
"heal_min": s.heal_min,
"heal_max": s.heal_max,
"known": char.known_spells.contains(&s.id.to_string()),
let spells_json: Vec<serde_json::Value> = available
.iter()
.map(|s| {
serde_json::json!({
"id": &s.id,
"code": &s.code,
"name": &s.name,
"spell_level": s.spell_level,
"spell_points": s.spell_points,
"range": &s.range,
"description": &s.description,
"combat_effect": &s.combat_effect,
"combat_damage_min": s.combat_damage_min,
"combat_damage_max": s.combat_damage_max,
"heal_min": s.heal_min,
"heal_max": s.heal_max,
"known": char.known_spells.contains(&s.id.to_string()),
})
})
}).collect();
.collect();
Ok(Json(serde_json::json!({
"caster_class": class_str,
"spell_level": spell_level,
@ -1051,7 +1180,6 @@ struct SpellRequest {
spell_id: Option<String>,
}
async fn level_up(
State(state): State<Arc<AppState>>,
Extension(auth): Extension<AuthUser>,
@ -1062,13 +1190,17 @@ async fn level_up(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
match character::level_up(char) {
Ok(_) => char.clone(),
@ -1100,14 +1232,18 @@ async fn learn_spell_level(
let char = chars.iter().find(|c| c.id == char_id);
match char {
None => return Err(axum::http::StatusCode::NOT_FOUND),
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => return Err(axum::http::StatusCode::FORBIDDEN),
Some(_) => {},
Some(c) if c.user_id != auth.user_id && auth.role != "admin" => {
return Err(axum::http::StatusCode::FORBIDDEN);
}
Some(_) => {}
}
}
if let Some(spell_id) = input.spell_id {
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
if spell_id.is_empty() {
char.active_spell = None;
@ -1131,11 +1267,14 @@ async fn learn_spell_level(
})));
}
let spell_level = input.spell_level
let spell_level = input
.spell_level
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
let updated = {
let mut chars = state.characters.write().await;
let char = chars.iter_mut().find(|c| c.id == char_id)
let char = chars
.iter_mut()
.find(|c| c.id == char_id)
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
let class_str = char.class.as_str().to_string();
@ -1158,7 +1297,8 @@ async fn learn_spell_level(
}
let spell_list = spells::spells_for_class(&class_str, spell_level);
let new_ids: Vec<String> = spell_list.iter()
let new_ids: Vec<String> = spell_list
.iter()
.filter(|s| !char.known_spells.contains(&s.id.to_string()))
.map(|s| s.id.to_string())
.collect();
@ -1256,7 +1396,9 @@ async fn login(
Json(input): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, axum::http::StatusCode> {
let users = state.users.read().await;
let user = users.iter().find(|u| u.name == input.name)
let user = users
.iter()
.find(|u| u.name == input.name)
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
if !sb_auth::hash::verify_password(&input.password, &user.password_hash) {
@ -1282,28 +1424,30 @@ async fn login(
pub fn seed_users() -> Arc<AppState> {
let db_path = std::env::var("SB_DB_PATH");
let db = match db_path.as_deref() {
Ok(path) if !path.is_empty() => {
match sb_db::Database::open(std::path::Path::new(path)) {
Ok(db) => {
tracing::info!("database loaded from {}", path);
Some(db)
}
Err(e) => {
tracing::warn!("failed to open database at {}: {}, falling back to in-memory", path, e);
None
}
Ok(path) if !path.is_empty() => match sb_db::Database::open(std::path::Path::new(path)) {
Ok(db) => {
tracing::info!("database loaded from {}", path);
Some(db)
}
}
Err(e) => {
tracing::warn!(
"failed to open database at {}: {}, falling back to in-memory",
path,
e
);
None
}
},
_ => None,
};
let admin_pass = std::env::var("SB_ADMIN_PASS").unwrap_or_else(|_| "admin".into());
let player_pass = std::env::var("SB_PLAYER_PASS").unwrap_or_else(|_| "player".into());
let admin_hash = sb_auth::hash::hash_password(&admin_pass)
.expect("failed to hash admin password");
let player_hash = sb_auth::hash::hash_password(&player_pass)
.expect("failed to hash player password");
let admin_hash =
sb_auth::hash::hash_password(&admin_pass).expect("failed to hash admin password");
let player_hash =
sb_auth::hash::hash_password(&player_pass).expect("failed to hash player password");
let (users, characters, shop_stock, next_id) = if let Some(ref db) = db {
let users = db.load_users().unwrap_or_else(|e| {
@ -1325,8 +1469,18 @@ pub fn seed_users() -> Arc<AppState> {
};
let mut initial_users = vec![
User { id: 1, name: "admin".into(), role: Role::Admin, password_hash: admin_hash },
User { id: 2, name: "player".into(), role: Role::Player, password_hash: player_hash },
User {
id: 1,
name: "admin".into(),
role: Role::Admin,
password_hash: admin_hash,
},
User {
id: 2,
name: "player".into(),
role: Role::Player,
password_hash: player_hash,
},
];
if let Some(ref db) = db {

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

@ -1,2 +1,2 @@
pub mod handler;
pub mod channels;
pub mod handler;