diff --git a/crates/sb-api/src/main.rs b/crates/sb-api/src/main.rs index bc673b3..fd6369d 100644 --- a/crates/sb-api/src/main.rs +++ b/crates/sb-api/src/main.rs @@ -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() { diff --git a/crates/sb-api/src/middleware/auth.rs b/crates/sb-api/src/middleware/auth.rs index 5db3365..fb6ce61 100644 --- a/crates/sb-api/src/middleware/auth.rs +++ b/crates/sb-api/src/middleware/auth.rs @@ -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 { +pub async fn auth_middleware(mut request: Request, next: Next) -> Result { 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); diff --git a/crates/sb-api/src/middleware/rate_limit.rs b/crates/sb-api/src/middleware/rate_limit.rs index e69de29..8b13789 100644 --- a/crates/sb-api/src/middleware/rate_limit.rs +++ b/crates/sb-api/src/middleware/rate_limit.rs @@ -0,0 +1 @@ + diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index 343fc10..c7c80d6 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -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) -> 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) -> 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 = chars.iter().filter(|c| c.user_id == user_id).cloned().collect(); + let user_chars: Vec = 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, Json(input): Json, ) -> Result, 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 = dropped_ids.iter().filter_map(|id| { - state.items.iter().find(|i| &i.id == id).map(|i| i.name.clone()) - }).collect(); + let loot: Vec = 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 = 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 = 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 = state.items.iter().filter(|item| { - item.always_in_shop || shop_stock.contains_key(&item.id) - }).map(|item| { - ShopItemResponse { + let shop_items: Vec = 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 = 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 = 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, input: ShopAction, ) -> Result, 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, input: ShopAction, ) -> Result, 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 = available.iter().map(|s| { - SongSummary { + let songs_json: Vec = 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 = 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 = 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, } - async fn level_up( State(state): State>, Extension(auth): Extension, @@ -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 = spell_list.iter() + let new_ids: Vec = 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, ) -> Result, 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 { 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 { }; 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 { diff --git a/crates/sb-api/src/ws/channels.rs b/crates/sb-api/src/ws/channels.rs index e69de29..8b13789 100644 --- a/crates/sb-api/src/ws/channels.rs +++ b/crates/sb-api/src/ws/channels.rs @@ -0,0 +1 @@ + diff --git a/crates/sb-api/src/ws/handler.rs b/crates/sb-api/src/ws/handler.rs index e69de29..8b13789 100644 --- a/crates/sb-api/src/ws/handler.rs +++ b/crates/sb-api/src/ws/handler.rs @@ -0,0 +1 @@ + diff --git a/crates/sb-api/src/ws/mod.rs b/crates/sb-api/src/ws/mod.rs index 42432de..bcfc7b4 100644 --- a/crates/sb-api/src/ws/mod.rs +++ b/crates/sb-api/src/ws/mod.rs @@ -1,2 +1,2 @@ -pub mod handler; pub mod channels; +pub mod handler; diff --git a/crates/sb-auth/src/hash.rs b/crates/sb-auth/src/hash.rs index e67a1ea..f7d133b 100644 --- a/crates/sb-auth/src/hash.rs +++ b/crates/sb-auth/src/hash.rs @@ -1,6 +1,6 @@ use argon2::{ - password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, }; pub fn hash_password(password: &str) -> Result { @@ -17,5 +17,7 @@ pub fn verify_password(password: &str, hash: &str) -> bool { Ok(h) => h, Err(_) => return false, }; - Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() } diff --git a/crates/sb-auth/src/jwt.rs b/crates/sb-auth/src/jwt.rs index 3745c22..86b0c13 100644 --- a/crates/sb-auth/src/jwt.rs +++ b/crates/sb-auth/src/jwt.rs @@ -1,6 +1,6 @@ +use chrono::{Duration, Utc}; use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; -use chrono::{Utc, Duration}; fn jwt_secret() -> Vec { std::env::var("JWT_SECRET") @@ -16,7 +16,11 @@ pub struct Claims { pub exp: usize, } -pub fn create_token(user_id: u32, name: &str, role: &str) -> Result { +pub fn create_token( + user_id: u32, + name: &str, + role: &str, +) -> Result { let exp = Utc::now() .checked_add_signed(Duration::hours(24)) .expect("valid timestamp") @@ -39,9 +43,6 @@ pub fn create_token(user_id: u32, name: &str, role: &str) -> Result Result { let mut validation = Validation::default(); validation.leeway = 60; - jsonwebtoken::decode::( - token, - &DecodingKey::from_secret(&jwt_secret()), - &validation, - ).map(|d| d.claims) + jsonwebtoken::decode::(token, &DecodingKey::from_secret(&jwt_secret()), &validation) + .map(|d| d.claims) } diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index 927fe04..e793147 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -1,5 +1,5 @@ -use sb_shared::{Character, CharacterStats, Class, Race}; use crate::spells; +use sb_shared::{Character, CharacterStats, Class, Race}; pub struct TrainingGround { pub stat: &'static str, @@ -8,11 +8,31 @@ pub struct TrainingGround { } pub const TRAINING_GROUNDS: &[TrainingGround] = &[ - TrainingGround { stat: "strength", name: "The Anvil", description: "Pound steel and lift stone under the guidance of master smiths." }, - TrainingGround { stat: "dexterity", name: "The Archery Range", description: "Sharpen reflexes with bow, blade, and balance drills." }, - TrainingGround { stat: "constitution", name: "The Endurance Pits", description: "Push your body past its limits in grueling trials of resilience." }, - TrainingGround { stat: "intelligence", name: "The Sage's Study", description: "Study ancient tomes and practice arcane disciplines." }, - TrainingGround { stat: "luck", name: "Fortune's Shrine", description: "Consult oracles and tempt fate in games of chance." }, + TrainingGround { + stat: "strength", + name: "The Anvil", + description: "Pound steel and lift stone under the guidance of master smiths.", + }, + TrainingGround { + stat: "dexterity", + name: "The Archery Range", + description: "Sharpen reflexes with bow, blade, and balance drills.", + }, + TrainingGround { + stat: "constitution", + name: "The Endurance Pits", + description: "Push your body past its limits in grueling trials of resilience.", + }, + TrainingGround { + stat: "intelligence", + name: "The Sage's Study", + description: "Study ancient tomes and practice arcane disciplines.", + }, + TrainingGround { + stat: "luck", + name: "Fortune's Shrine", + description: "Consult oracles and tempt fate in games of chance.", + }, ]; pub struct RaceInfo { @@ -26,11 +46,51 @@ pub struct RaceInfo { } pub const RACES: &[RaceInfo] = &[ - RaceInfo { race: Race::Human, name: "Human", str_mod: 0, int_mod: 0, dex_mod: 0, con_mod: 0, luck_mod: 0 }, - RaceInfo { race: Race::Elf, name: "Elf", str_mod: -2, int_mod: 4, dex_mod: 2, con_mod: -2, luck_mod: 0 }, - RaceInfo { race: Race::Dwarf, name: "Dwarf", str_mod: 2, int_mod: -2, dex_mod: -2, con_mod: 4, luck_mod: 0 }, - RaceInfo { race: Race::Hobbit, name: "Hobbit", str_mod: -4, int_mod: 0, dex_mod: 2, con_mod: -2, luck_mod: 4 }, - RaceInfo { race: Race::HalfElf, name: "Half-Elf", str_mod: 0, int_mod: 2, dex_mod: 2, con_mod: 0, luck_mod: 0 }, + RaceInfo { + race: Race::Human, + name: "Human", + str_mod: 0, + int_mod: 0, + dex_mod: 0, + con_mod: 0, + luck_mod: 0, + }, + RaceInfo { + race: Race::Elf, + name: "Elf", + str_mod: -2, + int_mod: 4, + dex_mod: 2, + con_mod: -2, + luck_mod: 0, + }, + RaceInfo { + race: Race::Dwarf, + name: "Dwarf", + str_mod: 2, + int_mod: -2, + dex_mod: -2, + con_mod: 4, + luck_mod: 0, + }, + RaceInfo { + race: Race::Hobbit, + name: "Hobbit", + str_mod: -4, + int_mod: 0, + dex_mod: 2, + con_mod: -2, + luck_mod: 4, + }, + RaceInfo { + race: Race::HalfElf, + name: "Half-Elf", + str_mod: 0, + int_mod: 2, + dex_mod: 2, + con_mod: 0, + luck_mod: 0, + }, ]; pub struct ClassInfo { @@ -43,16 +103,86 @@ pub struct ClassInfo { } pub const CLASSES: &[ClassInfo] = &[ - ClassInfo { class: Class::Warrior, name: "Warrior", primary_stat: "strength", secondary_stat: "constitution", base_hp: 20, hp_per_con: 3.0 }, - ClassInfo { class: Class::Paladin, name: "Paladin", primary_stat: "strength", secondary_stat: "luck", base_hp: 18, hp_per_con: 2.5 }, - ClassInfo { class: Class::Rogue, name: "Rogue", primary_stat: "dexterity", secondary_stat: "luck", base_hp: 14, hp_per_con: 2.0 }, - ClassInfo { class: Class::Bard, name: "Bard", primary_stat: "dexterity", secondary_stat: "intelligence", base_hp: 14, hp_per_con: 2.0 }, - ClassInfo { class: Class::Hunter, name: "Hunter", primary_stat: "dexterity", secondary_stat: "strength", base_hp: 16, hp_per_con: 2.5 }, - ClassInfo { class: Class::Monk, name: "Monk", primary_stat: "dexterity", secondary_stat: "constitution", base_hp: 15, hp_per_con: 2.5 }, - ClassInfo { class: Class::Mage, name: "Mage", primary_stat: "intelligence", secondary_stat: "dexterity", base_hp: 11, hp_per_con: 1.8 }, - ClassInfo { class: Class::Wizard, name: "Wizard", primary_stat: "intelligence", secondary_stat: "constitution", base_hp: 10, hp_per_con: 1.5 }, - ClassInfo { class: Class::Conjurer, name: "Conjurer", primary_stat: "intelligence", secondary_stat: "luck", base_hp: 10, hp_per_con: 1.5 }, - ClassInfo { class: Class::Sorcerer, name: "Sorcerer", primary_stat: "intelligence", secondary_stat: "strength", base_hp: 12, hp_per_con: 2.0 }, + ClassInfo { + class: Class::Warrior, + name: "Warrior", + primary_stat: "strength", + secondary_stat: "constitution", + base_hp: 20, + hp_per_con: 3.0, + }, + ClassInfo { + class: Class::Paladin, + name: "Paladin", + primary_stat: "strength", + secondary_stat: "luck", + base_hp: 18, + hp_per_con: 2.5, + }, + ClassInfo { + class: Class::Rogue, + name: "Rogue", + primary_stat: "dexterity", + secondary_stat: "luck", + base_hp: 14, + hp_per_con: 2.0, + }, + ClassInfo { + class: Class::Bard, + name: "Bard", + primary_stat: "dexterity", + secondary_stat: "intelligence", + base_hp: 14, + hp_per_con: 2.0, + }, + ClassInfo { + class: Class::Hunter, + name: "Hunter", + primary_stat: "dexterity", + secondary_stat: "strength", + base_hp: 16, + hp_per_con: 2.5, + }, + ClassInfo { + class: Class::Monk, + name: "Monk", + primary_stat: "dexterity", + secondary_stat: "constitution", + base_hp: 15, + hp_per_con: 2.5, + }, + ClassInfo { + class: Class::Mage, + name: "Mage", + primary_stat: "intelligence", + secondary_stat: "dexterity", + base_hp: 11, + hp_per_con: 1.8, + }, + ClassInfo { + class: Class::Wizard, + name: "Wizard", + primary_stat: "intelligence", + secondary_stat: "constitution", + base_hp: 10, + hp_per_con: 1.5, + }, + ClassInfo { + class: Class::Conjurer, + name: "Conjurer", + primary_stat: "intelligence", + secondary_stat: "luck", + base_hp: 10, + hp_per_con: 1.5, + }, + ClassInfo { + class: Class::Sorcerer, + name: "Sorcerer", + primary_stat: "intelligence", + secondary_stat: "strength", + base_hp: 12, + hp_per_con: 2.0, + }, ]; pub fn stat_bonus(stat: i64) -> i64 { @@ -65,7 +195,10 @@ fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 { } pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats { - let info = RACES.iter().find(|r| &r.race == race).expect("invalid race"); + let info = RACES + .iter() + .find(|r| &r.race == race) + .expect("invalid race"); CharacterStats { strength: roll_stat(rng, info.str_mod), intelligence: roll_stat(rng, info.int_mod), @@ -76,17 +209,26 @@ pub fn roll_stats(rng: &mut impl rand::Rng, race: &Race) -> CharacterStats { } pub fn calc_max_hp(class: &Class, stats: &CharacterStats) -> i32 { - let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + let info = CLASSES + .iter() + .find(|c| &c.class == class) + .expect("invalid class"); (info.base_hp as f64 + stats.constitution as f64 * info.hp_per_con) as i32 } pub fn calc_max_energy(class: &Class) -> i32 { - let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + let info = CLASSES + .iter() + .find(|c| &c.class == class) + .expect("invalid class"); 30 + info.base_hp / 3 } pub fn calc_max_nerve(class: &Class) -> i32 { - let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + let info = CLASSES + .iter() + .find(|c| &c.class == class) + .expect("invalid class"); 100 + info.base_hp / 2 } @@ -104,7 +246,9 @@ pub fn hp_regen_rate() -> i64 { pub fn apply_regen(char: &mut Character, now_ms: i64) { let elapsed = now_ms - char.last_regen; - if elapsed <= 0 { return; } + if elapsed <= 0 { + return; + } let energy_ticks = (elapsed / energy_regen_rate()) as i32; let nerve_ticks = (elapsed / nerve_regen_rate()) as i32; let hp_ticks = (elapsed / hp_regen_rate()) as i32; @@ -140,7 +284,8 @@ pub fn create_character( let max_nerve = calc_max_nerve(&class); let known_spells: Vec = if spells::is_caster_class(&class_str) { - spells::spells_for_class(&class_str, 1).iter() + spells::spells_for_class(&class_str, 1) + .iter() .filter(|s| s.spell_level <= 1) .map(|s| s.id.clone()) .collect() @@ -177,7 +322,9 @@ pub fn create_character( ("ring".into(), None), ("trinket".into(), None), ("instrument".into(), None), - ].into_iter().collect(), + ] + .into_iter() + .collect(), inventory: Vec::new(), active_song: None, known_spells, @@ -191,7 +338,9 @@ pub const MAX_LEVEL: i32 = 100; pub const XP_FACTOR: i64 = 18; pub fn xp_for_level(level: i32) -> i64 { - if level <= 1 { return 0; } + if level <= 1 { + return 0; + } let n = (level - 1) as f64; XP_FACTOR * (100.0 * n * n) as i64 } @@ -227,11 +376,17 @@ pub fn level_up(char: &mut Character) -> Result { } pub fn find_race_info(race: &Race) -> &'static RaceInfo { - RACES.iter().find(|r| &r.race == race).expect("invalid race") + RACES + .iter() + .find(|r| &r.race == race) + .expect("invalid race") } pub fn find_class_info(class: &Class) -> &'static ClassInfo { - CLASSES.iter().find(|c| &c.class == class).expect("invalid class") + CLASSES + .iter() + .find(|c| &c.class == class) + .expect("invalid class") } #[cfg(test)] @@ -242,15 +397,35 @@ mod tests { fn base_char() -> Character { Character { - id: 1, user_id: 1, name: "Test".into(), - race: Race::Human, class: Class::Warrior, - level: 1, xp: 0, - stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 }, - hp: 80, max_hp: 80, energy: 36, max_energy: 36, - nerve: 110, max_nerve: 110, gold: 0, last_regen: 0, - equipment: HashMap::new(), inventory: Vec::new(), - active_song: None, known_spells: Vec::new(), - active_spell: None, spell_points: 0, max_spell_points: 0, + id: 1, + user_id: 1, + name: "Test".into(), + race: Race::Human, + class: Class::Warrior, + level: 1, + xp: 0, + stats: CharacterStats { + strength: 20, + intelligence: 20, + dexterity: 20, + constitution: 20, + luck: 20, + }, + hp: 80, + max_hp: 80, + energy: 36, + max_energy: 36, + nerve: 110, + max_nerve: 110, + gold: 0, + last_regen: 0, + equipment: HashMap::new(), + inventory: Vec::new(), + active_song: None, + known_spells: Vec::new(), + active_spell: None, + spell_points: 0, + max_spell_points: 0, } } @@ -351,8 +526,20 @@ mod tests { #[test] fn calc_max_hp_depends_on_class_and_con() { - let stats_high = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 30, luck: 10 }; - let stats_low = CharacterStats { strength: 10, intelligence: 10, dexterity: 10, constitution: 10, luck: 10 }; + let stats_high = CharacterStats { + strength: 10, + intelligence: 10, + dexterity: 10, + constitution: 30, + luck: 10, + }; + let stats_low = CharacterStats { + strength: 10, + intelligence: 10, + dexterity: 10, + constitution: 10, + luck: 10, + }; let hp_high = calc_max_hp(&Class::Warrior, &stats_high); let hp_low = calc_max_hp(&Class::Warrior, &stats_low); assert!(hp_high > hp_low); @@ -396,7 +583,7 @@ mod tests { #[test] fn roll_stats_returns_all_five() { - use rand:: SeedableRng; + use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); let stats = roll_stats(&mut rng, &Race::Human); assert!(stats.strength >= 6 && stats.strength <= 16); @@ -408,7 +595,7 @@ mod tests { #[test] fn race_modifiers_apply() { - use rand:: SeedableRng; + use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); let human = roll_stats(&mut rng, &Race::Human); let mut rng2 = rand::rngs::StdRng::seed_from_u64(42); diff --git a/crates/sb-core/src/combat.rs b/crates/sb-core/src/combat.rs index 7894cd5..495edac 100644 --- a/crates/sb-core/src/combat.rs +++ b/crates/sb-core/src/combat.rs @@ -7,9 +7,8 @@ use std::sync::LazyLock; const CRIT_TABLES_JSON: &str = include_str!("../../../../skara-brae-common/data/crit-tables/combat.json"); -static CRIT_TABLES: LazyLock>> = LazyLock::new(|| { - serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables") -}); +static CRIT_TABLES: LazyLock>> = + LazyLock::new(|| serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables")); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CombatStats { @@ -93,11 +92,12 @@ pub fn resolve_attack( && let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity) { let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32; - let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) { - crit_damage.min(cap) - } else { - crit_damage - }; + let capped = + if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) { + crit_damage.min(cap) + } else { + crit_damage + }; damage = capped; crit = Some(CritResult { severity: severity.clone(), @@ -155,7 +155,9 @@ pub fn resolve_combat( gold_max: i32, rng: &mut impl Rng, ) -> CombatLog { - resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng) + resolve_combat_full( + attacker, defender, xp_reward, gold_min, gold_max, None, None, rng, + ) } pub fn resolve_combat_with_song( @@ -167,7 +169,16 @@ pub fn resolve_combat_with_song( song_effect: Option<&str>, rng: &mut impl Rng, ) -> CombatLog { - resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng) + resolve_combat_full( + attacker, + defender, + xp_reward, + gold_min, + gold_max, + song_effect, + None, + rng, + ) } #[allow(clippy::too_many_arguments)] @@ -187,7 +198,11 @@ pub fn resolve_combat_full( let mut rounds = Vec::new(); loop { - let mut round = resolve_attack(&mut rng, &apply_song_to_attacker(&attacker, song_effect), &apply_song_to_defender(&defender, song_effect)); + let mut round = resolve_attack( + &mut rng, + &apply_song_to_attacker(&attacker, song_effect), + &apply_song_to_defender(&defender, song_effect), + ); if let Some(sp) = spell && sp.combat_effect == "damage" @@ -211,7 +226,11 @@ pub fn resolve_combat_full( }; } - let mut round = resolve_attack(&mut rng, &defender, &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect)); + let mut round = resolve_attack( + &mut rng, + &defender, + &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect), + ); a_hp = (a_hp - round.damage).max(0); round.defender_hp = a_hp; rounds.push(round); @@ -232,7 +251,9 @@ pub fn resolve_combat_full( } if let Some(sp) = spell - && sp.combat_effect == "heal" && a_hp > 0 && a_hp < attacker.hp + && sp.combat_effect == "heal" + && a_hp > 0 + && a_hp < attacker.hp { let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max); a_hp = (a_hp + heal_amt).min(attacker.hp); @@ -243,8 +264,12 @@ pub fn resolve_combat_full( fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { let mut s = stats.clone(); match song_effect { - Some("accuracy") => { s.ob_base += 15; } - Some("damage") => { s.ob_base += 20; } + Some("accuracy") => { + s.ob_base += 15; + } + Some("damage") => { + s.ob_base += 20; + } _ => {} } s @@ -253,8 +278,12 @@ fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> Com fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { let mut s = stats.clone(); match song_effect { - Some("defense") | Some("magic_resist") => { s.db_base += 10; } - Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; } + Some("defense") | Some("magic_resist") => { + s.db_base += 10; + } + Some("weaken") => { + s.ob_base = (s.ob_base as f64 * 0.7) as i32; + } _ => {} } s @@ -272,18 +301,30 @@ mod tests { fn attacker() -> CombatStats { CombatStats { - name: "Hero".into(), hp: 100, level: 1, - strength: 50, dexterity: 40, constitution: 40, luck: 20, - ob_base: 20, db_base: 10, + name: "Hero".into(), + hp: 100, + level: 1, + strength: 50, + dexterity: 40, + constitution: 40, + luck: 20, + ob_base: 20, + db_base: 10, attack_type: AttackType::Slash, } } fn defender() -> CombatStats { CombatStats { - name: "Goblin".into(), hp: 50, level: 1, - strength: 30, dexterity: 30, constitution: 30, luck: 10, - ob_base: 10, db_base: 5, + name: "Goblin".into(), + hp: 50, + level: 1, + strength: 30, + dexterity: 30, + constitution: 30, + luck: 10, + ob_base: 10, + db_base: 5, attack_type: AttackType::Crush, } } @@ -294,7 +335,11 @@ mod tests { let mut rng = rand::rngs::StdRng::seed_from_u64(42); for _ in 0..1000 { let roll = open_ended_roll(&mut rng); - assert!(roll > -500 && roll < 500, "roll out of expected range: {}", roll); + assert!( + roll > -500 && roll < 500, + "roll out of expected range: {}", + roll + ); } } @@ -364,7 +409,10 @@ mod tests { fn parse_attack_type_maps_known_types() { assert!(matches!(parse_attack_type("slash"), AttackType::Slash)); assert!(matches!(parse_attack_type("crush"), AttackType::Crush)); - assert!(matches!(parse_attack_type("puncture"), AttackType::Puncture)); + assert!(matches!( + parse_attack_type("puncture"), + AttackType::Puncture + )); assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush)); assert!(matches!(parse_attack_type("unknown"), AttackType::Crush)); } diff --git a/crates/sb-core/src/data.rs b/crates/sb-core/src/data.rs index 389c801..fd73601 100644 --- a/crates/sb-core/src/data.rs +++ b/crates/sb-core/src/data.rs @@ -1,9 +1,7 @@ use serde::{Deserialize, Serialize}; -const ENEMIES_JSON: &str = - include_str!("../../../../skara-brae-common/data/enemies.json"); -const ZONES_JSON: &str = - include_str!("../../../../skara-brae-common/data/adventures/zones.json"); +const ENEMIES_JSON: &str = include_str!("../../../../skara-brae-common/data/enemies.json"); +const ZONES_JSON: &str = include_str!("../../../../skara-brae-common/data/adventures/zones.json"); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EnemyDef { diff --git a/crates/sb-core/src/items.rs b/crates/sb-core/src/items.rs index e1ef7a6..603a740 100644 --- a/crates/sb-core/src/items.rs +++ b/crates/sb-core/src/items.rs @@ -1,9 +1,7 @@ use sb_shared::Item; -const WEAPONS_JSON: &str = - include_str!("../../../../skara-brae-common/data/items/weapons.json"); -const ARMOR_JSON: &str = - include_str!("../../../../skara-brae-common/data/items/armor.json"); +const WEAPONS_JSON: &str = include_str!("../../../../skara-brae-common/data/items/weapons.json"); +const ARMOR_JSON: &str = include_str!("../../../../skara-brae-common/data/items/armor.json"); const ACCESSORIES_JSON: &str = include_str!("../../../../skara-brae-common/data/items/accessories.json"); @@ -14,8 +12,7 @@ pub fn load_all_items() -> Vec { serde_json::from_str(WEAPONS_JSON).expect("failed to parse weapons.json"); items.extend(weapons); - let armor: Vec = - serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json"); + let armor: Vec = serde_json::from_str(ARMOR_JSON).expect("failed to parse armor.json"); items.extend(armor); let accessories: Vec = diff --git a/crates/sb-core/src/services.rs b/crates/sb-core/src/services.rs index 6aae401..c745f80 100644 --- a/crates/sb-core/src/services.rs +++ b/crates/sb-core/src/services.rs @@ -1,6 +1,6 @@ -use sb_shared::{Character, Item, AttackType}; -use crate::data::{EnemyDef, EncounterEntry}; +use crate::data::{EncounterEntry, EnemyDef}; use rand::Rng; +use sb_shared::{AttackType, Character, Item}; pub fn pick_enemy( rng: &mut impl Rng, @@ -13,17 +13,24 @@ pub fn pick_enemy( for enc in encounters { remaining -= enc.weight; if remaining < 0 { - return enemies.iter().find(|e| e.id == enc.enemy_id) + return enemies + .iter() + .find(|e| e.id == enc.enemy_id) .expect("encounter references valid enemy") .clone(); } } - enemies.iter().find(|e| e.id == encounters.last().unwrap().enemy_id) + enemies + .iter() + .find(|e| e.id == encounters.last().unwrap().enemy_id) .expect("encounter references valid enemy") .clone() } -pub fn equipped_attack_type(equipment: &std::collections::HashMap>, items: &[Item]) -> AttackType { +pub fn equipped_attack_type( + equipment: &std::collections::HashMap>, + items: &[Item], +) -> AttackType { if let Some(Some(weapon_id)) = equipment.get("main_hand") && let Some(weapon) = items.iter().find(|i| &i.id == weapon_id) && let Some(ref at) = weapon.attack_type @@ -33,7 +40,10 @@ pub fn equipped_attack_type(equipment: &std::collections::HashMap>, items: &[Item]) -> i32 { +pub fn equipped_damage( + equipment: &std::collections::HashMap>, + items: &[Item], +) -> i32 { let mut total = 0i32; for slot in &["main_hand", "off_hand"] { if let Some(Some(item_id)) = equipment.get(*slot) @@ -46,7 +56,10 @@ pub fn equipped_damage(equipment: &std::collections::HashMap>, items: &[Item]) -> i32 { +pub fn total_defense( + equipment: &std::collections::HashMap>, + items: &[Item], +) -> i32 { let mut total = 0i32; for (_, item_id) in equipment.iter() { if let Some(id) = item_id @@ -60,7 +73,10 @@ pub fn total_defense(equipment: &std::collections::HashMap Result<(), &'static str> { - let inv_pos = char.inventory.iter().position(|id| id == item_id) + let inv_pos = char + .inventory + .iter() + .position(|id| id == item_id) .ok_or("item not in inventory")?; char.inventory.remove(inv_pos); @@ -73,7 +89,9 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<() } pub fn unequip_item(char: &mut Character, slot: &str) -> Result { - let prev = char.equipment.insert(slot.to_string(), None) + let prev = char + .equipment + .insert(slot.to_string(), None) .flatten() .ok_or("nothing equipped in slot")?; @@ -125,42 +143,70 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result #[cfg(test)] mod tests { use super::*; - use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType}; + use sb_shared::{Character, CharacterStats, Class, Item, ItemType, Quality, Race}; use std::collections::HashMap; fn base_char() -> Character { Character { - id: 1, user_id: 1, name: "Test".into(), - race: Race::Human, class: Class::Warrior, - level: 1, xp: 0, - stats: CharacterStats { strength: 20, intelligence: 20, dexterity: 20, constitution: 20, luck: 20 }, - hp: 80, max_hp: 80, energy: 10, max_energy: 36, - nerve: 110, max_nerve: 110, gold: 1000, last_regen: 0, - equipment: HashMap::new(), inventory: Vec::new(), - active_song: None, known_spells: Vec::new(), - active_spell: None, spell_points: 0, max_spell_points: 0, + id: 1, + user_id: 1, + name: "Test".into(), + race: Race::Human, + class: Class::Warrior, + level: 1, + xp: 0, + stats: CharacterStats { + strength: 20, + intelligence: 20, + dexterity: 20, + constitution: 20, + luck: 20, + }, + hp: 80, + max_hp: 80, + energy: 10, + max_energy: 36, + nerve: 110, + max_nerve: 110, + gold: 1000, + last_regen: 0, + equipment: HashMap::new(), + inventory: Vec::new(), + active_song: None, + known_spells: Vec::new(), + active_spell: None, + spell_points: 0, + max_spell_points: 0, } } fn sword() -> Item { Item { - id: "sword1".into(), name: "Iron Sword".into(), - r#type: ItemType::Weapon, quality: Quality::Common, + id: "sword1".into(), + name: "Iron Sword".into(), + r#type: ItemType::Weapon, + quality: Quality::Common, slot: "main_hand".into(), stats: HashMap::from([("damage".into(), 15i64)]), - description: String::new(), attack_type: Some(AttackType::Slash), - value: 100, always_in_shop: true, + description: String::new(), + attack_type: Some(AttackType::Slash), + value: 100, + always_in_shop: true, } } fn shield() -> Item { Item { - id: "shield1".into(), name: "Wood Shield".into(), - r#type: ItemType::Armor, quality: Quality::Common, + id: "shield1".into(), + name: "Wood Shield".into(), + r#type: ItemType::Armor, + quality: Quality::Common, slot: "off_hand".into(), stats: HashMap::from([("defense".into(), 10i64)]), - description: String::new(), attack_type: None, - value: 50, always_in_shop: true, + description: String::new(), + attack_type: None, + value: 50, + always_in_shop: true, } } @@ -211,16 +257,23 @@ mod tests { c.inventory.push("sword1".into()); equip_item(&mut c, &sword(), "sword1").unwrap(); assert!(!c.inventory.contains(&"sword1".to_string())); - assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into()))); + assert_eq!( + c.equipment.get("main_hand").cloned(), + Some(Some("sword1".into())) + ); } #[test] fn equip_item_swaps_previous() { let mut c = base_char(); - c.equipment.insert("main_hand".into(), Some("old_sword".into())); + c.equipment + .insert("main_hand".into(), Some("old_sword".into())); c.inventory.push("sword1".into()); equip_item(&mut c, &sword(), "sword1").unwrap(); - assert_eq!(c.equipment.get("main_hand").cloned(), Some(Some("sword1".into()))); + assert_eq!( + c.equipment.get("main_hand").cloned(), + Some(Some("sword1".into())) + ); assert!(c.inventory.contains(&"old_sword".to_string())); assert!(!c.inventory.contains(&"sword1".to_string())); } @@ -228,13 +281,17 @@ mod tests { #[test] fn equip_item_fails_if_not_in_inventory() { let mut c = base_char(); - assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory")); + assert_eq!( + equip_item(&mut c, &sword(), "sword1"), + Err("item not in inventory") + ); } #[test] fn unequip_item_moves_to_inventory() { let mut c = base_char(); - c.equipment.insert("main_hand".into(), Some("sword1".into())); + c.equipment + .insert("main_hand".into(), Some("sword1".into())); let result = unequip_item(&mut c, "main_hand").unwrap(); assert_eq!(result, "sword1"); assert!(c.inventory.contains(&"sword1".to_string())); @@ -244,7 +301,10 @@ mod tests { #[test] fn unequip_item_fails_empty_slot() { let mut c = base_char(); - assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot")); + assert_eq!( + unequip_item(&mut c, "main_hand"), + Err("nothing equipped in slot") + ); } #[test] @@ -270,42 +330,77 @@ mod tests { #[test] fn equipped_attack_type_returns_weapon_type() { let items = vec![sword()]; - let eq: HashMap> = HashMap::from([ - ("main_hand".into(), Some("sword1".into())), - ]); - assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Slash)); + let eq: HashMap> = + HashMap::from([("main_hand".into(), Some("sword1".into()))]); + assert!(matches!( + equipped_attack_type(&eq, &items), + AttackType::Slash + )); } #[test] fn equipped_attack_type_defaults_to_crush() { let items: Vec = vec![]; let eq: HashMap> = HashMap::new(); - assert!(matches!(equipped_attack_type(&eq, &items), AttackType::Crush)); + assert!(matches!( + equipped_attack_type(&eq, &items), + AttackType::Crush + )); } #[test] fn pick_enemy_respects_weights() { use rand::SeedableRng; let encounters = vec![ - crate::data::EncounterEntry { enemy_id: "kobold".into(), weight: 3 }, - crate::data::EncounterEntry { enemy_id: "rat".into(), weight: 1 }, + crate::data::EncounterEntry { + enemy_id: "kobold".into(), + weight: 3, + }, + crate::data::EncounterEntry { + enemy_id: "rat".into(), + weight: 1, + }, ]; let enemies = vec![ crate::data::EnemyDef { - id: "kobold".into(), name: "Kobold".into(), - hp: 20, level: 1, damage: 5, defense: 2, + id: "kobold".into(), + name: "Kobold".into(), + hp: 20, + level: 1, + damage: 5, + defense: 2, attack_type: "crush".into(), - xp: 10, gold_min: 1, gold_max: 5, - stats: crate::data::EnemyStats { strength: 15, intelligence: 10, dexterity: 15, constitution: 15, luck: 5 }, + xp: 10, + gold_min: 1, + gold_max: 5, + stats: crate::data::EnemyStats { + strength: 15, + intelligence: 10, + dexterity: 15, + constitution: 15, + luck: 5, + }, description: String::new(), loot: vec![], }, crate::data::EnemyDef { - id: "rat".into(), name: "Giant Rat".into(), - hp: 10, level: 1, damage: 3, defense: 1, + id: "rat".into(), + name: "Giant Rat".into(), + hp: 10, + level: 1, + damage: 3, + defense: 1, attack_type: "puncture".into(), - xp: 5, gold_min: 0, gold_max: 2, - stats: crate::data::EnemyStats { strength: 5, intelligence: 5, dexterity: 15, constitution: 5, luck: 5 }, + xp: 5, + gold_min: 0, + gold_max: 2, + stats: crate::data::EnemyStats { + strength: 5, + intelligence: 5, + dexterity: 15, + constitution: 5, + luck: 5, + }, description: String::new(), loot: vec![], }, diff --git a/crates/sb-core/src/songs.rs b/crates/sb-core/src/songs.rs index 8768eb9..56886f2 100644 --- a/crates/sb-core/src/songs.rs +++ b/crates/sb-core/src/songs.rs @@ -55,5 +55,8 @@ pub const BARD_SONGS: &[SongDef] = &[ ]; pub fn available_songs(level: i32) -> Vec<&'static SongDef> { - BARD_SONGS.iter().filter(|s| s.required_level <= level).collect() + BARD_SONGS + .iter() + .filter(|s| s.required_level <= level) + .collect() } diff --git a/crates/sb-core/src/spells.rs b/crates/sb-core/src/spells.rs index e6042bc..de3f5c0 100644 --- a/crates/sb-core/src/spells.rs +++ b/crates/sb-core/src/spells.rs @@ -18,8 +18,7 @@ pub struct SpellDef { pub heal_max: i32, } -const SPELLS_JSON: &str = - include_str!("../../../../skara-brae-common/data/spells/spells.json"); +const SPELLS_JSON: &str = include_str!("../../../../skara-brae-common/data/spells/spells.json"); pub fn load_spells() -> Vec { serde_json::from_str(SPELLS_JSON).expect("failed to parse spells.json") @@ -31,9 +30,10 @@ pub fn class_for_spells(class: &str) -> &str { pub fn spells_for_class(class: &str, spell_level: i32) -> Vec<&'static SpellDef> { let spell_class = class_for_spells(class); - ALL_SPELLS.iter().filter(|s| { - s.caster_class == spell_class && s.spell_level <= spell_level - }).collect() + ALL_SPELLS + .iter() + .filter(|s| s.caster_class == spell_class && s.spell_level <= spell_level) + .collect() } pub fn find_spell(id: &str) -> Option<&'static SpellDef> { @@ -63,7 +63,10 @@ pub fn max_spell_points(class: &str, level: i32) -> i32 { } pub fn is_caster_class(class: &str) -> bool { - matches!(class_for_spells(class), "conjurer" | "magician" | "sorcerer" | "wizard") + matches!( + class_for_spells(class), + "conjurer" | "magician" | "sorcerer" | "wizard" + ) } pub fn caster_classes() -> &'static [&'static str] { diff --git a/crates/sb-db/src/lib.rs b/crates/sb-db/src/lib.rs index 9c62f9d..510169a 100644 --- a/crates/sb-db/src/lib.rs +++ b/crates/sb-db/src/lib.rs @@ -1,5 +1,5 @@ use rusqlite::Connection; -use sb_shared::{Character, CharacterStats, Class, Race, User, Role}; +use sb_shared::{Character, CharacterStats, Class, Race, Role, User}; use std::collections::HashMap; use std::path::Path; use std::sync::Mutex; @@ -11,7 +11,9 @@ pub struct Database { impl Database { pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; - let db = Self { conn: Mutex::new(conn) }; + let db = Self { + conn: Mutex::new(conn), + }; db.run_migrations()?; Ok(db) } @@ -59,7 +61,7 @@ impl Database { quantity INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id); - " + ", )?; Ok(()) } @@ -114,10 +116,15 @@ impl Database { let inventory_json: String = row.get(22)?; let known_spells_json: String = row.get(24)?; - let equipment_raw: HashMap = serde_json::from_str(&equipment_json).unwrap_or_default(); - let equipment: HashMap> = equipment_raw.into_iter().map(|(k, v)| (k, Some(v))).collect(); + let equipment_raw: HashMap = + serde_json::from_str(&equipment_json).unwrap_or_default(); + let equipment: HashMap> = equipment_raw + .into_iter() + .map(|(k, v)| (k, Some(v))) + .collect(); let inventory: Vec = serde_json::from_str(&inventory_json).unwrap_or_default(); - let known_spells: Vec = serde_json::from_str(&known_spells_json).unwrap_or_default(); + let known_spells: Vec = + serde_json::from_str(&known_spells_json).unwrap_or_default(); let race_str: String = row.get(3)?; let class_str: String = row.get(4)?; @@ -177,15 +184,32 @@ impl Database { VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)", rusqlite::params![ - char.id, char.user_id, char.name, - char.race.as_str(), char.class.as_str(), - char.level, char.xp, char.gold, - char.hp, char.max_hp, char.energy, char.max_energy, - char.nerve, char.max_nerve, char.spell_points, char.max_spell_points, - char.stats.strength, char.stats.intelligence, char.stats.dexterity, - char.stats.constitution, char.stats.luck, - equipment_json, inventory_json, - char.active_song, known_spells_json, char.active_spell, + char.id, + char.user_id, + char.name, + char.race.as_str(), + char.class.as_str(), + char.level, + char.xp, + char.gold, + char.hp, + char.max_hp, + char.energy, + char.max_energy, + char.nerve, + char.max_nerve, + char.spell_points, + char.max_spell_points, + char.stats.strength, + char.stats.intelligence, + char.stats.dexterity, + char.stats.constitution, + char.stats.luck, + equipment_json, + inventory_json, + char.active_song, + known_spells_json, + char.active_spell, char.last_regen, ], )?; @@ -217,11 +241,9 @@ impl Database { pub fn next_character_id(&self) -> Result { let conn = self.conn.lock().unwrap(); - let id: u32 = conn.query_row( - "SELECT COALESCE(MAX(id), 0) FROM characters", - [], - |row| row.get(0), - )?; + let id: u32 = conn.query_row("SELECT COALESCE(MAX(id), 0) FROM characters", [], |row| { + row.get(0) + })?; Ok(id + 1) } } diff --git a/crates/sb-shared/src/character.rs b/crates/sb-shared/src/character.rs index 89440dd..08cccdd 100644 --- a/crates/sb-shared/src/character.rs +++ b/crates/sb-shared/src/character.rs @@ -131,8 +131,12 @@ pub struct Character { pub max_spell_points: i32, } -fn default_sp() -> i32 { 0 } -fn default_max_sp() -> i32 { 0 } +fn default_sp() -> i32 { + 0 +} +fn default_max_sp() -> i32 { + 0 +} #[derive(Debug, Clone, Deserialize)] pub struct CreateCharacter { diff --git a/crates/sb-shared/src/responses.rs b/crates/sb-shared/src/responses.rs index 58af405..82ffa5d 100644 --- a/crates/sb-shared/src/responses.rs +++ b/crates/sb-shared/src/responses.rs @@ -1,5 +1,5 @@ -use serde::{Deserialize, Serialize}; use crate::character::Character; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CritEntry {