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.
319 lines
11 KiB
Rust
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");
|
|
}
|
|
}
|
|
}
|