From 337e9bcf2c6d49473ec425be727582ba1fbf6af8 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 11:53:59 +0200 Subject: [PATCH 1/7] fmt: repo-wide baseline (WI-2, issue #2) --- crates/sb-api/src/main.rs | 2 +- crates/sb-api/src/middleware/auth.rs | 11 +- crates/sb-api/src/middleware/rate_limit.rs | 1 + crates/sb-api/src/routes/mod.rs | 472 ++++++++++++++------- crates/sb-api/src/ws/channels.rs | 1 + crates/sb-api/src/ws/handler.rs | 1 + crates/sb-api/src/ws/mod.rs | 2 +- crates/sb-auth/src/hash.rs | 6 +- crates/sb-auth/src/jwt.rs | 15 +- crates/sb-core/src/character.rs | 275 ++++++++++-- crates/sb-core/src/combat.rs | 98 +++-- crates/sb-core/src/data.rs | 6 +- crates/sb-core/src/items.rs | 9 +- crates/sb-core/src/services.rs | 191 ++++++--- crates/sb-core/src/songs.rs | 5 +- crates/sb-core/src/spells.rs | 15 +- crates/sb-db/src/lib.rs | 62 ++- crates/sb-shared/src/character.rs | 8 +- crates/sb-shared/src/responses.rs | 2 +- 19 files changed, 847 insertions(+), 335 deletions(-) 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 { From 093012af201154f9d84368d8aa4df450a97ca2c8 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 11:58:02 +0200 Subject: [PATCH 2/7] sb-core: constants module (WI-2, issue #2) --- crates/sb-core/src/character.rs | 30 ++++---- crates/sb-core/src/combat.rs | 44 ++++++----- crates/sb-core/src/constants.rs | 131 ++++++++++++++++++++++++++++++++ crates/sb-core/src/lib.rs | 1 + crates/sb-core/src/services.rs | 4 +- crates/sb-core/src/spells.rs | 58 +++++++++++--- 6 files changed, 223 insertions(+), 45 deletions(-) create mode 100644 crates/sb-core/src/constants.rs diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index e793147..99c1272 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -1,3 +1,4 @@ +use crate::constants; use crate::spells; use sb_shared::{Character, CharacterStats, Class, Race}; @@ -186,11 +187,11 @@ pub const CLASSES: &[ClassInfo] = &[ ]; pub fn stat_bonus(stat: i64) -> i64 { - (stat - 20) / 2 + (stat - constants::STAT_BONUS_NEUTRAL) / constants::STAT_BONUS_STEP } fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 { - let base: i64 = rng.random_range(6..=16); + let base: i64 = rng.random_range(constants::STAT_ROLL_MIN..=constants::STAT_ROLL_MAX); base + modifier } @@ -221,7 +222,7 @@ pub fn calc_max_energy(class: &Class) -> i32 { .iter() .find(|c| &c.class == class) .expect("invalid class"); - 30 + info.base_hp / 3 + constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR } pub fn calc_max_nerve(class: &Class) -> i32 { @@ -229,19 +230,19 @@ pub fn calc_max_nerve(class: &Class) -> i32 { .iter() .find(|c| &c.class == class) .expect("invalid class"); - 100 + info.base_hp / 2 + constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR } pub fn energy_regen_rate() -> i64 { - 180000 + constants::ENERGY_REGEN_INTERVAL_MS } pub fn nerve_regen_rate() -> i64 { - 300000 + constants::NERVE_REGEN_INTERVAL_MS } pub fn hp_regen_rate() -> i64 { - 300000 + constants::HP_REGEN_INTERVAL_MS } pub fn apply_regen(char: &mut Character, now_ms: i64) { @@ -259,15 +260,16 @@ pub fn apply_regen(char: &mut Character, now_ms: i64) { char.nerve = (char.nerve + nerve_ticks).min(char.max_nerve); } if hp_ticks > 0 && char.hp > 0 && char.hp < char.max_hp { - let regen_per_tick = (char.max_hp as f64 * 0.05).ceil() as i32; + let regen_per_tick = + (char.max_hp as f64 * constants::HP_REGEN_FRACTION_PER_TICK).ceil() as i32; char.hp = (char.hp + regen_per_tick * hp_ticks).min(char.max_hp); } char.last_regen = now_ms; } pub fn training_cost(stat: i64) -> i32 { - let scaled = (stat as f64 / 10.0).floor() as i32; - (5.0_f64 * 1.3_f64.powi(scaled)) as i32 + let scaled = (stat as f64 / constants::TRAINING_STAT_SCALER).floor() as i32; + (constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as i32 } pub fn create_character( @@ -299,7 +301,7 @@ pub fn create_character( name, race, class, - level: 1, + level: constants::STARTING_LEVEL, xp: 0, stats, hp: max_hp, @@ -308,7 +310,7 @@ pub fn create_character( max_energy, nerve: max_nerve, max_nerve, - gold: 50, + gold: constants::STARTING_GOLD, last_regen: 0, equipment: [ ("head".into(), None), @@ -342,11 +344,11 @@ pub fn xp_for_level(level: i32) -> i64 { return 0; } let n = (level - 1) as f64; - XP_FACTOR * (100.0 * n * n) as i64 + XP_FACTOR * (constants::XP_CURVE_COEFFICIENT * n * n) as i64 } pub fn level_up_cost(level: i32) -> i32 { - 500 * level + constants::LEVEL_UP_GOLD_PER_LEVEL * level } pub fn level_up(char: &mut Character) -> Result { diff --git a/crates/sb-core/src/combat.rs b/crates/sb-core/src/combat.rs index 495edac..2d2d5f8 100644 --- a/crates/sb-core/src/combat.rs +++ b/crates/sb-core/src/combat.rs @@ -1,3 +1,4 @@ +use crate::constants; use rand::Rng; use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; use serde::{Deserialize, Serialize}; @@ -32,7 +33,8 @@ impl CombatStats { pub fn defensive_bonus(&self) -> i32 { let dex_bonus = super::character::stat_bonus(self.dexterity) as i32; - let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2; + let con_bonus = (super::character::stat_bonus(self.constitution) as i32) + / constants::DEFENSIVE_CON_BONUS_DIVISOR; dex_bonus + con_bonus + self.db_base } } @@ -52,21 +54,27 @@ pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i3 return None; } let cap_pct = match diff { - 1..=9 => return None, - 10..=19 => 0.60, - 20..=49 => 0.40, - 50..=99 => 0.15, - _ => 0.05, + 1..=constants::CRIT_CAP_DISABLED_MAX_LEVEL_GAP => return None, + constants::CRIT_CAP_TIER1_MIN_GAP..=constants::CRIT_CAP_TIER1_MAX_GAP => { + constants::CRIT_CAP_TIER1_RATIO + } + constants::CRIT_CAP_TIER2_MIN_GAP..=constants::CRIT_CAP_TIER2_MAX_GAP => { + constants::CRIT_CAP_TIER2_RATIO + } + constants::CRIT_CAP_TIER3_MIN_GAP..=constants::CRIT_CAP_TIER3_MAX_GAP => { + constants::CRIT_CAP_TIER3_RATIO + } + _ => constants::CRIT_CAP_TIER4_RATIO, }; Some((defender_hp as f64 * cap_pct) as i32) } pub fn open_ended_roll(rng: &mut impl Rng) -> i32 { - let roll: i32 = rng.random_range(1..=100); + let roll: i32 = rng.random_range(constants::ATTACK_ROLL_MIN..=constants::ATTACK_ROLL_MAX); let mut total = roll; - if roll >= 96 { + if roll >= constants::OPEN_END_HIGH_THRESHOLD { total += open_ended_roll(rng); - } else if roll <= 5 { + } else if roll <= constants::OPEN_END_LOW_THRESHOLD { total -= open_ended_roll(rng); } total @@ -120,13 +128,13 @@ pub fn resolve_attack( } pub fn crit_severity(result: i32) -> Option { - if result >= 151 { + if result >= constants::CRIT_SEVERITY_D_MIN { Some("D".into()) - } else if result >= 101 { + } else if result >= constants::CRIT_SEVERITY_C_MIN { Some("C".into()) - } else if result >= 51 { + } else if result >= constants::CRIT_SEVERITY_B_MIN { Some("B".into()) - } else if result >= 21 { + } else if result >= constants::CRIT_SEVERITY_A_MIN { Some("A".into()) } else { None @@ -246,7 +254,7 @@ pub fn resolve_combat_full( } if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { - let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32; + let heal_amt = (attacker.hp as f64 * constants::BARD_SONG_HEAL_FRACTION).ceil() as i32; a_hp = (a_hp + heal_amt).min(attacker.hp); } @@ -265,10 +273,10 @@ fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> Com let mut s = stats.clone(); match song_effect { Some("accuracy") => { - s.ob_base += 15; + s.ob_base += constants::SONG_ACCURACY_OB_BONUS; } Some("damage") => { - s.ob_base += 20; + s.ob_base += constants::SONG_DAMAGE_OB_BONUS; } _ => {} } @@ -279,10 +287,10 @@ fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> Com let mut s = stats.clone(); match song_effect { Some("defense") | Some("magic_resist") => { - s.db_base += 10; + s.db_base += constants::SONG_DEFENSE_DB_BONUS; } Some("weaken") => { - s.ob_base = (s.ob_base as f64 * 0.7) as i32; + s.ob_base = (s.ob_base as f64 * constants::SONG_WEAKEN_OB_FACTOR) as i32; } _ => {} } diff --git a/crates/sb-core/src/constants.rs b/crates/sb-core/src/constants.rs new file mode 100644 index 0000000..4a410e4 --- /dev/null +++ b/crates/sb-core/src/constants.rs @@ -0,0 +1,131 @@ +//! Game-balance constants — the single audit home for tuning scalars. +//! Static data tables (`RACES`, `CLASSES`, `BARD_SONGS`, `TRAINING_GROUNDS`) +//! stay in their modules; only tuning scalars live here. + +pub use crate::character::{MAX_LEVEL, XP_FACTOR}; + +/// d100 attack roll bounds (origin: combat.rs:66). +pub const ATTACK_ROLL_MIN: i32 = 1; +/// d100 attack roll bounds (origin: combat.rs:66). +pub const ATTACK_ROLL_MAX: i32 = 100; + +/// Open-ended roll: re-roll upward at or above this value (origin: combat.rs:68). +pub const OPEN_END_HIGH_THRESHOLD: i32 = 96; +/// Open-ended roll: re-roll downward at or below this value (origin: combat.rs:70). +pub const OPEN_END_LOW_THRESHOLD: i32 = 5; + +/// Crit severity A floor (origin: combat.rs:129). +pub const CRIT_SEVERITY_A_MIN: i32 = 21; +/// Crit severity B floor (origin: combat.rs:127). +pub const CRIT_SEVERITY_B_MIN: i32 = 51; +/// Crit severity C floor (origin: combat.rs:125). +pub const CRIT_SEVERITY_C_MIN: i32 = 101; +/// Crit severity D floor (origin: combat.rs:123). +pub const CRIT_SEVERITY_D_MIN: i32 = 151; + +/// Level gaps up to this value apply no crit cap (origin: combat.rs:56). +pub const CRIT_CAP_DISABLED_MAX_LEVEL_GAP: i32 = 9; +/// Crit cap tier 1 level-gap range (origin: combat.rs:57). +pub const CRIT_CAP_TIER1_MIN_GAP: i32 = 10; +/// Crit cap tier 1 level-gap range (origin: combat.rs:57). +pub const CRIT_CAP_TIER1_MAX_GAP: i32 = 19; +/// Crit cap tier 2 level-gap range (origin: combat.rs:58). +pub const CRIT_CAP_TIER2_MIN_GAP: i32 = 20; +/// Crit cap tier 2 level-gap range (origin: combat.rs:58). +pub const CRIT_CAP_TIER2_MAX_GAP: i32 = 49; +/// Crit cap tier 3 level-gap range (origin: combat.rs:59). +pub const CRIT_CAP_TIER3_MIN_GAP: i32 = 50; +/// Crit cap tier 3 level-gap range (origin: combat.rs:59). +pub const CRIT_CAP_TIER3_MAX_GAP: i32 = 99; +/// Crit cap tier 4 applies from this gap upward (origin: combat.rs:60). +pub const CRIT_CAP_TIER4_MIN_GAP: i32 = 100; + +/// Crit damage cap ratio for level gaps 10–19 (origin: combat.rs:57). +pub const CRIT_CAP_TIER1_RATIO: f64 = 0.60; +/// Crit damage cap ratio for level gaps 20–49 (origin: combat.rs:58). +pub const CRIT_CAP_TIER2_RATIO: f64 = 0.40; +/// Crit damage cap ratio for level gaps 50–99 (origin: combat.rs:59). +pub const CRIT_CAP_TIER3_RATIO: f64 = 0.15; +/// Crit damage cap ratio for level gaps 100 and above (origin: combat.rs:60). +pub const CRIT_CAP_TIER4_RATIO: f64 = 0.05; + +/// Constitution contributes half its stat bonus to defense (origin: combat.rs:36). +pub const DEFENSIVE_CON_BONUS_DIVISOR: i32 = 2; + +/// Accuracy song offensive-bonus bonus (origin: combat.rs:246). +pub const SONG_ACCURACY_OB_BONUS: i32 = 15; +/// Damage song offensive-bonus bonus (origin: combat.rs:247). +pub const SONG_DAMAGE_OB_BONUS: i32 = 20; +/// Defense song defensive-bonus bonus (origin: combat.rs:256). +pub const SONG_DEFENSE_DB_BONUS: i32 = 10; +/// Weaken song reduces the foe's offensive bonus to this fraction (origin: combat.rs:257). +pub const SONG_WEAKEN_OB_FACTOR: f64 = 0.7; + +/// Bard heal song: fraction of max HP restored per round (origin: combat.rs:230). +pub const BARD_SONG_HEAL_FRACTION: f64 = 0.03; + +/// Stat bonus curve: bonus is zero at this stat value (origin: character.rs:59). +pub const STAT_BONUS_NEUTRAL: i64 = 20; +/// Stat bonus curve: bonus step size per 2 stat points (origin: character.rs:59). +pub const STAT_BONUS_STEP: i64 = 2; + +/// Character creation stat roll bounds (origin: character.rs:63). +pub const STAT_ROLL_MIN: i64 = 6; +/// Character creation stat roll bounds (origin: character.rs:63). +pub const STAT_ROLL_MAX: i64 = 16; + +/// Base maximum energy before the class contribution (origin: character.rs:85). +pub const BASE_MAX_ENERGY: i32 = 30; +/// Energy gains one point per this many base HP (origin: character.rs:85). +pub const MAX_ENERGY_PER_BASE_HP_DIVISOR: i32 = 3; + +/// Base maximum nerve before the class contribution (origin: character.rs:90). +pub const BASE_MAX_NERVE: i32 = 100; +/// Nerve gains one point per this many base HP (origin: character.rs:90). +pub const MAX_NERVE_PER_BASE_HP_DIVISOR: i32 = 2; + +/// Energy regen tick interval in milliseconds (origin: character.rs:94). +pub const ENERGY_REGEN_INTERVAL_MS: i64 = 180_000; +/// Nerve regen tick interval in milliseconds (origin: character.rs:98). +pub const NERVE_REGEN_INTERVAL_MS: i64 = 300_000; +/// HP regen tick interval in milliseconds (origin: character.rs:102). +pub const HP_REGEN_INTERVAL_MS: i64 = 300_000; + +/// HP restored per regen tick, as a fraction of max HP (origin: character.rs:118). +pub const HP_REGEN_FRACTION_PER_TICK: f64 = 0.05; + +/// Training cost curve base cost (origin: character.rs:126). +pub const TRAINING_BASE_COST: f64 = 5.0; +/// Training cost curve growth factor (origin: character.rs:126). +pub const TRAINING_GROWTH_FACTOR: f64 = 1.3; +/// Training cost curve stat scaler (origin: character.rs:125). +pub const TRAINING_STAT_SCALER: f64 = 10.0; + +/// Energy spent per training session (origin: services.rs:85, 100). +pub const TRAINING_ENERGY_COST: i32 = 1; + +/// Level assigned at character creation (origin: character.rs:157). +pub const STARTING_LEVEL: i32 = 1; +/// Gold assigned at character creation (origin: character.rs:166). +pub const STARTING_GOLD: i32 = 50; + +/// XP curve coefficient: 18 · 100 · (level−1)² (origin: character.rs:196). +pub const XP_CURVE_COEFFICIENT: f64 = 100.0; + +/// Gold cost per target level for level-up (origin: character.rs:200). +pub const LEVEL_UP_GOLD_PER_LEVEL: i32 = 500; + +/// Caster levels per spell level: (level / 3) + 1 curve (origin: spells.rs:59). +pub const CASTER_LEVELS_PER_SPELL_LEVEL: i32 = 3; +/// Spell points granted per caster spell level (origin: spells.rs:62). +pub const SPELL_POINTS_PER_SPELL_LEVEL: i32 = 10; + +/// Spell learn cost table, indexed by spell level − 1 (origin: spells.rs:44–52). +pub const SPELL_LEARN_COST_BY_LEVEL: [i32; 7] = [0, 1000, 2000, 4000, 7000, 10000, 20000]; +/// Learn cost reported for spell levels outside 1..=7 (origin: spells.rs:52). +pub const SPELL_LEARN_COST_UNKNOWN_LEVEL: i32 = 999_999; + +/// Lowest learn-request spell level — request validation only (origin: routes/mod.rs:1146). +pub const MIN_SPELL_LEVEL: i32 = 1; +/// Highest learn-request spell level — request validation only (origin: routes/mod.rs:1146). +pub const MAX_SPELL_LEVEL: i32 = 7; diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index 813f904..0eb4216 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod character; pub mod combat; +pub mod constants; pub mod data; pub mod items; pub mod services; diff --git a/crates/sb-core/src/services.rs b/crates/sb-core/src/services.rs index c745f80..348a153 100644 --- a/crates/sb-core/src/services.rs +++ b/crates/sb-core/src/services.rs @@ -100,7 +100,7 @@ pub fn unequip_item(char: &mut Character, slot: &str) -> Result Result { - if char.energy < 1 { + if char.energy < crate::constants::TRAINING_ENERGY_COST { return Err("not enough energy"); } let current = match stat { @@ -115,7 +115,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result if char.gold < cost { return Err("not enough gold"); } - char.energy -= 1; + char.energy -= crate::constants::TRAINING_ENERGY_COST; char.gold -= cost; match stat { "strength" => char.stats.strength += 1, diff --git a/crates/sb-core/src/spells.rs b/crates/sb-core/src/spells.rs index de3f5c0..843413c 100644 --- a/crates/sb-core/src/spells.rs +++ b/crates/sb-core/src/spells.rs @@ -1,3 +1,4 @@ +use crate::constants; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -41,25 +42,24 @@ pub fn find_spell(id: &str) -> Option<&'static SpellDef> { } pub fn spell_learn_cost(spell_level: i32) -> i32 { - match spell_level { - 1 => 0, - 2 => 1000, - 3 => 2000, - 4 => 4000, - 5 => 7000, - 6 => 10000, - 7 => 20000, - _ => 999999, + if (constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL).contains(&spell_level) { + constants::SPELL_LEARN_COST_BY_LEVEL[(spell_level - 1) as usize] + } else { + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL } } +pub fn caster_spell_level(character_level: i32) -> i32 { + (character_level / constants::CASTER_LEVELS_PER_SPELL_LEVEL) + 1 +} + pub fn max_spell_points(class: &str, level: i32) -> i32 { let spell_class = class_for_spells(class); let spell_level = match spell_class { - "conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1, + "conjurer" | "magician" | "sorcerer" | "wizard" => caster_spell_level(level), _ => 0, }; - spell_level * 10 + spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL } pub fn is_caster_class(class: &str) -> bool { @@ -75,3 +75,39 @@ pub fn caster_classes() -> &'static [&'static str] { use std::sync::LazyLock; static ALL_SPELLS: LazyLock> = LazyLock::new(load_spells); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn caster_spell_level_spot_values() { + assert_eq!(caster_spell_level(1), 1); + assert_eq!(caster_spell_level(2), 1); + assert_eq!(caster_spell_level(3), 2); + assert_eq!(caster_spell_level(6), 3); + assert_eq!(caster_spell_level(100), 34); + } + + #[test] + fn spell_learn_cost_guard_spot_checks() { + assert_eq!( + spell_learn_cost(0), + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL + ); + assert_eq!( + spell_learn_cost(-1), + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL + ); + assert_eq!( + spell_learn_cost(8), + constants::SPELL_LEARN_COST_UNKNOWN_LEVEL + ); + for level in constants::MIN_SPELL_LEVEL..=constants::MAX_SPELL_LEVEL { + assert_eq!( + spell_learn_cost(level), + constants::SPELL_LEARN_COST_BY_LEVEL[(level - 1) as usize] + ); + } + } +} From 423997f2d7a5396711b00ca15e9f302c9ff26d20 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 12:00:11 +0200 Subject: [PATCH 3/7] sb-core: DomainError + signature conversions (WI-2, issue #2) --- crates/sb-api/src/routes/mod.rs | 17 ++++------ crates/sb-core/src/character.rs | 15 +++++---- crates/sb-core/src/error.rs | 23 +++++++++++++ crates/sb-core/src/lib.rs | 1 + crates/sb-core/src/services.rs | 58 +++++++++++++++++++++------------ 5 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 crates/sb-core/src/error.rs diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index c7c80d6..1af0478 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -477,7 +477,8 @@ async fn run_adventure( } char.nerve -= zone.nerve_cost; - let enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies); + let enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; (char.clone(), enemy) }; @@ -698,8 +699,10 @@ async fn train_stat( 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, + sb_core::error::DomainError::NotEnoughEnergy + | sb_core::error::DomainError::NotEnoughGold => { + axum::http::StatusCode::UNPROCESSABLE_ENTITY + } _ => axum::http::StatusCode::BAD_REQUEST, })?; @@ -1204,13 +1207,7 @@ async fn level_up( .ok_or(axum::http::StatusCode::NOT_FOUND)?; match character::level_up(char) { Ok(_) => char.clone(), - Err(msg) => { - let status = match msg { - "not enough XP" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, - _ => axum::http::StatusCode::UNPROCESSABLE_ENTITY, - }; - return Err(status); - } + Err(_) => return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY), } }; persist_character(&state, &updated).await; diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index 99c1272..aee34a3 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -1,4 +1,5 @@ use crate::constants; +use crate::error::DomainError; use crate::spells; use sb_shared::{Character, CharacterStats, Class, Race}; @@ -351,17 +352,17 @@ pub fn level_up_cost(level: i32) -> i32 { constants::LEVEL_UP_GOLD_PER_LEVEL * level } -pub fn level_up(char: &mut Character) -> Result { +pub fn level_up(char: &mut Character) -> Result { if char.level >= MAX_LEVEL { - return Err("already at max level"); + return Err(DomainError::AlreadyAtMaxLevel); } let next_level = char.level + 1; if char.xp < xp_for_level(next_level) { - return Err("not enough XP"); + return Err(DomainError::NotEnoughXp); } let cost = level_up_cost(next_level); if char.gold < cost { - return Err("not enough gold"); + return Err(DomainError::NotEnoughGold); } char.gold -= cost; char.level = next_level; @@ -483,7 +484,7 @@ mod tests { c.level = MAX_LEVEL; c.xp = i64::MAX; c.gold = i32::MAX; - assert_eq!(level_up(&mut c), Err("already at max level")); + assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel)); } #[test] @@ -512,7 +513,7 @@ mod tests { let mut c = base_char(); c.xp = 100; c.gold = 10000; - assert_eq!(level_up(&mut c), Err("not enough XP")); + assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp)); assert_eq!(c.level, 1); assert_eq!(c.gold, 10000); } @@ -522,7 +523,7 @@ mod tests { let mut c = base_char(); c.xp = 1800; c.gold = 0; - assert_eq!(level_up(&mut c), Err("not enough gold")); + assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold)); assert_eq!(c.level, 1); } diff --git a/crates/sb-core/src/error.rs b/crates/sb-core/src/error.rs new file mode 100644 index 0000000..85e45c3 --- /dev/null +++ b/crates/sb-core/src/error.rs @@ -0,0 +1,23 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DomainError { + #[error("character is incapacitated")] + CharacterIncapacitated, + #[error("not enough nerve")] + NotEnoughNerve, + #[error("not enough energy")] + NotEnoughEnergy, + #[error("not enough gold")] + NotEnoughGold, + #[error("not enough XP")] + NotEnoughXp, + #[error("already at max level")] + AlreadyAtMaxLevel, + #[error("invalid stat name")] + InvalidStat, + #[error("item not in inventory")] + ItemNotInInventory, + #[error("nothing equipped in slot")] + NothingEquippedInSlot, + #[error("encounter references unknown enemy")] + UnknownEncounterEnemy, +} diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index 0eb4216..a56dc54 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod character; pub mod combat; pub mod constants; pub mod data; +pub mod error; pub mod items; pub mod services; pub mod songs; diff --git a/crates/sb-core/src/services.rs b/crates/sb-core/src/services.rs index 348a153..ceee6a2 100644 --- a/crates/sb-core/src/services.rs +++ b/crates/sb-core/src/services.rs @@ -1,4 +1,5 @@ use crate::data::{EncounterEntry, EnemyDef}; +use crate::error::DomainError; use rand::Rng; use sb_shared::{AttackType, Character, Item}; @@ -6,8 +7,11 @@ pub fn pick_enemy( rng: &mut impl Rng, encounters: &[EncounterEntry], enemies: &[EnemyDef], -) -> EnemyDef { +) -> Result { let total_weight: i32 = encounters.iter().map(|e| e.weight).sum(); + if encounters.is_empty() || total_weight <= 0 { + return Err(DomainError::UnknownEncounterEnemy); + } let roll: i32 = rng.random_range(0..total_weight); let mut remaining = roll; for enc in encounters { @@ -16,15 +20,18 @@ pub fn pick_enemy( return enemies .iter() .find(|e| e.id == enc.enemy_id) - .expect("encounter references valid enemy") - .clone(); + .cloned() + .ok_or(DomainError::UnknownEncounterEnemy); } } + let last = encounters + .last() + .ok_or(DomainError::UnknownEncounterEnemy)?; enemies .iter() - .find(|e| e.id == encounters.last().unwrap().enemy_id) - .expect("encounter references valid enemy") - .clone() + .find(|e| e.id == last.enemy_id) + .cloned() + .ok_or(DomainError::UnknownEncounterEnemy) } pub fn equipped_attack_type( @@ -72,12 +79,12 @@ pub fn total_defense( total } -pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> { +pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), DomainError> { let inv_pos = char .inventory .iter() .position(|id| id == item_id) - .ok_or("item not in inventory")?; + .ok_or(DomainError::ItemNotInInventory)?; char.inventory.remove(inv_pos); let slot = item.slot.clone(); @@ -88,20 +95,20 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<() Ok(()) } -pub fn unequip_item(char: &mut Character, slot: &str) -> Result { +pub fn unequip_item(char: &mut Character, slot: &str) -> Result { let prev = char .equipment .insert(slot.to_string(), None) .flatten() - .ok_or("nothing equipped in slot")?; + .ok_or(DomainError::NothingEquippedInSlot)?; char.inventory.push(prev.clone()); Ok(prev) } -pub fn train_stat(char: &mut Character, stat: &str) -> Result { +pub fn train_stat(char: &mut Character, stat: &str) -> Result { if char.energy < crate::constants::TRAINING_ENERGY_COST { - return Err("not enough energy"); + return Err(DomainError::NotEnoughEnergy); } let current = match stat { "strength" => char.stats.strength, @@ -109,11 +116,11 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result "dexterity" => char.stats.dexterity, "constitution" => char.stats.constitution, "luck" => char.stats.luck, - _ => return Err("invalid stat"), + _ => return Err(DomainError::InvalidStat), }; let cost = super::character::training_cost(current); if char.gold < cost { - return Err("not enough gold"); + return Err(DomainError::NotEnoughGold); } char.energy -= crate::constants::TRAINING_ENERGY_COST; char.gold -= cost; @@ -135,7 +142,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result "dexterity" => char.stats.dexterity, "constitution" => char.stats.constitution, "luck" => char.stats.luck, - _ => return Err("invalid stat"), + _ => return Err(DomainError::InvalidStat), }; Ok(new_val) } @@ -225,20 +232,29 @@ mod tests { fn train_stat_fails_without_energy() { let mut c = base_char(); c.energy = 0; - assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy")); + assert_eq!( + train_stat(&mut c, "strength"), + Err(DomainError::NotEnoughEnergy) + ); } #[test] fn train_stat_fails_without_gold() { let mut c = base_char(); c.gold = 0; - assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold")); + assert_eq!( + train_stat(&mut c, "strength"), + Err(DomainError::NotEnoughGold) + ); } #[test] fn train_stat_fails_invalid_stat() { let mut c = base_char(); - assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat")); + assert_eq!( + train_stat(&mut c, "charisma"), + Err(DomainError::InvalidStat) + ); } #[test] @@ -283,7 +299,7 @@ mod tests { let mut c = base_char(); assert_eq!( equip_item(&mut c, &sword(), "sword1"), - Err("item not in inventory") + Err(DomainError::ItemNotInInventory) ); } @@ -303,7 +319,7 @@ mod tests { let mut c = base_char(); assert_eq!( unequip_item(&mut c, "main_hand"), - Err("nothing equipped in slot") + Err(DomainError::NothingEquippedInSlot) ); } @@ -407,7 +423,7 @@ mod tests { ]; let mut rng = rand::rngs::StdRng::seed_from_u64(42); for _ in 0..100 { - let enemy = pick_enemy(&mut rng, &encounters, &enemies); + let enemy = pick_enemy(&mut rng, &encounters, &enemies).unwrap(); assert!(enemy.id == "kobold" || enemy.id == "rat"); } } From 6d9f02b77f040c5b6af99ba16f02c2ea1e5c11b7 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 12:03:18 +0200 Subject: [PATCH 4/7] sb-core: adventure service (WI-2, issue #2) --- crates/sb-core/src/adventure.rs | 470 ++++++++++++++++++++++++++++++++ crates/sb-core/src/lib.rs | 1 + 2 files changed, 471 insertions(+) create mode 100644 crates/sb-core/src/adventure.rs diff --git a/crates/sb-core/src/adventure.rs b/crates/sb-core/src/adventure.rs new file mode 100644 index 0000000..320276f --- /dev/null +++ b/crates/sb-core/src/adventure.rs @@ -0,0 +1,470 @@ +use crate::character; +use crate::combat::{self, CombatStats}; +use crate::data::{EnemyDef, ZoneDef}; +use crate::error::DomainError; +use crate::services; +use crate::songs; +use crate::spells; +use rand::Rng; +use sb_shared::{Character, CombatLog, Item}; + +/// Domain output of one adventure run. No serde derives — the HTTP DTO +/// (sb-api `AdventureRunResponse`) maps from this at the boundary. +pub struct AdventureOutcome { + pub zone_id: String, + pub enemy_name: String, + pub combat_log: CombatLog, + pub loot_item_names: Vec, +} + +pub fn run_adventure( + rng: &mut impl Rng, + character: &mut Character, + zone: &ZoneDef, + enemies: &[EnemyDef], + items: &[Item], + now_ms: i64, +) -> Result { + character::apply_regen(character, now_ms); + + if character.hp <= 0 { + return Err(DomainError::CharacterIncapacitated); + } + if character.nerve < zone.nerve_cost { + return Err(DomainError::NotEnoughNerve); + } + character.nerve -= zone.nerve_cost; + + let enemy_def = services::pick_enemy(rng, &zone.encounters, enemies)?; + + let char_attack_type = services::equipped_attack_type(&character.equipment, items); + let char_ob = services::equipped_damage(&character.equipment, items); + let char_db = services::total_defense(&character.equipment, items); + + let char_combat = CombatStats { + name: character.name.clone(), + hp: character.hp, + level: character.level, + strength: character.stats.strength, + dexterity: character.stats.dexterity, + constitution: character.stats.constitution, + luck: character.stats.luck, + ob_base: char_ob, + db_base: char_db, + attack_type: char_attack_type, + }; + + let enemy_combat = CombatStats { + name: enemy_def.name.clone(), + hp: enemy_def.hp, + level: enemy_def.level, + strength: enemy_def.stats.strength, + dexterity: enemy_def.stats.dexterity, + constitution: enemy_def.stats.constitution, + luck: enemy_def.stats.luck, + ob_base: enemy_def.damage, + db_base: enemy_def.defense, + attack_type: combat::parse_attack_type(&enemy_def.attack_type), + }; + + let spell_effect = character.active_spell.as_deref().and_then(|sid| { + spells::find_spell(sid).map(|s| combat::SpellEffect { + combat_effect: s.combat_effect.clone(), + damage_min: s.combat_damage_min, + damage_max: s.combat_damage_max, + heal_min: s.heal_min, + heal_max: s.heal_max, + }) + }); + + let song_effect = character.active_song.as_deref().and_then(|sid| { + songs::BARD_SONGS + .iter() + .find(|s| s.id == sid) + .map(|s| s.combat_effect) + }); + + let log = combat::resolve_combat_full( + char_combat, + enemy_combat, + enemy_def.xp, + enemy_def.gold_min, + enemy_def.gold_max, + song_effect, + spell_effect.as_ref(), + rng, + ); + + let mut dropped_ids: Vec = Vec::new(); + if log.winner == character.name { + character.xp += log.xp_gained as i64; + character.gold += log.gold_gained; + if let Some(hp) = log + .rounds + .iter() + .rfind(|r| r.defender == character.name) + .map(|r| r.defender_hp) + { + character.hp = hp; + } + + for drop in &enemy_def.loot { + if rng.random_bool(drop.chance) && items.iter().any(|i| i.id == drop.item_id) { + character.inventory.push(drop.item_id.clone()); + dropped_ids.push(drop.item_id.clone()); + } + } + } else { + character.hp = 0; + } + + if let Some(active) = character.active_spell.as_deref() + && let Some(sp) = spells::find_spell(active) + { + character.spell_points = (character.spell_points - sp.spell_points).max(0); + } + + let loot_item_names = dropped_ids + .iter() + .filter_map(|id| items.iter().find(|i| &i.id == id).map(|i| i.name.clone())) + .collect(); + + Ok(AdventureOutcome { + zone_id: zone.id.clone(), + enemy_name: enemy_def.name.clone(), + combat_log: log, + loot_item_names, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants; + use crate::data::{EncounterEntry, EnemyStats, LootDrop}; + use rand::SeedableRng; + use sb_shared::{AttackType, CharacterStats, Class, 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: 50, + intelligence: 20, + dexterity: 20, + constitution: 20, + luck: 20, + }, + hp: 100, + max_hp: 100, + energy: 36, + max_energy: 36, + nerve: 110, + max_nerve: 110, + gold: 100, + 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 char_with_sword() -> Character { + let mut c = base_char(); + c.equipment + .insert("main_hand".into(), Some("sword1".into())); + c + } + + fn sword() -> Item { + Item { + 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, + } + } + + fn items() -> Vec { + vec![sword()] + } + + fn zone() -> ZoneDef { + zone_with("rat") + } + + fn zone_with(enemy_id: &str) -> ZoneDef { + ZoneDef { + id: "streets".into(), + name: "Skara Brae Streets".into(), + min_level: 1, + max_level: 5, + nerve_cost: 5, + description: String::new(), + encounters: vec![EncounterEntry { + enemy_id: enemy_id.into(), + weight: 1, + }], + } + } + + fn weak_enemy() -> EnemyDef { + EnemyDef { + id: "rat".into(), + name: "Giant Rat".into(), + hp: 10, + level: 1, + damage: 0, + defense: 0, + attack_type: "puncture".into(), + xp: 5, + gold_min: 1, + gold_max: 2, + stats: EnemyStats { + strength: 5, + intelligence: 5, + dexterity: 5, + constitution: 5, + luck: 5, + }, + description: String::new(), + loot: vec![LootDrop { + item_id: "sword1".into(), + chance: 1.0, + }], + } + } + + fn overwhelming_enemy() -> EnemyDef { + EnemyDef { + id: "doom".into(), + name: "Doom Bringer".into(), + hp: 500, + level: 10, + damage: 200, + defense: 0, + attack_type: "crush".into(), + xp: 5, + gold_min: 1, + gold_max: 2, + stats: EnemyStats { + strength: 5, + intelligence: 5, + dexterity: 5, + constitution: 5, + luck: 5, + }, + description: String::new(), + loot: vec![LootDrop { + item_id: "sword1".into(), + chance: 1.0, + }], + } + } + + #[test] + fn run_adventure_win_applies_rewards_and_loot() { + let mut rng = rand::rngs::StdRng::seed_from_u64(7); + let mut c = char_with_sword(); + let (xp_before, gold_before, nerve_before) = (c.xp, c.gold, c.nerve); + let z = zone(); + let outcome = + run_adventure(&mut rng, &mut c, &z, &[weak_enemy()], &items(), 1_000_000).unwrap(); + + assert_eq!(outcome.combat_log.winner, c.name); + assert_eq!(c.xp, xp_before + 5); + assert!(c.gold > gold_before && c.gold <= gold_before + 2); + assert_eq!(c.gold, gold_before + outcome.combat_log.gold_gained); + assert_eq!(c.nerve, nerve_before - z.nerve_cost); + assert!(c.inventory.contains(&"sword1".to_string())); + assert_eq!(outcome.loot_item_names, vec!["Iron Sword".to_string()]); + assert_eq!(outcome.enemy_name, "Giant Rat"); + assert_eq!(outcome.zone_id, "streets"); + assert_eq!( + serde_json::to_string(&outcome.combat_log).unwrap(), + GOLDEN_COMBAT_LOG_JSON + ); + } + + #[test] + fn run_adventure_defeat_sets_hp_to_zero() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = base_char(); + c.stats.strength = 5; + let (xp_before, gold_before) = (c.xp, c.gold); + let outcome = run_adventure( + &mut rng, + &mut c, + &zone_with("doom"), + &[overwhelming_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + + assert_eq!(outcome.combat_log.winner, "Doom Bringer"); + assert_eq!(c.hp, 0); + assert_eq!(c.xp, xp_before); + assert_eq!(c.gold, gold_before); + assert!(c.inventory.is_empty()); + assert!(outcome.loot_item_names.is_empty()); + } + + #[test] + fn run_adventure_rejects_incapacitated() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + c.hp = 0; + let nerve_before = c.nerve; + let result = run_adventure( + &mut rng, + &mut c, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ); + assert!(matches!(result, Err(DomainError::CharacterIncapacitated))); + assert_eq!(c.nerve, nerve_before); + } + + #[test] + fn run_adventure_rejects_insufficient_nerve() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + c.nerve = 4; + c.last_regen = 1_000_000; + let nerve_before = c.nerve; + let result = run_adventure( + &mut rng, + &mut c, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ); + assert!(matches!(result, Err(DomainError::NotEnoughNerve))); + assert_eq!(c.nerve, nerve_before); + } + + #[test] + fn run_adventure_applies_regen_before_guards() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + c.hp = 40; + c.max_hp = 80; + c.last_regen = 0; + let now_ms = 10 * constants::HP_REGEN_INTERVAL_MS; + let mut enemy = weak_enemy(); + enemy.damage = 0; + let outcome = run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), now_ms).unwrap(); + + assert_eq!(c.last_regen, now_ms); + assert_eq!(c.hp, 80); + assert_eq!(outcome.combat_log.winner, c.name); + } + + #[test] + fn run_adventure_charges_spell_upkeep_on_win_and_loss() { + let spell = spells::find_spell("arfi").expect("arfi fixture spell"); + assert!(spell.spell_points > 0); + + let mut win_rng = rand::rngs::StdRng::seed_from_u64(7); + let mut winner = char_with_sword(); + winner.active_spell = Some("arfi".into()); + winner.spell_points = 20; + run_adventure( + &mut win_rng, + &mut winner, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + assert_eq!(winner.spell_points, (20 - spell.spell_points).max(0)); + assert_eq!(winner.active_spell.as_deref(), Some("arfi")); + + let mut loss_rng = rand::rngs::StdRng::seed_from_u64(42); + let mut loser = base_char(); + loser.stats.strength = 5; + loser.active_spell = Some("arfi".into()); + loser.spell_points = 20; + run_adventure( + &mut loss_rng, + &mut loser, + &zone_with("doom"), + &[overwhelming_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + assert_eq!(loser.spell_points, (20 - spell.spell_points).max(0)); + assert_eq!(loser.active_spell.as_deref(), Some("arfi")); + } + + #[test] + fn run_adventure_filters_loot_to_catalog_items() { + let mut rng = rand::rngs::StdRng::seed_from_u64(7); + let mut enemy = weak_enemy(); + enemy.loot = vec![LootDrop { + item_id: "ghost_item".into(), + chance: 1.0, + }]; + let mut c = char_with_sword(); + let outcome = + run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), 1_000_000).unwrap(); + + assert!(!c.inventory.contains(&"ghost_item".to_string())); + assert!(outcome.loot_item_names.is_empty()); + } + + #[test] + fn run_adventure_is_deterministic_under_seeded_rng() { + let run = || { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + let outcome = run_adventure( + &mut rng, + &mut c, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + ( + format!( + "{:?}", + ( + outcome.zone_id, + outcome.enemy_name, + outcome.loot_item_names, + outcome.combat_log + ) + ), + format!("{:?}", c), + ) + }; + assert_eq!(run(), run()); + } + + const GOLDEN_COMBAT_LOG_JSON: &str = r#"{"rounds":[{"attacker":"Test","defender":"Giant Rat","raw_roll":-11,"ob":30,"db":-10,"result":29,"damage":43,"crit":{"severity":"A","crit":{"name":"Minor Slash","damage_multiplier":1.5,"stun_rounds":0,"bleed_per_round":1,"bleed_rounds":1,"effects":[]}},"defender_hp":0}],"winner":"Test","loser":"Giant Rat","xp_gained":5,"gold_gained":1}"#; +} diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index a56dc54..aa80266 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +pub mod adventure; pub mod character; pub mod combat; pub mod constants; From 5d5a594a7af5ad49e387cbc240bc14a717f81bbd Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 12:06:05 +0200 Subject: [PATCH 5/7] sb-api: responses module + handler rewiring (WI-2, issue #2) --- crates/sb-api/src/routes/mod.rs | 321 ++++++++------------------ crates/sb-api/src/routes/responses.rs | 239 +++++++++++++++++++ 2 files changed, 339 insertions(+), 221 deletions(-) create mode 100644 crates/sb-api/src/routes/responses.rs diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index 1af0478..0021f0d 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -5,8 +5,6 @@ use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS}; -use sb_core::combat::parse_attack_type; -use sb_core::combat::{self, CombatStats}; use sb_core::data; use sb_core::items; use sb_core::services as domain; @@ -19,14 +17,40 @@ use sb_shared::{ ShopItemResponse, ShopResponse, SongSummary, SongsResponse, TrainStatResponse, TrainingStatResponse, User, ZoneSummary, }; +use std::collections::BTreeMap; use std::sync::Arc; use tokio::sync::RwLock; +mod responses; + fn request_rng() -> rand::rngs::StdRng { use rand::SeedableRng; rand::rngs::StdRng::from_os_rng() } +fn now_unix_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} + +fn domain_status(error: sb_core::error::DomainError) -> axum::http::StatusCode { + use sb_core::error::DomainError; + match error { + DomainError::CharacterIncapacitated + | DomainError::InvalidStat + | DomainError::ItemNotInInventory + | DomainError::NothingEquippedInSlot => axum::http::StatusCode::BAD_REQUEST, + DomainError::NotEnoughNerve + | DomainError::NotEnoughEnergy + | DomainError::NotEnoughGold + | DomainError::NotEnoughXp + | DomainError::AlreadyAtMaxLevel => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + DomainError::UnknownEncounterEnemy => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + } +} + pub(crate) struct AppState { users: RwLock>, items: Vec, @@ -259,10 +283,7 @@ async fn get_characters( if user_id != auth.user_id && auth.role != "admin" { return Err(axum::http::StatusCode::FORBIDDEN); } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; + let now = now_unix_ms(); let mut chars = state.characters.write().await; for c in chars.iter_mut() { sb_core::character::apply_regen(c, now); @@ -330,10 +351,7 @@ async fn get_character( Some(_) => {} } } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; + let now = now_unix_ms(); let mut chars = state.characters.write().await; let char = chars .iter_mut() @@ -379,8 +397,7 @@ async fn equip_item( .find(|c| c.id == char_id) .ok_or(axum::http::StatusCode::NOT_FOUND)?; - domain::equip_item(char, item, &input.item_id) - .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; + domain::equip_item(char, item, &input.item_id).map_err(domain_status)?; char.clone() }; @@ -418,7 +435,7 @@ async fn unequip_item( .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(domain_status)?; char.clone() }; @@ -432,7 +449,7 @@ async fn run_adventure( Extension(auth): Extension, Path(char_id): Path, Query(query): Query, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { let zone_id = query.zone_id.as_deref().unwrap_or("skara_brae_streets"); { @@ -453,158 +470,31 @@ async fn run_adventure( .find(|z| z.id == zone_id) .ok_or(axum::http::StatusCode::NOT_FOUND)?; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - + let now_ms = now_unix_ms(); let mut rng = request_rng(); - let (char, enemy_def) = { + let (outcome, updated) = { let mut chars = state.characters.write().await; - let char = chars + let character = chars .iter_mut() .find(|c| c.id == char_id) .ok_or(axum::http::StatusCode::NOT_FOUND)?; - sb_core::character::apply_regen(char, now); - - if char.hp <= 0 { - return Err(axum::http::StatusCode::BAD_REQUEST); - } - if char.nerve < zone.nerve_cost { - return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); - } - char.nerve -= zone.nerve_cost; - - let enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies) - .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; - (char.clone(), enemy) + let outcome = sb_core::adventure::run_adventure( + &mut rng, + character, + zone, + &state.enemies, + &state.items, + now_ms, + ) + .map_err(domain_status)?; + (outcome, character.clone()) }; - let char_attack_type = domain::equipped_attack_type(&char.equipment, &state.items); - let char_ob = domain::equipped_damage(&char.equipment, &state.items); - let char_db = domain::total_defense(&char.equipment, &state.items); + persist_character(&state, &updated).await; - let char_combat = CombatStats { - name: char.name.clone(), - hp: char.hp, - level: char.level, - strength: char.stats.strength, - dexterity: char.stats.dexterity, - constitution: char.stats.constitution, - luck: char.stats.luck, - ob_base: char_ob, - db_base: char_db, - attack_type: char_attack_type, - }; - - let enemy_combat = CombatStats { - name: enemy_def.name.clone(), - hp: enemy_def.hp, - level: enemy_def.level, - strength: enemy_def.stats.strength, - dexterity: enemy_def.stats.dexterity, - constitution: enemy_def.stats.constitution, - luck: enemy_def.stats.luck, - ob_base: enemy_def.damage, - db_base: enemy_def.defense, - attack_type: parse_attack_type(&enemy_def.attack_type), - }; - - let spell_effect = char.active_spell.as_deref().and_then(|sid| { - spells::find_spell(sid).map(|s| combat::SpellEffect { - combat_effect: s.combat_effect.clone(), - damage_min: s.combat_damage_min, - damage_max: s.combat_damage_max, - heal_min: s.heal_min, - heal_max: s.heal_max, - }) - }); - - let log = combat::resolve_combat_full( - char_combat, - enemy_combat, - enemy_def.xp, - 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) - }), - spell_effect.as_ref(), - &mut rng, - ); - - let (loot_names, updated_char) = { - let mut chars = state.characters.write().await; - let dropped_ids: Vec = if let Some(c) = chars.iter_mut().find(|c| c.id == char_id) { - use rand::Rng; - - if log.winner == c.name { - c.xp += log.xp_gained as i64; - c.gold += log.gold_gained; - let char_hp = log - .rounds - .iter() - .rfind(|r| r.defender == c.name) - .map(|r| r.defender_hp); - if let Some(hp) = char_hp { - c.hp = hp; - } - - let mut drops = Vec::new(); - for drop in &enemy_def.loot { - if rng.random_bool(drop.chance) - && state.items.iter().any(|i| i.id == drop.item_id) - { - c.inventory.push(drop.item_id.clone()); - drops.push(drop.item_id.clone()); - } - } - drops - } else { - c.hp = 0; - Vec::new() - } - } else { - Vec::new() - }; - - if let Some(c) = chars.iter_mut().find(|c| c.id == char_id) - && c.active_spell.is_some() - && let Some(sp) = spells::find_spell(c.active_spell.as_deref().unwrap_or("")) - { - c.spell_points = (c.spell_points - sp.spell_points).max(0); - } - - 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(); - (loot, updated) - }; - - if let Some(ref c) = updated_char { - persist_character(&state, c).await; - } - - Ok(Json(serde_json::json!({ - "zone": zone_id, - "enemy": &enemy_def.name, - "combat_log": &log, - "loot": loot_names, - }))) + Ok(Json(outcome.into())) } async fn get_gym( @@ -684,10 +574,7 @@ async fn train_stat( Some(_) => {} } } - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; + let now = now_unix_ms(); let (updated, _value) = { let mut chars = state.characters.write().await; @@ -698,13 +585,7 @@ async fn train_stat( sb_core::character::apply_regen(char, now); - let new_val = domain::train_stat(char, &input.stat).map_err(|e| match e { - sb_core::error::DomainError::NotEnoughEnergy - | sb_core::error::DomainError::NotEnoughGold => { - axum::http::StatusCode::UNPROCESSABLE_ENTITY - } - _ => axum::http::StatusCode::BAD_REQUEST, - })?; + let new_val = domain::train_stat(char, &input.stat).map_err(domain_status)?; (char.clone(), new_val) }; @@ -952,7 +833,7 @@ async fn heal_character( Extension(auth): Extension, Path(char_id): Path, Json(input): Json, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { { let chars = state.characters.read().await; let char = chars.iter().find(|c| c.id == char_id); @@ -995,12 +876,12 @@ async fn heal_character( }; persist_character(&state, &updated).await; - Ok(Json(serde_json::json!({ - "hp": updated.hp, - "max_hp": updated.max_hp, - "gold": updated.gold, - "healed": true, - }))) + Ok(Json(responses::HealResponse { + gold: updated.gold, + healed: true, + hp: updated.hp, + max_hp: updated.max_hp, + })) } async fn get_songs( @@ -1114,7 +995,7 @@ async fn get_spells( State(state): State>, Extension(auth): Extension, Path(char_id): Path, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { { let chars = state.characters.read().await; let char = chars.iter().find(|c| c.id == char_id); @@ -1138,43 +1019,37 @@ async fn get_spells( if !spells::is_caster_class(&class_str) { return Err(axum::http::StatusCode::FORBIDDEN); } - let spell_level = (char.level / 3) + 1; + let spell_level = spells::caster_spell_level(char.level); let available = spells::spells_for_class(&class_str, spell_level); - let spells_json: Vec = available + 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()), - }) + .map(|s| responses::SpellEntryResponse { + code: s.code.clone(), + combat_damage_max: s.combat_damage_max, + combat_damage_min: s.combat_damage_min, + combat_effect: s.combat_effect.clone(), + description: s.description.clone(), + heal_max: s.heal_max, + heal_min: s.heal_min, + id: s.id.clone(), + known: char.known_spells.contains(&s.id.to_string()), + name: s.name.clone(), + range: s.range.clone(), + spell_level: s.spell_level, + spell_points: s.spell_points, }) .collect(); - Ok(Json(serde_json::json!({ - "caster_class": class_str, - "spell_level": spell_level, - "spell_points": char.spell_points, - "max_spell_points": char.max_spell_points, - "spells": spells_json, - "learn_costs": { - "2": spells::spell_learn_cost(2), - "3": spells::spell_learn_cost(3), - "4": spells::spell_learn_cost(4), - "5": spells::spell_learn_cost(5), - "6": spells::spell_learn_cost(6), - "7": spells::spell_learn_cost(7), - }, - }))) + let learn_costs = (2..=7) + .map(|level| (level.to_string(), spells::spell_learn_cost(level))) + .collect::>(); + Ok(Json(responses::SpellbookResponse { + caster_class: class_str, + learn_costs, + max_spell_points: char.max_spell_points, + spell_level, + spell_points: char.spell_points, + spells: spells_json, + })) } #[derive(serde::Deserialize)] @@ -1207,7 +1082,7 @@ async fn level_up( .ok_or(axum::http::StatusCode::NOT_FOUND)?; match character::level_up(char) { Ok(_) => char.clone(), - Err(_) => return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY), + Err(e) => return Err(domain_status(e)), } }; persist_character(&state, &updated).await; @@ -1223,7 +1098,7 @@ async fn learn_spell_level( Extension(auth): Extension, Path(char_id): Path, Json(input): Json, -) -> Result, axum::http::StatusCode> { +) -> Result, axum::http::StatusCode> { { let chars = state.characters.read().await; let char = chars.iter().find(|c| c.id == char_id); @@ -1258,10 +1133,12 @@ async fn learn_spell_level( char.clone() }; persist_character(&state, &updated).await; - return Ok(Json(serde_json::json!({ - "active_spell": updated.active_spell, - "spell_points": updated.spell_points, - }))); + return Ok(Json(responses::LearnSpellResponse::Activate( + responses::ActivateSpellResponse { + active_spell: updated.active_spell, + spell_points: updated.spell_points, + }, + ))); } let spell_level = input @@ -1283,7 +1160,7 @@ async fn learn_spell_level( return Err(axum::http::StatusCode::BAD_REQUEST); } - let current_level = (char.level / 3) + 1; + let current_level = spells::caster_spell_level(char.level); if spell_level > current_level { return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } @@ -1317,12 +1194,14 @@ async fn learn_spell_level( }; persist_character(&state, &updated).await; - Ok(Json(serde_json::json!({ - "learned_count": updated.known_spells.len(), - "spell_points": updated.spell_points, - "max_spell_points": updated.max_spell_points, - "gold": updated.gold, - }))) + Ok(Json(responses::LearnSpellResponse::Learn( + responses::LearnSpellsResponse { + gold: updated.gold, + learned_count: updated.known_spells.len(), + max_spell_points: updated.max_spell_points, + spell_points: updated.spell_points, + }, + ))) } #[derive(serde::Deserialize)] diff --git a/crates/sb-api/src/routes/responses.rs b/crates/sb-api/src/routes/responses.rs new file mode 100644 index 0000000..98ce1c5 --- /dev/null +++ b/crates/sb-api/src/routes/responses.rs @@ -0,0 +1,239 @@ +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Debug, Serialize)] +pub struct AdventureRunResponse { + pub combat_log: serde_json::Value, + pub enemy: String, + pub loot: Vec, + pub zone: String, +} + +#[derive(Debug, Serialize)] +pub struct HealResponse { + pub gold: i32, + pub healed: bool, + pub hp: i32, + pub max_hp: i32, +} + +#[derive(Debug, Serialize)] +pub struct SpellEntryResponse { + pub code: String, + pub combat_damage_max: i32, + pub combat_damage_min: i32, + pub combat_effect: String, + pub description: String, + pub heal_max: i32, + pub heal_min: i32, + pub id: String, + pub known: bool, + pub name: String, + pub range: String, + pub spell_level: i32, + pub spell_points: i32, +} + +#[derive(Debug, Serialize)] +pub struct SpellbookResponse { + pub caster_class: String, + pub learn_costs: BTreeMap, + pub max_spell_points: i32, + pub spell_level: i32, + pub spell_points: i32, + pub spells: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ActivateSpellResponse { + pub active_spell: Option, + pub spell_points: i32, +} + +#[derive(Debug, Serialize)] +pub struct LearnSpellsResponse { + pub gold: i32, + pub learned_count: usize, + pub max_spell_points: i32, + pub spell_points: i32, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum LearnSpellResponse { + Activate(ActivateSpellResponse), + Learn(LearnSpellsResponse), +} + +impl From for AdventureRunResponse { + fn from(outcome: sb_core::adventure::AdventureOutcome) -> Self { + AdventureRunResponse { + // to_value mirrors exactly what json! does to &log today: struct → + // Value::Object with BTreeMap ordering — nested keys sorted, byte-identical wire. + combat_log: serde_json::to_value(&outcome.combat_log) + .expect("CombatLog graph contains no map types; to_value is infallible"), + enemy: outcome.enemy_name, + loot: outcome.loot_item_names, + zone: outcome.zone_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use sb_shared::{CombatLog, CombatRound, CritEntry, CritResult}; + + fn representative_log() -> CombatLog { + CombatLog { + rounds: vec![ + CombatRound { + attacker: "Test".into(), + defender: "Giant Rat".into(), + raw_roll: 67, + ob: 15, + db: 0, + result: 82, + damage: 12, + crit: None, + defender_hp: 0, + }, + CombatRound { + attacker: "Giant Rat".into(), + defender: "Test".into(), + raw_roll: 99, + ob: 3, + db: 5, + result: 97, + damage: 18, + crit: Some(CritResult { + severity: "A".into(), + crit: CritEntry { + name: "critical".into(), + damage_multiplier: 2.0, + stun_rounds: 1, + bleed_per_round: 2, + bleed_rounds: 3, + effects: vec!["stun".into()], + }, + }), + defender_hp: 62, + }, + ], + winner: "Test".into(), + loser: "Giant Rat".into(), + xp_gained: 5, + gold_gained: 2, + } + } + + fn representative_outcome() -> sb_core::adventure::AdventureOutcome { + sb_core::adventure::AdventureOutcome { + zone_id: "skara_brae_streets".into(), + enemy_name: "Giant Rat".into(), + combat_log: representative_log(), + loot_item_names: vec!["Iron Sword".into()], + } + } + + const GOLDEN_W1: &str = r#"{"combat_log":{"gold_gained":2,"loser":"Giant Rat","rounds":[{"attacker":"Test","crit":null,"damage":12,"db":0,"defender":"Giant Rat","defender_hp":0,"ob":15,"raw_roll":67,"result":82},{"attacker":"Giant Rat","crit":{"crit":{"bleed_per_round":2,"bleed_rounds":3,"damage_multiplier":2.0,"effects":["stun"],"name":"critical","stun_rounds":1},"severity":"A"},"damage":18,"db":5,"defender":"Test","defender_hp":62,"ob":3,"raw_roll":99,"result":97}],"winner":"Test","xp_gained":5},"enemy":"Giant Rat","loot":["Iron Sword"],"zone":"skara_brae_streets"}"#; + + const GOLDEN_W2: &str = r#"{"gold":123,"healed":true,"hp":45,"max_hp":80}"#; + + const GOLDEN_W3: &str = r#"{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}"#; + + const GOLDEN_W4: &str = r#"{"caster_class":"mage","learn_costs":{"2":1000,"3":2000,"4":4000,"5":7000,"6":10000,"7":20000},"max_spell_points":40,"spell_level":4,"spell_points":12,"spells":[{"code":"fire_bolt","combat_damage_max":6,"combat_damage_min":2,"combat_effect":"damage","description":"Hurls a mote of fire at the target.","heal_max":0,"heal_min":0,"id":"spell_fire_bolt","known":true,"name":"Fire Bolt","range":"short","spell_level":1,"spell_points":10}]}"#; + + const GOLDEN_W5: &str = r#"{"active_spell":"mage_armor","spell_points":12}"#; + const GOLDEN_W5_NULL: &str = r#"{"active_spell":null,"spell_points":12}"#; + + const GOLDEN_W6: &str = + r#"{"gold":19700,"learned_count":4,"max_spell_points":40,"spell_points":40}"#; + + #[test] + fn wire_identity_adventure_response() { + let dto: AdventureRunResponse = representative_outcome().into(); + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W1); + } + + #[test] + fn wire_identity_heal_response() { + let dto = HealResponse { + gold: 123, + healed: true, + hp: 45, + max_hp: 80, + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W2); + } + + fn representative_spell_entry() -> SpellEntryResponse { + SpellEntryResponse { + code: "fire_bolt".into(), + combat_damage_max: 6, + combat_damage_min: 2, + combat_effect: "damage".into(), + description: "Hurls a mote of fire at the target.".into(), + heal_max: 0, + heal_min: 0, + id: "spell_fire_bolt".into(), + known: true, + name: "Fire Bolt".into(), + range: "short".into(), + spell_level: 1, + spell_points: 10, + } + } + + #[test] + fn wire_identity_spell_entry_response() { + let dto = representative_spell_entry(); + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W3); + } + + #[test] + fn wire_identity_spellbook_response() { + let dto = SpellbookResponse { + caster_class: "mage".into(), + learn_costs: BTreeMap::from([ + ("2".into(), 1000), + ("3".into(), 2000), + ("4".into(), 4000), + ("5".into(), 7000), + ("6".into(), 10000), + ("7".into(), 20000), + ]), + max_spell_points: 40, + spell_level: 4, + spell_points: 12, + spells: vec![representative_spell_entry()], + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W4); + } + + #[test] + fn wire_identity_activate_spell_response() { + let dto = ActivateSpellResponse { + active_spell: Some("mage_armor".into()), + spell_points: 12, + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5); + + let dto = ActivateSpellResponse { + active_spell: None, + spell_points: 12, + }; + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W5_NULL); + } + + #[test] + fn wire_identity_learn_response() { + let dto = LearnSpellResponse::Learn(LearnSpellsResponse { + gold: 19700, + learned_count: 4, + max_spell_points: 40, + spell_points: 40, + }); + assert_eq!(serde_json::to_string(&dto).unwrap(), GOLDEN_W6); + } +} From f0659421e75b923a3c2afe3e5ab887ebec37ec73 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 12:08:21 +0200 Subject: [PATCH 6/7] sb-api: label helpers wiring + gym/stat_value + policy constants (WI-2, issue #2) --- crates/sb-api/src/routes/mod.rs | 81 +++++++++++++----------------- crates/sb-core/src/character.rs | 87 +++++++++++++++++++++++++++++++++ crates/sb-core/src/combat.rs | 5 ++ crates/sb-core/src/data.rs | 15 ++++++ crates/sb-core/src/items.rs | 10 ++++ crates/sb-core/src/spells.rs | 5 ++ crates/sb-shared/src/item.rs | 61 +++++++++++++++++++++++ 7 files changed, 217 insertions(+), 47 deletions(-) diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index 0021f0d..af76d61 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -4,7 +4,7 @@ use crate::middleware::auth::AuthUser; use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; -use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS}; +use sb_core::character::{self, CLASSES, GYM_STAT_DISPLAY_ORDER, RACES, TRAINING_GROUNDS}; use sb_core::data; use sb_core::items; use sb_core::services as domain; @@ -23,6 +23,16 @@ use tokio::sync::RwLock; mod responses; +const DEFAULT_ZONE_ID: &str = "skara_brae_streets"; +const CHARACTER_NAME_MAX_LEN: usize = 32; +const REGISTER_NAME_MIN_LEN: usize = 3; +const REGISTER_NAME_MAX_LEN: usize = 32; +const PASSWORD_MIN_LEN: usize = 6; +const PARTIAL_HEAL_FRACTION: f64 = 0.5; +const MIN_HEAL_COST: i32 = 1; +const SPELL_LEARN_COST_DISPLAY_MIN: i32 = 2; +const SPELL_LEARN_COST_DISPLAY_MAX: i32 = 7; + fn request_rng() -> rand::rngs::StdRng { use rand::SeedableRng; rand::rngs::StdRng::from_os_rng() @@ -314,7 +324,7 @@ async fn create_character( } let name = input.name.trim().to_string(); - if name.is_empty() || name.len() > 32 { + if name.is_empty() || name.len() > CHARACTER_NAME_MAX_LEN { return Err(axum::http::StatusCode::BAD_REQUEST); } @@ -450,7 +460,7 @@ async fn run_adventure( Path(char_id): Path, Query(query): Query, ) -> Result, axum::http::StatusCode> { - let zone_id = query.zone_id.as_deref().unwrap_or("skara_brae_streets"); + let zone_id = query.zone_id.as_deref().unwrap_or(DEFAULT_ZONE_ID); { let chars = state.characters.read().await; @@ -521,27 +531,18 @@ async fn get_gym( .ok_or(axum::http::StatusCode::NOT_FOUND)? .clone() }; - let stats = [ - ("strength", char.stats.strength), - ("intelligence", char.stats.intelligence), - ("dexterity", char.stats.dexterity), - ("constitution", char.stats.constitution), - ("luck", char.stats.luck), - ]; - let stat_responses: Vec = stats + let stat_responses: Vec = GYM_STAT_DISPLAY_ORDER .iter() - .map(|(name, val)| { - let ground = TRAINING_GROUNDS - .iter() - .find(|g| g.stat == *name) - .expect("valid stat"); - TrainingStatResponse { - stat: name.to_string(), + .filter_map(|stat_name| { + let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *stat_name)?; + let value = character::stat_value(&char.stats, stat_name)?; + Some(TrainingStatResponse { + stat: stat_name.to_string(), name: ground.name.to_string(), description: ground.description.to_string(), - value: *val, - cost: character::training_cost(*val), - } + value, + cost: character::training_cost(value), + }) }) .collect(); Ok(Json(GymResponse { @@ -630,16 +631,8 @@ async fn get_shop( .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: item.r#type.as_str().to_string(), + quality: item.quality.as_str().to_string(), slot: Some(item.slot.clone()), stats: item.stats.clone(), description: item.description.clone(), @@ -659,16 +652,8 @@ async fn get_shop( .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(), + item_type: item.r#type.as_str().to_string(), + quality: item.quality.as_str().to_string(), slot: Some(item.slot.clone()), stats: item.stats.clone(), description: item.description.clone(), @@ -859,11 +844,11 @@ async fn heal_character( let heal_amount = match input.amount.as_deref() { Some("full") => missing, - Some("partial") => (missing as f64 / 2.0).ceil() as i32, + Some("partial") => (missing as f64 * PARTIAL_HEAL_FRACTION).ceil() as i32, _ => missing, }; let actual = heal_amount.min(missing); - let cost = actual.max(1); + let cost = actual.max(MIN_HEAL_COST); if char.gold < cost { return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); @@ -1039,7 +1024,7 @@ async fn get_spells( spell_points: s.spell_points, }) .collect(); - let learn_costs = (2..=7) + let learn_costs = (SPELL_LEARN_COST_DISPLAY_MIN..=SPELL_LEARN_COST_DISPLAY_MAX) .map(|level| (level.to_string(), spells::spell_learn_cost(level))) .collect::>(); Ok(Json(responses::SpellbookResponse { @@ -1156,7 +1141,9 @@ async fn learn_spell_level( return Err(axum::http::StatusCode::FORBIDDEN); } - if !(1..=7).contains(&spell_level) { + if !(sb_core::constants::MIN_SPELL_LEVEL..=sb_core::constants::MAX_SPELL_LEVEL) + .contains(&spell_level) + { return Err(axum::http::StatusCode::BAD_REQUEST); } @@ -1228,10 +1215,10 @@ async fn register( State(state): State>, Json(input): Json, ) -> Result, axum::http::StatusCode> { - if input.name.len() < 3 || input.name.len() > 32 { + if input.name.len() < REGISTER_NAME_MIN_LEN || input.name.len() > REGISTER_NAME_MAX_LEN { return Err(axum::http::StatusCode::BAD_REQUEST); } - if input.password.len() < 6 { + if input.password.len() < PASSWORD_MIN_LEN { return Err(axum::http::StatusCode::BAD_REQUEST); } diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index aee34a3..fea0211 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -392,6 +392,27 @@ pub fn find_class_info(class: &Class) -> &'static ClassInfo { .expect("invalid class") } +/// Gym stat display order — pinned to the get_gym handler's fixed array +/// (routes/mod.rs:588–594), deliberately differs from TRAINING_GROUNDS order. +pub const GYM_STAT_DISPLAY_ORDER: &[&str] = &[ + "strength", + "intelligence", + "dexterity", + "constitution", + "luck", +]; + +pub fn stat_value(stats: &CharacterStats, stat: &str) -> Option { + match stat { + "strength" => Some(stats.strength), + "intelligence" => Some(stats.intelligence), + "dexterity" => Some(stats.dexterity), + "constitution" => Some(stats.constitution), + "luck" => Some(stats.luck), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -631,4 +652,70 @@ mod tests { assert_eq!(first.energy, second.energy); assert_eq!(first.nerve, second.nerve); } + + #[test] + fn gym_display_order_covers_all_training_grounds() { + let stats = CharacterStats { + strength: 10, + intelligence: 11, + dexterity: 12, + constitution: 13, + luck: 14, + }; + assert_eq!(GYM_STAT_DISPLAY_ORDER.len(), TRAINING_GROUNDS.len()); + for name in GYM_STAT_DISPLAY_ORDER { + assert!( + TRAINING_GROUNDS.iter().any(|g| g.stat == *name), + "no training ground for stat {}", + name + ); + assert!( + stat_value(&stats, name).is_some(), + "stat_value cannot resolve {}", + name + ); + } + for ground in TRAINING_GROUNDS { + assert!( + GYM_STAT_DISPLAY_ORDER.contains(&ground.stat), + "gym display order missing stat {}", + ground.stat + ); + } + } + + #[test] + fn race_table_covers_all_variants() { + let all = [ + Race::Human, + Race::Elf, + Race::Dwarf, + Race::Hobbit, + Race::HalfElf, + ]; + for race in &all { + assert!(RACES.iter().any(|r| &r.race == race)); + let _ = find_race_info(race); + } + } + + #[test] + fn class_table_covers_all_variants() { + let all = [ + Class::Warrior, + Class::Paladin, + Class::Rogue, + Class::Bard, + Class::Hunter, + Class::Monk, + Class::Mage, + Class::Wizard, + Class::Conjurer, + Class::Sorcerer, + ]; + for class in &all { + assert!(CLASSES.iter().any(|c| &c.class == class)); + let _ = find_class_info(class); + } + } } diff --git a/crates/sb-core/src/combat.rs b/crates/sb-core/src/combat.rs index 2d2d5f8..8f645fc 100644 --- a/crates/sb-core/src/combat.rs +++ b/crates/sb-core/src/combat.rs @@ -424,4 +424,9 @@ mod tests { assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush)); assert!(matches!(parse_attack_type("unknown"), AttackType::Crush)); } + + #[test] + fn crit_tables_parse() { + assert!(!CRIT_TABLES.is_empty()); + } } diff --git a/crates/sb-core/src/data.rs b/crates/sb-core/src/data.rs index fd73601..c7f6d99 100644 --- a/crates/sb-core/src/data.rs +++ b/crates/sb-core/src/data.rs @@ -59,3 +59,18 @@ pub fn load_enemies() -> Vec { pub fn load_zones() -> Vec { serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enemies_json_parses() { + assert!(!load_enemies().is_empty()); + } + + #[test] + fn zones_json_parses() { + assert!(!load_zones().is_empty()); + } +} diff --git a/crates/sb-core/src/items.rs b/crates/sb-core/src/items.rs index 603a740..a8ead26 100644 --- a/crates/sb-core/src/items.rs +++ b/crates/sb-core/src/items.rs @@ -29,3 +29,13 @@ pub fn item_price(item: &Item) -> i32 { pub fn item_sell_price(item: &Item) -> i32 { item.value / 2 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_items_json_parses() { + assert!(!load_all_items().is_empty()); + } +} diff --git a/crates/sb-core/src/spells.rs b/crates/sb-core/src/spells.rs index 843413c..939ba87 100644 --- a/crates/sb-core/src/spells.rs +++ b/crates/sb-core/src/spells.rs @@ -110,4 +110,9 @@ mod tests { ); } } + + #[test] + fn spells_json_parses() { + assert!(!load_spells().is_empty()); + } } diff --git a/crates/sb-shared/src/item.rs b/crates/sb-shared/src/item.rs index 466dde0..c9cce77 100644 --- a/crates/sb-shared/src/item.rs +++ b/crates/sb-shared/src/item.rs @@ -27,6 +27,18 @@ pub enum ItemType { Material, } +impl ItemType { + pub fn as_str(&self) -> &'static str { + match self { + ItemType::Weapon => "weapon", + ItemType::Armor => "armor", + ItemType::Accessory => "accessory", + ItemType::Consumable => "consumable", + ItemType::Material => "material", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum AttackType { @@ -44,3 +56,52 @@ pub enum Quality { Epic, Legendary, } + +impl Quality { + pub fn as_str(&self) -> &'static str { + match self { + Quality::Common => "common", + Quality::Uncommon => "uncommon", + Quality::Rare => "rare", + Quality::Epic => "epic", + Quality::Legendary => "legendary", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_type_as_str_matches_serde_wire() { + for variant in [ + ItemType::Weapon, + ItemType::Armor, + ItemType::Accessory, + ItemType::Consumable, + ItemType::Material, + ] { + assert_eq!( + serde_json::to_value(&variant).unwrap(), + serde_json::json!(variant.as_str()) + ); + } + } + + #[test] + fn quality_as_str_matches_serde_wire() { + for variant in [ + Quality::Common, + Quality::Uncommon, + Quality::Rare, + Quality::Epic, + Quality::Legendary, + ] { + assert_eq!( + serde_json::to_value(&variant).unwrap(), + serde_json::json!(variant.as_str()) + ); + } + } +} From c613142bd48bdeaba3e1d2dd774df4baf2c76f79 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 17:46:05 +0200 Subject: [PATCH 7/7] rework: A5 no-hit-seed rationale comment + spell-level range via use import (WI-2 2B findings, issue #2) --- crates/sb-api/src/routes/mod.rs | 5 ++--- crates/sb-core/src/adventure.rs | 3 +++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index af76d61..1ed49b1 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -5,6 +5,7 @@ use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; use sb_core::character::{self, CLASSES, GYM_STAT_DISPLAY_ORDER, RACES, TRAINING_GROUNDS}; +use sb_core::constants::{MAX_SPELL_LEVEL, MIN_SPELL_LEVEL}; use sb_core::data; use sb_core::items; use sb_core::services as domain; @@ -1141,9 +1142,7 @@ async fn learn_spell_level( return Err(axum::http::StatusCode::FORBIDDEN); } - if !(sb_core::constants::MIN_SPELL_LEVEL..=sb_core::constants::MAX_SPELL_LEVEL) - .contains(&spell_level) - { + if !(MIN_SPELL_LEVEL..=MAX_SPELL_LEVEL).contains(&spell_level) { return Err(axum::http::StatusCode::BAD_REQUEST); } diff --git a/crates/sb-core/src/adventure.rs b/crates/sb-core/src/adventure.rs index 320276f..32b44ea 100644 --- a/crates/sb-core/src/adventure.rs +++ b/crates/sb-core/src/adventure.rs @@ -374,6 +374,9 @@ mod tests { let now_ms = 10 * constants::HP_REGEN_INTERVAL_MS; let mut enemy = weak_enemy(); enemy.damage = 0; + // Seed 42 was verified at fixture-recording time to yield zero enemy + // hits (the character kills the damaged-0 enemy in round 1), so the + // hp == 80 assertion holds: design §7 A5 no-hit-seed rationale. let outcome = run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), now_ms).unwrap(); assert_eq!(c.last_regen, now_ms);