diff --git a/crates/sb-api/src/main.rs b/crates/sb-api/src/main.rs index fd6369d..bc673b3 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 fb6ce61..5db3365 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::{StatusCode, header}, + http::{header, StatusCode}, middleware::Next, response::Response, }; @@ -13,14 +13,19 @@ 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 8b13789..e69de29 100644 --- a/crates/sb-api/src/middleware/rate_limit.rs +++ b/crates/sb-api/src/middleware/rate_limit.rs @@ -1 +0,0 @@ - diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index 1ed49b1..343fc10 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -1,67 +1,35 @@ #![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 sb_core::character::{self, CLASSES, GYM_STAT_DISPLAY_ORDER, RACES, TRAINING_GROUNDS}; -use sb_core::constants::{MAX_SPELL_LEVEL, MIN_SPELL_LEVEL}; +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::data; -use sb_core::items; use sb_core::services as domain; +use sb_core::items; use sb_core::songs; use sb_core::spells; use sb_shared::{ - 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, + 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, }; -use std::collections::BTreeMap; use std::sync::Arc; 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() } -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, @@ -125,10 +93,7 @@ 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)) @@ -137,10 +102,7 @@ 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, @@ -294,16 +256,15 @@ async fn get_characters( if user_id != auth.user_id && auth.role != "admin" { return Err(axum::http::StatusCode::FORBIDDEN); } - let now = now_unix_ms(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; let mut chars = state.characters.write().await; 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)) } @@ -325,7 +286,7 @@ async fn create_character( } let name = input.name.trim().to_string(); - if name.is_empty() || name.len() > CHARACTER_NAME_MAX_LEN { + if name.is_empty() || name.len() > 32 { return Err(axum::http::StatusCode::BAD_REQUEST); } @@ -356,17 +317,16 @@ 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 = now_unix_ms(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .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())) @@ -383,10 +343,7 @@ 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)?; { @@ -394,21 +351,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).map_err(domain_status)?; + domain::equip_item(char, item, &input.item_id) + .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; char.clone() }; @@ -433,20 +387,17 @@ 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(domain_status)?; + domain::unequip_item(char, &input.slot) + .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; char.clone() }; @@ -460,52 +411,159 @@ async fn run_adventure( Extension(auth): Extension, Path(char_id): Path, Query(query): Query, -) -> Result, axum::http::StatusCode> { - let zone_id = query.zone_id.as_deref().unwrap_or(DEFAULT_ZONE_ID); +) -> Result, axum::http::StatusCode> { + let zone_id = query.zone_id.as_deref().unwrap_or("skara_brae_streets"); { let chars = state.characters.read().await; 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_ms = now_unix_ms(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let mut rng = request_rng(); - let (outcome, updated) = { + let (char, enemy_def) = { let mut chars = state.characters.write().await; - let character = 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 outcome = sb_core::adventure::run_adventure( - &mut rng, - character, - zone, - &state.enemies, - &state.items, - now_ms, - ) - .map_err(domain_status)?; - (outcome, character.clone()) + 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); + (char.clone(), enemy) }; - persist_character(&state, &updated).await; + 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); - Ok(Json(outcome.into())) + 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, + }))) } async fn get_gym( @@ -518,34 +576,32 @@ 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 stat_responses: Vec = GYM_STAT_DISPLAY_ORDER - .iter() - .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, - cost: character::training_cost(value), - }) - }) - .collect(); + 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.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, @@ -570,24 +626,27 @@ 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 = now_unix_ms(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .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(domain_status)?; + 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) }; @@ -610,60 +669,48 @@ 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: item.r#type.as_str().to_string(), - quality: item.quality.as_str().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(); - 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: 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(), - attack_type: item.attack_type.as_ref().map(|t| format!("{:?}", t)), - value: item.value, - equipped: false, - }) - }) - .collect(); + }).collect(); Ok(Json(ShopResponse { gold: char.gold, inventory: bag_items, @@ -688,10 +735,8 @@ 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() { @@ -706,19 +751,14 @@ 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); @@ -739,9 +779,7 @@ 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()); @@ -765,25 +803,17 @@ 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); @@ -819,23 +849,19 @@ 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); 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; @@ -845,11 +871,11 @@ async fn heal_character( let heal_amount = match input.amount.as_deref() { Some("full") => missing, - Some("partial") => (missing as f64 * PARTIAL_HEAL_FRACTION).ceil() as i32, + Some("partial") => (missing as f64 / 2.0).ceil() as i32, _ => missing, }; let actual = heal_amount.min(missing); - let cost = actual.max(MIN_HEAL_COST); + let cost = actual.max(1); if char.gold < cost { return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); @@ -862,12 +888,12 @@ async fn heal_character( }; persist_character(&state, &updated).await; - Ok(Json(responses::HealResponse { - gold: updated.gold, - healed: true, - hp: updated.hp, - max_hp: updated.max_hp, - })) + Ok(Json(serde_json::json!({ + "hp": updated.hp, + "max_hp": updated.max_hp, + "gold": updated.gold, + "healed": true, + }))) } async fn get_songs( @@ -880,34 +906,28 @@ 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, @@ -930,17 +950,13 @@ 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 { @@ -948,13 +964,9 @@ 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); @@ -969,10 +981,7 @@ 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(), })) } @@ -981,61 +990,59 @@ 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); 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) { return Err(axum::http::StatusCode::FORBIDDEN); } - let spell_level = spells::caster_spell_level(char.level); + 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| 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, + 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(); - 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 { - caster_class: class_str, - learn_costs, - max_spell_points: char.max_spell_points, - spell_level, - spell_points: char.spell_points, - spells: spells_json, - })) + }).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), + }, + }))) } #[derive(serde::Deserialize)] @@ -1044,6 +1051,7 @@ struct SpellRequest { spell_id: Option, } + async fn level_up( State(state): State>, Extension(auth): Extension, @@ -1054,21 +1062,23 @@ 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(), - Err(e) => return Err(domain_status(e)), + Err(msg) => { + let status = match msg { + "not enough XP" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + _ => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + }; + return Err(status); + } } }; persist_character(&state, &updated).await; @@ -1084,24 +1094,20 @@ 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); 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; @@ -1119,22 +1125,17 @@ async fn learn_spell_level( char.clone() }; persist_character(&state, &updated).await; - return Ok(Json(responses::LearnSpellResponse::Activate( - responses::ActivateSpellResponse { - active_spell: updated.active_spell, - spell_points: updated.spell_points, - }, - ))); + return Ok(Json(serde_json::json!({ + "active_spell": updated.active_spell, + "spell_points": updated.spell_points, + }))); } - 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(); @@ -1142,11 +1143,11 @@ async fn learn_spell_level( return Err(axum::http::StatusCode::FORBIDDEN); } - if !(MIN_SPELL_LEVEL..=MAX_SPELL_LEVEL).contains(&spell_level) { + if !(1..=7).contains(&spell_level) { return Err(axum::http::StatusCode::BAD_REQUEST); } - let current_level = spells::caster_spell_level(char.level); + let current_level = (char.level / 3) + 1; if spell_level > current_level { return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); } @@ -1157,8 +1158,7 @@ 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(); @@ -1180,14 +1180,12 @@ async fn learn_spell_level( }; persist_character(&state, &updated).await; - 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, - }, - ))) + 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, + }))) } #[derive(serde::Deserialize)] @@ -1214,10 +1212,10 @@ async fn register( State(state): State>, Json(input): Json, ) -> Result, axum::http::StatusCode> { - if input.name.len() < REGISTER_NAME_MIN_LEN || input.name.len() > REGISTER_NAME_MAX_LEN { + if input.name.len() < 3 || input.name.len() > 32 { return Err(axum::http::StatusCode::BAD_REQUEST); } - if input.password.len() < PASSWORD_MIN_LEN { + if input.password.len() < 6 { return Err(axum::http::StatusCode::BAD_REQUEST); } @@ -1258,9 +1256,7 @@ 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) { @@ -1286,30 +1282,28 @@ 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) + 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 + } } - 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| { @@ -1331,18 +1325,8 @@ 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/routes/responses.rs b/crates/sb-api/src/routes/responses.rs deleted file mode 100644 index 98ce1c5..0000000 --- a/crates/sb-api/src/routes/responses.rs +++ /dev/null @@ -1,239 +0,0 @@ -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); - } -} diff --git a/crates/sb-api/src/ws/channels.rs b/crates/sb-api/src/ws/channels.rs index 8b13789..e69de29 100644 --- a/crates/sb-api/src/ws/channels.rs +++ b/crates/sb-api/src/ws/channels.rs @@ -1 +0,0 @@ - diff --git a/crates/sb-api/src/ws/handler.rs b/crates/sb-api/src/ws/handler.rs index 8b13789..e69de29 100644 --- a/crates/sb-api/src/ws/handler.rs +++ b/crates/sb-api/src/ws/handler.rs @@ -1 +0,0 @@ - diff --git a/crates/sb-api/src/ws/mod.rs b/crates/sb-api/src/ws/mod.rs index bcfc7b4..42432de 100644 --- a/crates/sb-api/src/ws/mod.rs +++ b/crates/sb-api/src/ws/mod.rs @@ -1,2 +1,2 @@ -pub mod channels; pub mod handler; +pub mod channels; diff --git a/crates/sb-auth/src/hash.rs b/crates/sb-auth/src/hash.rs index f7d133b..e67a1ea 100644 --- a/crates/sb-auth/src/hash.rs +++ b/crates/sb-auth/src/hash.rs @@ -1,6 +1,6 @@ use argon2::{ - Argon2, password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, }; pub fn hash_password(password: &str) -> Result { @@ -17,7 +17,5 @@ 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 86b0c13..3745c22 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,11 +16,7 @@ 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") @@ -43,6 +39,9 @@ pub fn create_token( pub fn verify_token(token: &str) -> 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/adventure.rs b/crates/sb-core/src/adventure.rs deleted file mode 100644 index 32b44ea..0000000 --- a/crates/sb-core/src/adventure.rs +++ /dev/null @@ -1,473 +0,0 @@ -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; - // 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); - 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/character.rs b/crates/sb-core/src/character.rs index fea0211..927fe04 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -1,7 +1,5 @@ -use crate::constants; -use crate::error::DomainError; -use crate::spells; use sb_shared::{Character, CharacterStats, Class, Race}; +use crate::spells; pub struct TrainingGround { pub stat: &'static str, @@ -10,31 +8,11 @@ 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 { @@ -48,51 +26,11 @@ 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 { @@ -105,102 +43,29 @@ 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 { - (stat - constants::STAT_BONUS_NEUTRAL) / constants::STAT_BONUS_STEP + (stat - 20) / 2 } fn roll_stat(rng: &mut impl rand::Rng, modifier: i64) -> i64 { - let base: i64 = rng.random_range(constants::STAT_ROLL_MIN..=constants::STAT_ROLL_MAX); + let base: i64 = rng.random_range(6..=16); base + modifier } 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), @@ -211,46 +76,35 @@ 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"); - constants::BASE_MAX_ENERGY + info.base_hp / constants::MAX_ENERGY_PER_BASE_HP_DIVISOR + 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"); - constants::BASE_MAX_NERVE + info.base_hp / constants::MAX_NERVE_PER_BASE_HP_DIVISOR + let info = CLASSES.iter().find(|c| &c.class == class).expect("invalid class"); + 100 + info.base_hp / 2 } pub fn energy_regen_rate() -> i64 { - constants::ENERGY_REGEN_INTERVAL_MS + 180000 } pub fn nerve_regen_rate() -> i64 { - constants::NERVE_REGEN_INTERVAL_MS + 300000 } pub fn hp_regen_rate() -> i64 { - constants::HP_REGEN_INTERVAL_MS + 300000 } 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; @@ -261,16 +115,15 @@ 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 * constants::HP_REGEN_FRACTION_PER_TICK).ceil() as i32; + let regen_per_tick = (char.max_hp as f64 * 0.05).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 / constants::TRAINING_STAT_SCALER).floor() as i32; - (constants::TRAINING_BASE_COST * constants::TRAINING_GROWTH_FACTOR.powi(scaled)) as i32 + let scaled = (stat as f64 / 10.0).floor() as i32; + (5.0_f64 * 1.3_f64.powi(scaled)) as i32 } pub fn create_character( @@ -287,8 +140,7 @@ 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() @@ -302,7 +154,7 @@ pub fn create_character( name, race, class, - level: constants::STARTING_LEVEL, + level: 1, xp: 0, stats, hp: max_hp, @@ -311,7 +163,7 @@ pub fn create_character( max_energy, nerve: max_nerve, max_nerve, - gold: constants::STARTING_GOLD, + gold: 50, last_regen: 0, equipment: [ ("head".into(), None), @@ -325,9 +177,7 @@ 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, @@ -341,28 +191,26 @@ 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 * (constants::XP_CURVE_COEFFICIENT * n * n) as i64 + XP_FACTOR * (100.0 * n * n) as i64 } pub fn level_up_cost(level: i32) -> i32 { - constants::LEVEL_UP_GOLD_PER_LEVEL * level + 500 * level } -pub fn level_up(char: &mut Character) -> Result { +pub fn level_up(char: &mut Character) -> Result { if char.level >= MAX_LEVEL { - return Err(DomainError::AlreadyAtMaxLevel); + return Err("already at max level"); } let next_level = char.level + 1; if char.xp < xp_for_level(next_level) { - return Err(DomainError::NotEnoughXp); + return Err("not enough XP"); } let cost = level_up_cost(next_level); if char.gold < cost { - return Err(DomainError::NotEnoughGold); + return Err("not enough gold"); } char.gold -= cost; char.level = next_level; @@ -379,38 +227,11 @@ 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") -} - -/// 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, - } + CLASSES.iter().find(|c| &c.class == class).expect("invalid class") } #[cfg(test)] @@ -421,35 +242,15 @@ 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, } } @@ -505,7 +306,7 @@ mod tests { c.level = MAX_LEVEL; c.xp = i64::MAX; c.gold = i32::MAX; - assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel)); + assert_eq!(level_up(&mut c), Err("already at max level")); } #[test] @@ -534,7 +335,7 @@ mod tests { let mut c = base_char(); c.xp = 100; c.gold = 10000; - assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp)); + assert_eq!(level_up(&mut c), Err("not enough XP")); assert_eq!(c.level, 1); assert_eq!(c.gold, 10000); } @@ -544,26 +345,14 @@ mod tests { let mut c = base_char(); c.xp = 1800; c.gold = 0; - assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold)); + assert_eq!(level_up(&mut c), Err("not enough gold")); assert_eq!(c.level, 1); } #[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); @@ -607,7 +396,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); @@ -619,7 +408,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); @@ -652,70 +441,4 @@ 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 8f645fc..7894cd5 100644 --- a/crates/sb-core/src/combat.rs +++ b/crates/sb-core/src/combat.rs @@ -1,4 +1,3 @@ -use crate::constants; use rand::Rng; use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; use serde::{Deserialize, Serialize}; @@ -8,8 +7,9 @@ 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 { @@ -33,8 +33,7 @@ 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) - / constants::DEFENSIVE_CON_BONUS_DIVISOR; + let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2; dex_bonus + con_bonus + self.db_base } } @@ -54,27 +53,21 @@ pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i3 return None; } let cap_pct = match diff { - 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, + 1..=9 => return None, + 10..=19 => 0.60, + 20..=49 => 0.40, + 50..=99 => 0.15, + _ => 0.05, }; 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(constants::ATTACK_ROLL_MIN..=constants::ATTACK_ROLL_MAX); + let roll: i32 = rng.random_range(1..=100); let mut total = roll; - if roll >= constants::OPEN_END_HIGH_THRESHOLD { + if roll >= 96 { total += open_ended_roll(rng); - } else if roll <= constants::OPEN_END_LOW_THRESHOLD { + } else if roll <= 5 { total -= open_ended_roll(rng); } total @@ -100,12 +93,11 @@ 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(), @@ -128,13 +120,13 @@ pub fn resolve_attack( } pub fn crit_severity(result: i32) -> Option { - if result >= constants::CRIT_SEVERITY_D_MIN { + if result >= 151 { Some("D".into()) - } else if result >= constants::CRIT_SEVERITY_C_MIN { + } else if result >= 101 { Some("C".into()) - } else if result >= constants::CRIT_SEVERITY_B_MIN { + } else if result >= 51 { Some("B".into()) - } else if result >= constants::CRIT_SEVERITY_A_MIN { + } else if result >= 21 { Some("A".into()) } else { None @@ -163,9 +155,7 @@ 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( @@ -177,16 +167,7 @@ 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)] @@ -206,11 +187,7 @@ 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" @@ -234,11 +211,7 @@ 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); @@ -254,14 +227,12 @@ pub fn resolve_combat_full( } if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { - let heal_amt = (attacker.hp as f64 * constants::BARD_SONG_HEAL_FRACTION).ceil() as i32; + let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32; a_hp = (a_hp + heal_amt).min(attacker.hp); } 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); @@ -272,12 +243,8 @@ 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 += constants::SONG_ACCURACY_OB_BONUS; - } - Some("damage") => { - s.ob_base += constants::SONG_DAMAGE_OB_BONUS; - } + Some("accuracy") => { s.ob_base += 15; } + Some("damage") => { s.ob_base += 20; } _ => {} } s @@ -286,12 +253,8 @@ 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 += constants::SONG_DEFENSE_DB_BONUS; - } - Some("weaken") => { - s.ob_base = (s.ob_base as f64 * constants::SONG_WEAKEN_OB_FACTOR) 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 @@ -309,30 +272,18 @@ 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, } } @@ -343,11 +294,7 @@ 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); } } @@ -417,16 +364,8 @@ 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)); } - - #[test] - fn crit_tables_parse() { - assert!(!CRIT_TABLES.is_empty()); - } } diff --git a/crates/sb-core/src/constants.rs b/crates/sb-core/src/constants.rs deleted file mode 100644 index 4a410e4..0000000 --- a/crates/sb-core/src/constants.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! 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/data.rs b/crates/sb-core/src/data.rs index c7f6d99..389c801 100644 --- a/crates/sb-core/src/data.rs +++ b/crates/sb-core/src/data.rs @@ -1,7 +1,9 @@ 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 { @@ -59,18 +61,3 @@ 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/error.rs b/crates/sb-core/src/error.rs deleted file mode 100644 index 85e45c3..0000000 --- a/crates/sb-core/src/error.rs +++ /dev/null @@ -1,23 +0,0 @@ -#[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/items.rs b/crates/sb-core/src/items.rs index a8ead26..e1ef7a6 100644 --- a/crates/sb-core/src/items.rs +++ b/crates/sb-core/src/items.rs @@ -1,7 +1,9 @@ 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"); @@ -12,7 +14,8 @@ 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 = @@ -29,13 +32,3 @@ 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/lib.rs b/crates/sb-core/src/lib.rs index aa80266..813f904 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -1,11 +1,8 @@ #![allow(dead_code)] -pub mod adventure; 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 ceee6a2..6aae401 100644 --- a/crates/sb-core/src/services.rs +++ b/crates/sb-core/src/services.rs @@ -1,43 +1,29 @@ -use crate::data::{EncounterEntry, EnemyDef}; -use crate::error::DomainError; +use sb_shared::{Character, Item, AttackType}; +use crate::data::{EnemyDef, EncounterEntry}; use rand::Rng; -use sb_shared::{AttackType, Character, Item}; pub fn pick_enemy( rng: &mut impl Rng, encounters: &[EncounterEntry], enemies: &[EnemyDef], -) -> Result { +) -> EnemyDef { 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 { remaining -= enc.weight; if remaining < 0 { - return enemies - .iter() - .find(|e| e.id == enc.enemy_id) - .cloned() - .ok_or(DomainError::UnknownEncounterEnemy); + return enemies.iter().find(|e| e.id == enc.enemy_id) + .expect("encounter references valid enemy") + .clone(); } } - let last = encounters - .last() - .ok_or(DomainError::UnknownEncounterEnemy)?; - enemies - .iter() - .find(|e| e.id == last.enemy_id) - .cloned() - .ok_or(DomainError::UnknownEncounterEnemy) + 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 @@ -47,10 +33,7 @@ pub fn equipped_attack_type( AttackType::Crush } -pub fn equipped_damage( - 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) @@ -63,10 +46,7 @@ pub fn equipped_damage( total } -pub fn total_defense( - 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 @@ -79,12 +59,9 @@ pub fn total_defense( total } -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(DomainError::ItemNotInInventory)?; +pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> { + let inv_pos = char.inventory.iter().position(|id| id == item_id) + .ok_or("item not in inventory")?; char.inventory.remove(inv_pos); let slot = item.slot.clone(); @@ -95,20 +72,18 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<() Ok(()) } -pub fn unequip_item(char: &mut Character, slot: &str) -> Result { - let prev = char - .equipment - .insert(slot.to_string(), None) +pub fn unequip_item(char: &mut Character, slot: &str) -> Result { + let prev = char.equipment.insert(slot.to_string(), None) .flatten() - .ok_or(DomainError::NothingEquippedInSlot)?; + .ok_or("nothing equipped in slot")?; char.inventory.push(prev.clone()); Ok(prev) } -pub fn train_stat(char: &mut Character, stat: &str) -> Result { - if char.energy < crate::constants::TRAINING_ENERGY_COST { - return Err(DomainError::NotEnoughEnergy); +pub fn train_stat(char: &mut Character, stat: &str) -> Result { + if char.energy < 1 { + return Err("not enough energy"); } let current = match stat { "strength" => char.stats.strength, @@ -116,13 +91,13 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result "dexterity" => char.stats.dexterity, "constitution" => char.stats.constitution, "luck" => char.stats.luck, - _ => return Err(DomainError::InvalidStat), + _ => return Err("invalid stat"), }; let cost = super::character::training_cost(current); if char.gold < cost { - return Err(DomainError::NotEnoughGold); + return Err("not enough gold"); } - char.energy -= crate::constants::TRAINING_ENERGY_COST; + char.energy -= 1; char.gold -= cost; match stat { "strength" => char.stats.strength += 1, @@ -142,7 +117,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(DomainError::InvalidStat), + _ => return Err("invalid stat"), }; Ok(new_val) } @@ -150,70 +125,42 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result #[cfg(test)] mod tests { use super::*; - use sb_shared::{Character, CharacterStats, Class, Item, ItemType, Quality, Race}; + use sb_shared::{Character, CharacterStats, Class, Race, Item, Quality, ItemType}; 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, } } @@ -232,29 +179,20 @@ mod tests { fn train_stat_fails_without_energy() { let mut c = base_char(); c.energy = 0; - assert_eq!( - train_stat(&mut c, "strength"), - Err(DomainError::NotEnoughEnergy) - ); + assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy")); } #[test] fn train_stat_fails_without_gold() { let mut c = base_char(); c.gold = 0; - assert_eq!( - train_stat(&mut c, "strength"), - Err(DomainError::NotEnoughGold) - ); + assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold")); } #[test] fn train_stat_fails_invalid_stat() { let mut c = base_char(); - assert_eq!( - train_stat(&mut c, "charisma"), - Err(DomainError::InvalidStat) - ); + assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat")); } #[test] @@ -273,23 +211,16 @@ 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())); } @@ -297,17 +228,13 @@ 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(DomainError::ItemNotInInventory) - ); + 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())); @@ -317,10 +244,7 @@ mod tests { #[test] fn unequip_item_fails_empty_slot() { let mut c = base_char(); - assert_eq!( - unequip_item(&mut c, "main_hand"), - Err(DomainError::NothingEquippedInSlot) - ); + assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot")); } #[test] @@ -346,84 +270,49 @@ 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![], }, ]; let mut rng = rand::rngs::StdRng::seed_from_u64(42); for _ in 0..100 { - let enemy = pick_enemy(&mut rng, &encounters, &enemies).unwrap(); + let enemy = pick_enemy(&mut rng, &encounters, &enemies); assert!(enemy.id == "kobold" || enemy.id == "rat"); } } diff --git a/crates/sb-core/src/songs.rs b/crates/sb-core/src/songs.rs index 56886f2..8768eb9 100644 --- a/crates/sb-core/src/songs.rs +++ b/crates/sb-core/src/songs.rs @@ -55,8 +55,5 @@ 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 939ba87..e6042bc 100644 --- a/crates/sb-core/src/spells.rs +++ b/crates/sb-core/src/spells.rs @@ -1,4 +1,3 @@ -use crate::constants; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -19,7 +18,8 @@ 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,10 +31,9 @@ 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> { @@ -42,31 +41,29 @@ pub fn find_spell(id: &str) -> Option<&'static SpellDef> { } pub fn spell_learn_cost(spell_level: i32) -> i32 { - 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 + match spell_level { + 1 => 0, + 2 => 1000, + 3 => 2000, + 4 => 4000, + 5 => 7000, + 6 => 10000, + 7 => 20000, + _ => 999999, } } -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" => caster_spell_level(level), + "conjurer" | "magician" | "sorcerer" | "wizard" => (level / 3) + 1, _ => 0, }; - spell_level * constants::SPELL_POINTS_PER_SPELL_LEVEL + spell_level * 10 } 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] { @@ -75,44 +72,3 @@ 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] - ); - } - } - - #[test] - fn spells_json_parses() { - assert!(!load_spells().is_empty()); - } -} diff --git a/crates/sb-db/src/lib.rs b/crates/sb-db/src/lib.rs index 510169a..9c62f9d 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, Role, User}; +use sb_shared::{Character, CharacterStats, Class, Race, User, Role}; use std::collections::HashMap; use std::path::Path; use std::sync::Mutex; @@ -11,9 +11,7 @@ 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) } @@ -61,7 +59,7 @@ impl Database { quantity INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_characters_user ON characters(user_id); - ", + " )?; Ok(()) } @@ -116,15 +114,10 @@ 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)?; @@ -184,32 +177,15 @@ 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, ], )?; @@ -241,9 +217,11 @@ 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 08cccdd..89440dd 100644 --- a/crates/sb-shared/src/character.rs +++ b/crates/sb-shared/src/character.rs @@ -131,12 +131,8 @@ 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/item.rs b/crates/sb-shared/src/item.rs index c9cce77..466dde0 100644 --- a/crates/sb-shared/src/item.rs +++ b/crates/sb-shared/src/item.rs @@ -27,18 +27,6 @@ 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 { @@ -56,52 +44,3 @@ 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()) - ); - } - } -} diff --git a/crates/sb-shared/src/responses.rs b/crates/sb-shared/src/responses.rs index 82ffa5d..58af405 100644 --- a/crates/sb-shared/src/responses.rs +++ b/crates/sb-shared/src/responses.rs @@ -1,5 +1,5 @@ -use crate::character::Character; use serde::{Deserialize, Serialize}; +use crate::character::Character; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CritEntry {