sb-core: adventure service (WI-2, issue #2)

This commit is contained in:
Jan-Peter von Hunnius 2026-10-03 12:03:18 +02:00
parent 423997f2d7
commit 6d9f02b77f
2 changed files with 471 additions and 0 deletions

View file

@ -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<String>,
}
pub fn run_adventure(
rng: &mut impl Rng,
character: &mut Character,
zone: &ZoneDef,
enemies: &[EnemyDef],
items: &[Item],
now_ms: i64,
) -> Result<AdventureOutcome, DomainError> {
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<String> = 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<Item> {
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}"#;
}

View file

@ -1,5 +1,6 @@
#![allow(dead_code)]
pub mod adventure;
pub mod character;
pub mod combat;
pub mod constants;