diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index 0021f0d..af76d61 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -4,7 +4,7 @@ use crate::middleware::auth::AuthUser; use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Extension, Json, Router}; -use sb_core::character::{self, CLASSES, RACES, TRAINING_GROUNDS}; +use sb_core::character::{self, CLASSES, GYM_STAT_DISPLAY_ORDER, RACES, TRAINING_GROUNDS}; use sb_core::data; use sb_core::items; use sb_core::services as domain; @@ -23,6 +23,16 @@ use tokio::sync::RwLock; mod responses; +const DEFAULT_ZONE_ID: &str = "skara_brae_streets"; +const CHARACTER_NAME_MAX_LEN: usize = 32; +const REGISTER_NAME_MIN_LEN: usize = 3; +const REGISTER_NAME_MAX_LEN: usize = 32; +const PASSWORD_MIN_LEN: usize = 6; +const PARTIAL_HEAL_FRACTION: f64 = 0.5; +const MIN_HEAL_COST: i32 = 1; +const SPELL_LEARN_COST_DISPLAY_MIN: i32 = 2; +const SPELL_LEARN_COST_DISPLAY_MAX: i32 = 7; + fn request_rng() -> rand::rngs::StdRng { use rand::SeedableRng; rand::rngs::StdRng::from_os_rng() @@ -314,7 +324,7 @@ async fn create_character( } let name = input.name.trim().to_string(); - if name.is_empty() || name.len() > 32 { + if name.is_empty() || name.len() > CHARACTER_NAME_MAX_LEN { return Err(axum::http::StatusCode::BAD_REQUEST); } @@ -450,7 +460,7 @@ async fn run_adventure( Path(char_id): Path, Query(query): Query, ) -> Result, axum::http::StatusCode> { - let zone_id = query.zone_id.as_deref().unwrap_or("skara_brae_streets"); + let zone_id = query.zone_id.as_deref().unwrap_or(DEFAULT_ZONE_ID); { let chars = state.characters.read().await; @@ -521,27 +531,18 @@ async fn get_gym( .ok_or(axum::http::StatusCode::NOT_FOUND)? .clone() }; - let stats = [ - ("strength", char.stats.strength), - ("intelligence", char.stats.intelligence), - ("dexterity", char.stats.dexterity), - ("constitution", char.stats.constitution), - ("luck", char.stats.luck), - ]; - let stat_responses: Vec = stats + let stat_responses: Vec = GYM_STAT_DISPLAY_ORDER .iter() - .map(|(name, val)| { - let ground = TRAINING_GROUNDS - .iter() - .find(|g| g.stat == *name) - .expect("valid stat"); - TrainingStatResponse { - stat: name.to_string(), + .filter_map(|stat_name| { + let ground = TRAINING_GROUNDS.iter().find(|g| g.stat == *stat_name)?; + let value = character::stat_value(&char.stats, stat_name)?; + Some(TrainingStatResponse { + stat: stat_name.to_string(), name: ground.name.to_string(), description: ground.description.to_string(), - value: *val, - cost: character::training_cost(*val), - } + value, + cost: character::training_cost(value), + }) }) .collect(); Ok(Json(GymResponse { @@ -630,16 +631,8 @@ async fn get_shop( .map(|item| ShopItemResponse { id: item.id.clone(), name: item.name.clone(), - item_type: serde_json::to_value(&item.r#type) - .unwrap() - .as_str() - .unwrap() - .to_string(), - quality: serde_json::to_value(&item.quality) - .unwrap() - .as_str() - .unwrap() - .to_string(), + item_type: item.r#type.as_str().to_string(), + quality: item.quality.as_str().to_string(), slot: Some(item.slot.clone()), stats: item.stats.clone(), description: item.description.clone(), @@ -659,16 +652,8 @@ async fn get_shop( .map(|item| InventoryItemResponse { id: item.id.clone(), name: item.name.clone(), - item_type: serde_json::to_value(&item.r#type) - .unwrap() - .as_str() - .unwrap() - .to_string(), - quality: serde_json::to_value(&item.quality) - .unwrap() - .as_str() - .unwrap() - .to_string(), + item_type: item.r#type.as_str().to_string(), + quality: item.quality.as_str().to_string(), slot: Some(item.slot.clone()), stats: item.stats.clone(), description: item.description.clone(), @@ -859,11 +844,11 @@ async fn heal_character( let heal_amount = match input.amount.as_deref() { Some("full") => missing, - Some("partial") => (missing as f64 / 2.0).ceil() as i32, + Some("partial") => (missing as f64 * PARTIAL_HEAL_FRACTION).ceil() as i32, _ => missing, }; let actual = heal_amount.min(missing); - let cost = actual.max(1); + let cost = actual.max(MIN_HEAL_COST); if char.gold < cost { return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY); @@ -1039,7 +1024,7 @@ async fn get_spells( spell_points: s.spell_points, }) .collect(); - let learn_costs = (2..=7) + let learn_costs = (SPELL_LEARN_COST_DISPLAY_MIN..=SPELL_LEARN_COST_DISPLAY_MAX) .map(|level| (level.to_string(), spells::spell_learn_cost(level))) .collect::>(); Ok(Json(responses::SpellbookResponse { @@ -1156,7 +1141,9 @@ async fn learn_spell_level( return Err(axum::http::StatusCode::FORBIDDEN); } - if !(1..=7).contains(&spell_level) { + if !(sb_core::constants::MIN_SPELL_LEVEL..=sb_core::constants::MAX_SPELL_LEVEL) + .contains(&spell_level) + { return Err(axum::http::StatusCode::BAD_REQUEST); } @@ -1228,10 +1215,10 @@ async fn register( State(state): State>, Json(input): Json, ) -> Result, axum::http::StatusCode> { - if input.name.len() < 3 || input.name.len() > 32 { + if input.name.len() < REGISTER_NAME_MIN_LEN || input.name.len() > REGISTER_NAME_MAX_LEN { return Err(axum::http::StatusCode::BAD_REQUEST); } - if input.password.len() < 6 { + if input.password.len() < PASSWORD_MIN_LEN { return Err(axum::http::StatusCode::BAD_REQUEST); } diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index aee34a3..fea0211 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -392,6 +392,27 @@ pub fn find_class_info(class: &Class) -> &'static ClassInfo { .expect("invalid class") } +/// Gym stat display order — pinned to the get_gym handler's fixed array +/// (routes/mod.rs:588–594), deliberately differs from TRAINING_GROUNDS order. +pub const GYM_STAT_DISPLAY_ORDER: &[&str] = &[ + "strength", + "intelligence", + "dexterity", + "constitution", + "luck", +]; + +pub fn stat_value(stats: &CharacterStats, stat: &str) -> Option { + match stat { + "strength" => Some(stats.strength), + "intelligence" => Some(stats.intelligence), + "dexterity" => Some(stats.dexterity), + "constitution" => Some(stats.constitution), + "luck" => Some(stats.luck), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -631,4 +652,70 @@ mod tests { assert_eq!(first.energy, second.energy); assert_eq!(first.nerve, second.nerve); } + + #[test] + fn gym_display_order_covers_all_training_grounds() { + let stats = CharacterStats { + strength: 10, + intelligence: 11, + dexterity: 12, + constitution: 13, + luck: 14, + }; + assert_eq!(GYM_STAT_DISPLAY_ORDER.len(), TRAINING_GROUNDS.len()); + for name in GYM_STAT_DISPLAY_ORDER { + assert!( + TRAINING_GROUNDS.iter().any(|g| g.stat == *name), + "no training ground for stat {}", + name + ); + assert!( + stat_value(&stats, name).is_some(), + "stat_value cannot resolve {}", + name + ); + } + for ground in TRAINING_GROUNDS { + assert!( + GYM_STAT_DISPLAY_ORDER.contains(&ground.stat), + "gym display order missing stat {}", + ground.stat + ); + } + } + + #[test] + fn race_table_covers_all_variants() { + let all = [ + Race::Human, + Race::Elf, + Race::Dwarf, + Race::Hobbit, + Race::HalfElf, + ]; + for race in &all { + assert!(RACES.iter().any(|r| &r.race == race)); + let _ = find_race_info(race); + } + } + + #[test] + fn class_table_covers_all_variants() { + let all = [ + Class::Warrior, + Class::Paladin, + Class::Rogue, + Class::Bard, + Class::Hunter, + Class::Monk, + Class::Mage, + Class::Wizard, + Class::Conjurer, + Class::Sorcerer, + ]; + for class in &all { + assert!(CLASSES.iter().any(|c| &c.class == class)); + let _ = find_class_info(class); + } + } } diff --git a/crates/sb-core/src/combat.rs b/crates/sb-core/src/combat.rs index 2d2d5f8..8f645fc 100644 --- a/crates/sb-core/src/combat.rs +++ b/crates/sb-core/src/combat.rs @@ -424,4 +424,9 @@ mod tests { assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush)); assert!(matches!(parse_attack_type("unknown"), AttackType::Crush)); } + + #[test] + fn crit_tables_parse() { + assert!(!CRIT_TABLES.is_empty()); + } } diff --git a/crates/sb-core/src/data.rs b/crates/sb-core/src/data.rs index fd73601..c7f6d99 100644 --- a/crates/sb-core/src/data.rs +++ b/crates/sb-core/src/data.rs @@ -59,3 +59,18 @@ pub fn load_enemies() -> Vec { pub fn load_zones() -> Vec { serde_json::from_str(ZONES_JSON).expect("failed to parse zones.json") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enemies_json_parses() { + assert!(!load_enemies().is_empty()); + } + + #[test] + fn zones_json_parses() { + assert!(!load_zones().is_empty()); + } +} diff --git a/crates/sb-core/src/items.rs b/crates/sb-core/src/items.rs index 603a740..a8ead26 100644 --- a/crates/sb-core/src/items.rs +++ b/crates/sb-core/src/items.rs @@ -29,3 +29,13 @@ pub fn item_price(item: &Item) -> i32 { pub fn item_sell_price(item: &Item) -> i32 { item.value / 2 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_items_json_parses() { + assert!(!load_all_items().is_empty()); + } +} diff --git a/crates/sb-core/src/spells.rs b/crates/sb-core/src/spells.rs index 843413c..939ba87 100644 --- a/crates/sb-core/src/spells.rs +++ b/crates/sb-core/src/spells.rs @@ -110,4 +110,9 @@ mod tests { ); } } + + #[test] + fn spells_json_parses() { + assert!(!load_spells().is_empty()); + } } diff --git a/crates/sb-shared/src/item.rs b/crates/sb-shared/src/item.rs index 466dde0..c9cce77 100644 --- a/crates/sb-shared/src/item.rs +++ b/crates/sb-shared/src/item.rs @@ -27,6 +27,18 @@ pub enum ItemType { Material, } +impl ItemType { + pub fn as_str(&self) -> &'static str { + match self { + ItemType::Weapon => "weapon", + ItemType::Armor => "armor", + ItemType::Accessory => "accessory", + ItemType::Consumable => "consumable", + ItemType::Material => "material", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum AttackType { @@ -44,3 +56,52 @@ pub enum Quality { Epic, Legendary, } + +impl Quality { + pub fn as_str(&self) -> &'static str { + match self { + Quality::Common => "common", + Quality::Uncommon => "uncommon", + Quality::Rare => "rare", + Quality::Epic => "epic", + Quality::Legendary => "legendary", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn item_type_as_str_matches_serde_wire() { + for variant in [ + ItemType::Weapon, + ItemType::Armor, + ItemType::Accessory, + ItemType::Consumable, + ItemType::Material, + ] { + assert_eq!( + serde_json::to_value(&variant).unwrap(), + serde_json::json!(variant.as_str()) + ); + } + } + + #[test] + fn quality_as_str_matches_serde_wire() { + for variant in [ + Quality::Common, + Quality::Uncommon, + Quality::Rare, + Quality::Epic, + Quality::Legendary, + ] { + assert_eq!( + serde_json::to_value(&variant).unwrap(), + serde_json::json!(variant.as_str()) + ); + } + } +}