diff --git a/crates/sb-api/src/routes/mod.rs b/crates/sb-api/src/routes/mod.rs index c7c80d6..1af0478 100644 --- a/crates/sb-api/src/routes/mod.rs +++ b/crates/sb-api/src/routes/mod.rs @@ -477,7 +477,8 @@ async fn run_adventure( } char.nerve -= zone.nerve_cost; - let enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies); + let enemy = domain::pick_enemy(&mut rng, &zone.encounters, &state.enemies) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; (char.clone(), enemy) }; @@ -698,8 +699,10 @@ async fn train_stat( sb_core::character::apply_regen(char, now); let new_val = domain::train_stat(char, &input.stat).map_err(|e| match e { - "not enough energy" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, - "not enough gold" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, + sb_core::error::DomainError::NotEnoughEnergy + | sb_core::error::DomainError::NotEnoughGold => { + axum::http::StatusCode::UNPROCESSABLE_ENTITY + } _ => axum::http::StatusCode::BAD_REQUEST, })?; @@ -1204,13 +1207,7 @@ async fn level_up( .ok_or(axum::http::StatusCode::NOT_FOUND)?; match character::level_up(char) { Ok(_) => char.clone(), - Err(msg) => { - let status = match msg { - "not enough XP" => axum::http::StatusCode::UNPROCESSABLE_ENTITY, - _ => axum::http::StatusCode::UNPROCESSABLE_ENTITY, - }; - return Err(status); - } + Err(_) => return Err(axum::http::StatusCode::UNPROCESSABLE_ENTITY), } }; persist_character(&state, &updated).await; diff --git a/crates/sb-core/src/character.rs b/crates/sb-core/src/character.rs index 99c1272..aee34a3 100644 --- a/crates/sb-core/src/character.rs +++ b/crates/sb-core/src/character.rs @@ -1,4 +1,5 @@ use crate::constants; +use crate::error::DomainError; use crate::spells; use sb_shared::{Character, CharacterStats, Class, Race}; @@ -351,17 +352,17 @@ pub fn level_up_cost(level: i32) -> i32 { constants::LEVEL_UP_GOLD_PER_LEVEL * level } -pub fn level_up(char: &mut Character) -> Result { +pub fn level_up(char: &mut Character) -> Result { if char.level >= MAX_LEVEL { - return Err("already at max level"); + return Err(DomainError::AlreadyAtMaxLevel); } let next_level = char.level + 1; if char.xp < xp_for_level(next_level) { - return Err("not enough XP"); + return Err(DomainError::NotEnoughXp); } let cost = level_up_cost(next_level); if char.gold < cost { - return Err("not enough gold"); + return Err(DomainError::NotEnoughGold); } char.gold -= cost; char.level = next_level; @@ -483,7 +484,7 @@ mod tests { c.level = MAX_LEVEL; c.xp = i64::MAX; c.gold = i32::MAX; - assert_eq!(level_up(&mut c), Err("already at max level")); + assert_eq!(level_up(&mut c), Err(DomainError::AlreadyAtMaxLevel)); } #[test] @@ -512,7 +513,7 @@ mod tests { let mut c = base_char(); c.xp = 100; c.gold = 10000; - assert_eq!(level_up(&mut c), Err("not enough XP")); + assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughXp)); assert_eq!(c.level, 1); assert_eq!(c.gold, 10000); } @@ -522,7 +523,7 @@ mod tests { let mut c = base_char(); c.xp = 1800; c.gold = 0; - assert_eq!(level_up(&mut c), Err("not enough gold")); + assert_eq!(level_up(&mut c), Err(DomainError::NotEnoughGold)); assert_eq!(c.level, 1); } diff --git a/crates/sb-core/src/error.rs b/crates/sb-core/src/error.rs new file mode 100644 index 0000000..85e45c3 --- /dev/null +++ b/crates/sb-core/src/error.rs @@ -0,0 +1,23 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum DomainError { + #[error("character is incapacitated")] + CharacterIncapacitated, + #[error("not enough nerve")] + NotEnoughNerve, + #[error("not enough energy")] + NotEnoughEnergy, + #[error("not enough gold")] + NotEnoughGold, + #[error("not enough XP")] + NotEnoughXp, + #[error("already at max level")] + AlreadyAtMaxLevel, + #[error("invalid stat name")] + InvalidStat, + #[error("item not in inventory")] + ItemNotInInventory, + #[error("nothing equipped in slot")] + NothingEquippedInSlot, + #[error("encounter references unknown enemy")] + UnknownEncounterEnemy, +} diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index 0eb4216..a56dc54 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod character; pub mod combat; pub mod constants; pub mod data; +pub mod error; pub mod items; pub mod services; pub mod songs; diff --git a/crates/sb-core/src/services.rs b/crates/sb-core/src/services.rs index 348a153..ceee6a2 100644 --- a/crates/sb-core/src/services.rs +++ b/crates/sb-core/src/services.rs @@ -1,4 +1,5 @@ use crate::data::{EncounterEntry, EnemyDef}; +use crate::error::DomainError; use rand::Rng; use sb_shared::{AttackType, Character, Item}; @@ -6,8 +7,11 @@ pub fn pick_enemy( rng: &mut impl Rng, encounters: &[EncounterEntry], enemies: &[EnemyDef], -) -> EnemyDef { +) -> Result { let total_weight: i32 = encounters.iter().map(|e| e.weight).sum(); + if encounters.is_empty() || total_weight <= 0 { + return Err(DomainError::UnknownEncounterEnemy); + } let roll: i32 = rng.random_range(0..total_weight); let mut remaining = roll; for enc in encounters { @@ -16,15 +20,18 @@ pub fn pick_enemy( return enemies .iter() .find(|e| e.id == enc.enemy_id) - .expect("encounter references valid enemy") - .clone(); + .cloned() + .ok_or(DomainError::UnknownEncounterEnemy); } } + let last = encounters + .last() + .ok_or(DomainError::UnknownEncounterEnemy)?; enemies .iter() - .find(|e| e.id == encounters.last().unwrap().enemy_id) - .expect("encounter references valid enemy") - .clone() + .find(|e| e.id == last.enemy_id) + .cloned() + .ok_or(DomainError::UnknownEncounterEnemy) } pub fn equipped_attack_type( @@ -72,12 +79,12 @@ pub fn total_defense( total } -pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), &'static str> { +pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<(), DomainError> { let inv_pos = char .inventory .iter() .position(|id| id == item_id) - .ok_or("item not in inventory")?; + .ok_or(DomainError::ItemNotInInventory)?; char.inventory.remove(inv_pos); let slot = item.slot.clone(); @@ -88,20 +95,20 @@ pub fn equip_item(char: &mut Character, item: &Item, item_id: &str) -> Result<() Ok(()) } -pub fn unequip_item(char: &mut Character, slot: &str) -> Result { +pub fn unequip_item(char: &mut Character, slot: &str) -> Result { let prev = char .equipment .insert(slot.to_string(), None) .flatten() - .ok_or("nothing equipped in slot")?; + .ok_or(DomainError::NothingEquippedInSlot)?; char.inventory.push(prev.clone()); Ok(prev) } -pub fn train_stat(char: &mut Character, stat: &str) -> Result { +pub fn train_stat(char: &mut Character, stat: &str) -> Result { if char.energy < crate::constants::TRAINING_ENERGY_COST { - return Err("not enough energy"); + return Err(DomainError::NotEnoughEnergy); } let current = match stat { "strength" => char.stats.strength, @@ -109,11 +116,11 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result "dexterity" => char.stats.dexterity, "constitution" => char.stats.constitution, "luck" => char.stats.luck, - _ => return Err("invalid stat"), + _ => return Err(DomainError::InvalidStat), }; let cost = super::character::training_cost(current); if char.gold < cost { - return Err("not enough gold"); + return Err(DomainError::NotEnoughGold); } char.energy -= crate::constants::TRAINING_ENERGY_COST; char.gold -= cost; @@ -135,7 +142,7 @@ pub fn train_stat(char: &mut Character, stat: &str) -> Result "dexterity" => char.stats.dexterity, "constitution" => char.stats.constitution, "luck" => char.stats.luck, - _ => return Err("invalid stat"), + _ => return Err(DomainError::InvalidStat), }; Ok(new_val) } @@ -225,20 +232,29 @@ mod tests { fn train_stat_fails_without_energy() { let mut c = base_char(); c.energy = 0; - assert_eq!(train_stat(&mut c, "strength"), Err("not enough energy")); + assert_eq!( + train_stat(&mut c, "strength"), + Err(DomainError::NotEnoughEnergy) + ); } #[test] fn train_stat_fails_without_gold() { let mut c = base_char(); c.gold = 0; - assert_eq!(train_stat(&mut c, "strength"), Err("not enough gold")); + assert_eq!( + train_stat(&mut c, "strength"), + Err(DomainError::NotEnoughGold) + ); } #[test] fn train_stat_fails_invalid_stat() { let mut c = base_char(); - assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat")); + assert_eq!( + train_stat(&mut c, "charisma"), + Err(DomainError::InvalidStat) + ); } #[test] @@ -283,7 +299,7 @@ mod tests { let mut c = base_char(); assert_eq!( equip_item(&mut c, &sword(), "sword1"), - Err("item not in inventory") + Err(DomainError::ItemNotInInventory) ); } @@ -303,7 +319,7 @@ mod tests { let mut c = base_char(); assert_eq!( unequip_item(&mut c, "main_hand"), - Err("nothing equipped in slot") + Err(DomainError::NothingEquippedInSlot) ); } @@ -407,7 +423,7 @@ mod tests { ]; let mut rng = rand::rngs::StdRng::seed_from_u64(42); for _ in 0..100 { - let enemy = pick_enemy(&mut rng, &encounters, &enemies); + let enemy = pick_enemy(&mut rng, &encounters, &enemies).unwrap(); assert!(enemy.id == "kobold" || enemy.id == "rat"); } }