sb-core: DomainError + signature conversions (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 12:00:11 +02:00
parent 093012af20
commit 423997f2d7
5 changed files with 76 additions and 38 deletions

View file

@ -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;

View file

@ -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<i32, &'static str> {
pub fn level_up(char: &mut Character) -> Result<i32, DomainError> {
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);
}

View file

@ -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,
}

View file

@ -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;

View file

@ -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<EnemyDef, DomainError> {
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<String, &'static str> {
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, DomainError> {
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<i64, &'static str> {
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, DomainError> {
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<i64, &'static str>
"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<i64, &'static str>
"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");
}
}