sb-core: adventure service (WI-2, issue #2)
This commit is contained in:
parent
423997f2d7
commit
6d9f02b77f
2 changed files with 471 additions and 0 deletions
470
crates/sb-core/src/adventure.rs
Normal file
470
crates/sb-core/src/adventure.rs
Normal 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}"#;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#![allow(dead_code)]
|
#![allow(dead_code)]
|
||||||
|
|
||||||
|
pub mod adventure;
|
||||||
pub mod character;
|
pub mod character;
|
||||||
pub mod combat;
|
pub mod combat;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue