skara-brae-server/crates/sb-core/src/services.rs
Jan-Peter von Hunnius 6410e0a86d Implement auth, persistence, typed responses, and injectable RNG
Auth: JWT tokens (sb-auth) with argon2 password hashing, login/register
endpoints, Bearer token middleware protecting all routes except auth.
Authorization checks ensure users can only access their own characters.

Persistence: SQLite via rusqlite (sb-db) with write-through on all
mutations. Optional via SB_DB_PATH env var; falls back to in-memory.

Typed responses: ~20 response DTOs in sb-shared replacing ad-hoc JSON.

Combat: Injectable RNG via &mut impl Rng for deterministic tests.

Clean Architecture: Combat types and response DTOs in sb-shared; domain
services in sb-core; auth in sb-auth; persistence in sb-db.
2026-08-09 16:51:52 +02:00

319 lines
11 KiB
Rust

use sb_shared::{Character, Item, AttackType};
use crate::data::{EnemyDef, EncounterEntry};
use rand::Rng;
pub fn pick_enemy(
rng: &mut impl Rng,
encounters: &[EncounterEntry],
enemies: &[EnemyDef],
) -> EnemyDef {
let total_weight: i32 = encounters.iter().map(|e| e.weight).sum();
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)
.expect("encounter references valid enemy")
.clone();
}
}
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<String, Option<String>>, 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
{
return at.clone();
}
AttackType::Crush
}
pub fn equipped_damage(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
let mut total = 0i32;
for slot in &["main_hand", "off_hand"] {
if let Some(Some(item_id)) = equipment.get(*slot)
&& let Some(item) = items.iter().find(|i| &i.id == item_id)
&& let Some(&dmg) = item.stats.get("damage")
{
total += dmg as i32;
}
}
total
}
pub fn total_defense(equipment: &std::collections::HashMap<String, Option<String>>, items: &[Item]) -> i32 {
let mut total = 0i32;
for (_, item_id) in equipment.iter() {
if let Some(id) = item_id
&& let Some(item) = items.iter().find(|i| &i.id == id)
&& let Some(&def) = item.stats.get("defense")
{
total += def as i32;
}
}
total
}
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();
if let Some(Some(prev)) = char.equipment.insert(slot, Some(item_id.to_string())) {
char.inventory.push(prev);
}
Ok(())
}
pub fn unequip_item(char: &mut Character, slot: &str) -> Result<String, &'static str> {
let prev = char.equipment.insert(slot.to_string(), None)
.flatten()
.ok_or("nothing equipped in slot")?;
char.inventory.push(prev.clone());
Ok(prev)
}
pub fn train_stat(char: &mut Character, stat: &str) -> Result<i64, &'static str> {
if char.energy < 1 {
return Err("not enough energy");
}
let current = match stat {
"strength" => char.stats.strength,
"intelligence" => char.stats.intelligence,
"dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution,
"luck" => char.stats.luck,
_ => return Err("invalid stat"),
};
let cost = super::character::training_cost(current);
if char.gold < cost {
return Err("not enough gold");
}
char.energy -= 1;
char.gold -= cost;
match stat {
"strength" => char.stats.strength += 1,
"intelligence" => char.stats.intelligence += 1,
"dexterity" => char.stats.dexterity += 1,
"constitution" => {
char.stats.constitution += 1;
char.max_hp = super::character::calc_max_hp(&char.class, &char.stats);
char.hp = char.hp.min(char.max_hp);
}
"luck" => char.stats.luck += 1,
_ => {}
}
let new_val = match stat {
"strength" => char.stats.strength,
"intelligence" => char.stats.intelligence,
"dexterity" => char.stats.dexterity,
"constitution" => char.stats.constitution,
"luck" => char.stats.luck,
_ => return Err("invalid stat"),
};
Ok(new_val)
}
#[cfg(test)]
mod tests {
use super::*;
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,
}
}
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 shield() -> Item {
Item {
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,
}
}
#[test]
fn train_stat_increases_stat() {
let mut c = base_char();
let cost = super::super::character::training_cost(c.stats.strength);
c.gold = cost;
let result = train_stat(&mut c, "strength");
assert_eq!(result, Ok(21));
assert_eq!(c.stats.strength, 21);
assert_eq!(c.energy, 9);
}
#[test]
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"));
}
#[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"));
}
#[test]
fn train_stat_fails_invalid_stat() {
let mut c = base_char();
assert_eq!(train_stat(&mut c, "charisma"), Err("invalid stat"));
}
#[test]
fn train_constitution_recals_hp() {
let mut c = base_char();
let old_max = c.max_hp;
let cost = super::super::character::training_cost(c.stats.constitution);
c.gold = cost;
train_stat(&mut c, "constitution").unwrap();
assert!(c.max_hp > old_max);
}
#[test]
fn equip_item_moves_from_inventory_to_slot() {
let mut c = base_char();
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())));
}
#[test]
fn equip_item_swaps_previous() {
let mut c = base_char();
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!(c.inventory.contains(&"old_sword".to_string()));
assert!(!c.inventory.contains(&"sword1".to_string()));
}
#[test]
fn equip_item_fails_if_not_in_inventory() {
let mut c = base_char();
assert_eq!(equip_item(&mut c, &sword(), "sword1"), Err("item not in inventory"));
}
#[test]
fn unequip_item_moves_to_inventory() {
let mut c = base_char();
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()));
assert_eq!(c.equipment.get("main_hand").cloned(), Some(None));
}
#[test]
fn unequip_item_fails_empty_slot() {
let mut c = base_char();
assert_eq!(unequip_item(&mut c, "main_hand"), Err("nothing equipped in slot"));
}
#[test]
fn equipped_damage_sums_weapon_slots() {
let items = vec![sword(), shield()];
let eq: HashMap<String, Option<String>> = HashMap::from([
("main_hand".into(), Some("sword1".into())),
("off_hand".into(), Some("shield1".into())),
]);
assert_eq!(equipped_damage(&eq, &items), 15);
}
#[test]
fn total_defense_sums_all_slots() {
let items = vec![sword(), shield()];
let eq: HashMap<String, Option<String>> = HashMap::from([
("main_hand".into(), Some("sword1".into())),
("off_hand".into(), Some("shield1".into())),
]);
assert_eq!(total_defense(&eq, &items), 10);
}
#[test]
fn equipped_attack_type_returns_weapon_type() {
let items = vec![sword()];
let eq: HashMap<String, Option<String>> = 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<Item> = vec![];
let eq: HashMap<String, Option<String>> = HashMap::new();
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 },
];
let enemies = vec![
crate::data::EnemyDef {
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 },
description: String::new(),
loot: vec![],
},
crate::data::EnemyDef {
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 },
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);
assert!(enemy.id == "kobold" || enemy.id == "rat");
}
}
}