From 6d9f02b77f040c5b6af99ba16f02c2ea1e5c11b7 Mon Sep 17 00:00:00 2001 From: Jan-Peter von Hunnius Date: Sat, 3 Oct 2026 12:03:18 +0200 Subject: [PATCH] sb-core: adventure service (WI-2, issue #2) --- crates/sb-core/src/adventure.rs | 470 ++++++++++++++++++++++++++++++++ crates/sb-core/src/lib.rs | 1 + 2 files changed, 471 insertions(+) create mode 100644 crates/sb-core/src/adventure.rs diff --git a/crates/sb-core/src/adventure.rs b/crates/sb-core/src/adventure.rs new file mode 100644 index 0000000..320276f --- /dev/null +++ b/crates/sb-core/src/adventure.rs @@ -0,0 +1,470 @@ +use crate::character; +use crate::combat::{self, CombatStats}; +use crate::data::{EnemyDef, ZoneDef}; +use crate::error::DomainError; +use crate::services; +use crate::songs; +use crate::spells; +use rand::Rng; +use sb_shared::{Character, CombatLog, Item}; + +/// Domain output of one adventure run. No serde derives — the HTTP DTO +/// (sb-api `AdventureRunResponse`) maps from this at the boundary. +pub struct AdventureOutcome { + pub zone_id: String, + pub enemy_name: String, + pub combat_log: CombatLog, + pub loot_item_names: Vec, +} + +pub fn run_adventure( + rng: &mut impl Rng, + character: &mut Character, + zone: &ZoneDef, + enemies: &[EnemyDef], + items: &[Item], + now_ms: i64, +) -> Result { + character::apply_regen(character, now_ms); + + if character.hp <= 0 { + return Err(DomainError::CharacterIncapacitated); + } + if character.nerve < zone.nerve_cost { + return Err(DomainError::NotEnoughNerve); + } + character.nerve -= zone.nerve_cost; + + let enemy_def = services::pick_enemy(rng, &zone.encounters, enemies)?; + + let char_attack_type = services::equipped_attack_type(&character.equipment, items); + let char_ob = services::equipped_damage(&character.equipment, items); + let char_db = services::total_defense(&character.equipment, items); + + let char_combat = CombatStats { + name: character.name.clone(), + hp: character.hp, + level: character.level, + strength: character.stats.strength, + dexterity: character.stats.dexterity, + constitution: character.stats.constitution, + luck: character.stats.luck, + ob_base: char_ob, + db_base: char_db, + attack_type: char_attack_type, + }; + + let enemy_combat = CombatStats { + name: enemy_def.name.clone(), + hp: enemy_def.hp, + level: enemy_def.level, + strength: enemy_def.stats.strength, + dexterity: enemy_def.stats.dexterity, + constitution: enemy_def.stats.constitution, + luck: enemy_def.stats.luck, + ob_base: enemy_def.damage, + db_base: enemy_def.defense, + attack_type: combat::parse_attack_type(&enemy_def.attack_type), + }; + + let spell_effect = character.active_spell.as_deref().and_then(|sid| { + spells::find_spell(sid).map(|s| combat::SpellEffect { + combat_effect: s.combat_effect.clone(), + damage_min: s.combat_damage_min, + damage_max: s.combat_damage_max, + heal_min: s.heal_min, + heal_max: s.heal_max, + }) + }); + + let song_effect = character.active_song.as_deref().and_then(|sid| { + songs::BARD_SONGS + .iter() + .find(|s| s.id == sid) + .map(|s| s.combat_effect) + }); + + let log = combat::resolve_combat_full( + char_combat, + enemy_combat, + enemy_def.xp, + enemy_def.gold_min, + enemy_def.gold_max, + song_effect, + spell_effect.as_ref(), + rng, + ); + + let mut dropped_ids: Vec = Vec::new(); + if log.winner == character.name { + character.xp += log.xp_gained as i64; + character.gold += log.gold_gained; + if let Some(hp) = log + .rounds + .iter() + .rfind(|r| r.defender == character.name) + .map(|r| r.defender_hp) + { + character.hp = hp; + } + + for drop in &enemy_def.loot { + if rng.random_bool(drop.chance) && items.iter().any(|i| i.id == drop.item_id) { + character.inventory.push(drop.item_id.clone()); + dropped_ids.push(drop.item_id.clone()); + } + } + } else { + character.hp = 0; + } + + if let Some(active) = character.active_spell.as_deref() + && let Some(sp) = spells::find_spell(active) + { + character.spell_points = (character.spell_points - sp.spell_points).max(0); + } + + let loot_item_names = dropped_ids + .iter() + .filter_map(|id| items.iter().find(|i| &i.id == id).map(|i| i.name.clone())) + .collect(); + + Ok(AdventureOutcome { + zone_id: zone.id.clone(), + enemy_name: enemy_def.name.clone(), + combat_log: log, + loot_item_names, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants; + use crate::data::{EncounterEntry, EnemyStats, LootDrop}; + use rand::SeedableRng; + use sb_shared::{AttackType, CharacterStats, Class, ItemType, Quality, Race}; + 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: 50, + intelligence: 20, + dexterity: 20, + constitution: 20, + luck: 20, + }, + hp: 100, + max_hp: 100, + energy: 36, + max_energy: 36, + nerve: 110, + max_nerve: 110, + gold: 100, + 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 char_with_sword() -> Character { + let mut c = base_char(); + c.equipment + .insert("main_hand".into(), Some("sword1".into())); + c + } + + 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 items() -> Vec { + vec![sword()] + } + + fn zone() -> ZoneDef { + zone_with("rat") + } + + fn zone_with(enemy_id: &str) -> ZoneDef { + ZoneDef { + id: "streets".into(), + name: "Skara Brae Streets".into(), + min_level: 1, + max_level: 5, + nerve_cost: 5, + description: String::new(), + encounters: vec![EncounterEntry { + enemy_id: enemy_id.into(), + weight: 1, + }], + } + } + + fn weak_enemy() -> EnemyDef { + EnemyDef { + id: "rat".into(), + name: "Giant Rat".into(), + hp: 10, + level: 1, + damage: 0, + defense: 0, + attack_type: "puncture".into(), + xp: 5, + gold_min: 1, + gold_max: 2, + stats: EnemyStats { + strength: 5, + intelligence: 5, + dexterity: 5, + constitution: 5, + luck: 5, + }, + description: String::new(), + loot: vec![LootDrop { + item_id: "sword1".into(), + chance: 1.0, + }], + } + } + + fn overwhelming_enemy() -> EnemyDef { + EnemyDef { + id: "doom".into(), + name: "Doom Bringer".into(), + hp: 500, + level: 10, + damage: 200, + defense: 0, + attack_type: "crush".into(), + xp: 5, + gold_min: 1, + gold_max: 2, + stats: EnemyStats { + strength: 5, + intelligence: 5, + dexterity: 5, + constitution: 5, + luck: 5, + }, + description: String::new(), + loot: vec![LootDrop { + item_id: "sword1".into(), + chance: 1.0, + }], + } + } + + #[test] + fn run_adventure_win_applies_rewards_and_loot() { + let mut rng = rand::rngs::StdRng::seed_from_u64(7); + let mut c = char_with_sword(); + let (xp_before, gold_before, nerve_before) = (c.xp, c.gold, c.nerve); + let z = zone(); + let outcome = + run_adventure(&mut rng, &mut c, &z, &[weak_enemy()], &items(), 1_000_000).unwrap(); + + assert_eq!(outcome.combat_log.winner, c.name); + assert_eq!(c.xp, xp_before + 5); + assert!(c.gold > gold_before && c.gold <= gold_before + 2); + assert_eq!(c.gold, gold_before + outcome.combat_log.gold_gained); + assert_eq!(c.nerve, nerve_before - z.nerve_cost); + assert!(c.inventory.contains(&"sword1".to_string())); + assert_eq!(outcome.loot_item_names, vec!["Iron Sword".to_string()]); + assert_eq!(outcome.enemy_name, "Giant Rat"); + assert_eq!(outcome.zone_id, "streets"); + assert_eq!( + serde_json::to_string(&outcome.combat_log).unwrap(), + GOLDEN_COMBAT_LOG_JSON + ); + } + + #[test] + fn run_adventure_defeat_sets_hp_to_zero() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = base_char(); + c.stats.strength = 5; + let (xp_before, gold_before) = (c.xp, c.gold); + let outcome = run_adventure( + &mut rng, + &mut c, + &zone_with("doom"), + &[overwhelming_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + + assert_eq!(outcome.combat_log.winner, "Doom Bringer"); + assert_eq!(c.hp, 0); + assert_eq!(c.xp, xp_before); + assert_eq!(c.gold, gold_before); + assert!(c.inventory.is_empty()); + assert!(outcome.loot_item_names.is_empty()); + } + + #[test] + fn run_adventure_rejects_incapacitated() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + c.hp = 0; + let nerve_before = c.nerve; + let result = run_adventure( + &mut rng, + &mut c, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ); + assert!(matches!(result, Err(DomainError::CharacterIncapacitated))); + assert_eq!(c.nerve, nerve_before); + } + + #[test] + fn run_adventure_rejects_insufficient_nerve() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + c.nerve = 4; + c.last_regen = 1_000_000; + let nerve_before = c.nerve; + let result = run_adventure( + &mut rng, + &mut c, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ); + assert!(matches!(result, Err(DomainError::NotEnoughNerve))); + assert_eq!(c.nerve, nerve_before); + } + + #[test] + fn run_adventure_applies_regen_before_guards() { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + c.hp = 40; + c.max_hp = 80; + c.last_regen = 0; + let now_ms = 10 * constants::HP_REGEN_INTERVAL_MS; + let mut enemy = weak_enemy(); + enemy.damage = 0; + let outcome = run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), now_ms).unwrap(); + + assert_eq!(c.last_regen, now_ms); + assert_eq!(c.hp, 80); + assert_eq!(outcome.combat_log.winner, c.name); + } + + #[test] + fn run_adventure_charges_spell_upkeep_on_win_and_loss() { + let spell = spells::find_spell("arfi").expect("arfi fixture spell"); + assert!(spell.spell_points > 0); + + let mut win_rng = rand::rngs::StdRng::seed_from_u64(7); + let mut winner = char_with_sword(); + winner.active_spell = Some("arfi".into()); + winner.spell_points = 20; + run_adventure( + &mut win_rng, + &mut winner, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + assert_eq!(winner.spell_points, (20 - spell.spell_points).max(0)); + assert_eq!(winner.active_spell.as_deref(), Some("arfi")); + + let mut loss_rng = rand::rngs::StdRng::seed_from_u64(42); + let mut loser = base_char(); + loser.stats.strength = 5; + loser.active_spell = Some("arfi".into()); + loser.spell_points = 20; + run_adventure( + &mut loss_rng, + &mut loser, + &zone_with("doom"), + &[overwhelming_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + assert_eq!(loser.spell_points, (20 - spell.spell_points).max(0)); + assert_eq!(loser.active_spell.as_deref(), Some("arfi")); + } + + #[test] + fn run_adventure_filters_loot_to_catalog_items() { + let mut rng = rand::rngs::StdRng::seed_from_u64(7); + let mut enemy = weak_enemy(); + enemy.loot = vec![LootDrop { + item_id: "ghost_item".into(), + chance: 1.0, + }]; + let mut c = char_with_sword(); + let outcome = + run_adventure(&mut rng, &mut c, &zone(), &[enemy], &items(), 1_000_000).unwrap(); + + assert!(!c.inventory.contains(&"ghost_item".to_string())); + assert!(outcome.loot_item_names.is_empty()); + } + + #[test] + fn run_adventure_is_deterministic_under_seeded_rng() { + let run = || { + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut c = char_with_sword(); + let outcome = run_adventure( + &mut rng, + &mut c, + &zone(), + &[weak_enemy()], + &items(), + 1_000_000, + ) + .unwrap(); + ( + format!( + "{:?}", + ( + outcome.zone_id, + outcome.enemy_name, + outcome.loot_item_names, + outcome.combat_log + ) + ), + format!("{:?}", c), + ) + }; + assert_eq!(run(), run()); + } + + const GOLDEN_COMBAT_LOG_JSON: &str = r#"{"rounds":[{"attacker":"Test","defender":"Giant Rat","raw_roll":-11,"ob":30,"db":-10,"result":29,"damage":43,"crit":{"severity":"A","crit":{"name":"Minor Slash","damage_multiplier":1.5,"stun_rounds":0,"bleed_per_round":1,"bleed_rounds":1,"effects":[]}},"defender_hp":0}],"winner":"Test","loser":"Giant Rat","xp_gained":5,"gold_gained":1}"#; +} diff --git a/crates/sb-core/src/lib.rs b/crates/sb-core/src/lib.rs index a56dc54..aa80266 100644 --- a/crates/sb-core/src/lib.rs +++ b/crates/sb-core/src/lib.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +pub mod adventure; pub mod character; pub mod combat; pub mod constants;