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>, 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>, 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>, 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 { 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 { 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> = 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> = 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> = 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 = vec![]; let eq: HashMap> = 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"); } } }