use rand::Rng; use sb_shared::{AttackType, CombatLog, CombatRound, CritEntry, CritResult}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::LazyLock; const CRIT_TABLES_JSON: &str = include_str!("../../../../skara-brae-common/data/crit-tables/combat.json"); static CRIT_TABLES: LazyLock>> = LazyLock::new(|| { serde_json::from_str(CRIT_TABLES_JSON).expect("failed to parse crit tables") }); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CombatStats { pub name: String, pub hp: i32, pub level: i32, pub strength: i64, pub dexterity: i64, pub constitution: i64, pub luck: i64, pub ob_base: i32, pub db_base: i32, pub attack_type: AttackType, } impl CombatStats { pub fn offensive_bonus(&self) -> i32 { let str_bonus = super::character::stat_bonus(self.strength) as i32; str_bonus + self.ob_base } pub fn defensive_bonus(&self) -> i32 { let dex_bonus = super::character::stat_bonus(self.dexterity) as i32; let con_bonus = (super::character::stat_bonus(self.constitution) as i32) / 2; dex_bonus + con_bonus + self.db_base } } pub fn parse_attack_type(s: &str) -> AttackType { match s { "slash" => AttackType::Slash, "crush" => AttackType::Crush, "puncture" => AttackType::Puncture, _ => AttackType::Crush, } } pub fn crit_damage_cap(attacker_level: i32, defender_level: i32, defender_hp: i32) -> Option { let diff = defender_level - attacker_level; if diff <= 0 { return None; } let cap_pct = match diff { 1..=9 => return None, 10..=19 => 0.60, 20..=49 => 0.40, 50..=99 => 0.15, _ => 0.05, }; Some((defender_hp as f64 * cap_pct) as i32) } pub fn open_ended_roll(rng: &mut impl Rng) -> i32 { let roll: i32 = rng.random_range(1..=100); let mut total = roll; if roll >= 96 { total += open_ended_roll(rng); } else if roll <= 5 { total -= open_ended_roll(rng); } total } pub fn resolve_attack( rng: &mut impl Rng, attacker: &CombatStats, defender: &CombatStats, ) -> CombatRound { let raw_roll = open_ended_roll(rng); let ob = attacker.offensive_bonus(); let db = defender.defensive_bonus(); let attack_total = raw_roll + ob; let result = attack_total - db; let mut damage = 0; let mut crit = None; if result > 0 { damage = result; if let Some(severity) = crit_severity(result) && let Some(crit_entry) = lookup_crit(&attacker.attack_type, &severity) { let crit_damage = (damage as f64 * crit_entry.damage_multiplier) as i32; let capped = if let Some(cap) = crit_damage_cap(attacker.level, defender.level, defender.hp) { crit_damage.min(cap) } else { crit_damage }; damage = capped; crit = Some(CritResult { severity: severity.clone(), crit: crit_entry, }); } } CombatRound { attacker: attacker.name.clone(), defender: defender.name.clone(), raw_roll, ob, db, result, damage, crit, defender_hp: 0, } } pub fn crit_severity(result: i32) -> Option { if result >= 151 { Some("D".into()) } else if result >= 101 { Some("C".into()) } else if result >= 51 { Some("B".into()) } else if result >= 21 { Some("A".into()) } else { None } } pub fn lookup_crit(attack_type: &AttackType, severity: &str) -> Option { let type_key = format!("{:?}", attack_type).to_lowercase(); CRIT_TABLES.get(&type_key)?.get(severity).cloned() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpellEffect { pub combat_effect: String, pub damage_min: i32, pub damage_max: i32, pub heal_min: i32, pub heal_max: i32, } pub fn resolve_combat( attacker: CombatStats, defender: CombatStats, xp_reward: i32, gold_min: i32, gold_max: i32, rng: &mut impl Rng, ) -> CombatLog { resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, None, None, rng) } pub fn resolve_combat_with_song( attacker: CombatStats, defender: CombatStats, xp_reward: i32, gold_min: i32, gold_max: i32, song_effect: Option<&str>, rng: &mut impl Rng, ) -> CombatLog { resolve_combat_full(attacker, defender, xp_reward, gold_min, gold_max, song_effect, None, rng) } #[allow(clippy::too_many_arguments)] pub fn resolve_combat_full( attacker: CombatStats, defender: CombatStats, xp_reward: i32, gold_min: i32, gold_max: i32, song_effect: Option<&str>, spell: Option<&SpellEffect>, rng: &mut impl Rng, ) -> CombatLog { let mut rng = rng; let mut a_hp = attacker.hp; let mut d_hp = defender.hp; let mut rounds = Vec::new(); loop { let mut round = resolve_attack(&mut rng, &apply_song_to_attacker(&attacker, song_effect), &apply_song_to_defender(&defender, song_effect)); if let Some(sp) = spell && sp.combat_effect == "damage" { let spell_dmg = rng.random_range(sp.damage_min..=sp.damage_max); round.damage += spell_dmg; } d_hp = (d_hp - round.damage).max(0); round.defender_hp = d_hp; rounds.push(round); if d_hp <= 0 { let gold = rng.random_range(gold_min..=gold_max); return CombatLog { rounds, winner: attacker.name.clone(), loser: defender.name.clone(), xp_gained: xp_reward, gold_gained: gold, }; } let mut round = resolve_attack(&mut rng, &defender, &apply_song_to_defender(&make_temp_stats(&attacker, a_hp), song_effect)); a_hp = (a_hp - round.damage).max(0); round.defender_hp = a_hp; rounds.push(round); if a_hp <= 0 { return CombatLog { rounds, winner: defender.name.clone(), loser: attacker.name.clone(), xp_gained: 0, gold_gained: 0, }; } if song_effect == Some("heal") && a_hp > 0 && a_hp < attacker.hp { let heal_amt = (attacker.hp as f64 * 0.03).ceil() as i32; a_hp = (a_hp + heal_amt).min(attacker.hp); } if let Some(sp) = spell && sp.combat_effect == "heal" && a_hp > 0 && a_hp < attacker.hp { let heal_amt = rng.random_range(sp.heal_min..=sp.heal_max); a_hp = (a_hp + heal_amt).min(attacker.hp); } } } fn apply_song_to_attacker(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { let mut s = stats.clone(); match song_effect { Some("accuracy") => { s.ob_base += 15; } Some("damage") => { s.ob_base += 20; } _ => {} } s } fn apply_song_to_defender(stats: &CombatStats, song_effect: Option<&str>) -> CombatStats { let mut s = stats.clone(); match song_effect { Some("defense") | Some("magic_resist") => { s.db_base += 10; } Some("weaken") => { s.ob_base = (s.ob_base as f64 * 0.7) as i32; } _ => {} } s } fn make_temp_stats(original: &CombatStats, hp: i32) -> CombatStats { let mut s = original.clone(); s.hp = hp; s } #[cfg(test)] mod tests { use super::*; fn attacker() -> CombatStats { CombatStats { name: "Hero".into(), hp: 100, level: 1, strength: 50, dexterity: 40, constitution: 40, luck: 20, ob_base: 20, db_base: 10, attack_type: AttackType::Slash, } } fn defender() -> CombatStats { CombatStats { name: "Goblin".into(), hp: 50, level: 1, strength: 30, dexterity: 30, constitution: 30, luck: 10, ob_base: 10, db_base: 5, attack_type: AttackType::Crush, } } #[test] fn open_ended_roll_stays_in_range() { use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); for _ in 0..1000 { let roll = open_ended_roll(&mut rng); assert!(roll > -500 && roll < 500, "roll out of expected range: {}", roll); } } #[test] fn offensive_bonus_includes_stat_and_base() { let a = attacker(); let ob = a.offensive_bonus(); assert!(ob > a.ob_base); } #[test] fn defensive_bonus_includes_stat_and_base() { let d = defender(); let db = d.defensive_bonus(); assert!(db > d.db_base); } #[test] fn crit_damage_cap_equal_level_is_none() { assert_eq!(crit_damage_cap(5, 5, 100), None); assert_eq!(crit_damage_cap(1, 1, 100), None); } #[test] fn crit_damage_cap_higher_defender_reduces() { let cap = crit_damage_cap(1, 15, 100).unwrap(); assert!(cap < 100); } #[test] fn crit_damage_cap_scaling() { let cap_10 = crit_damage_cap(1, 11, 100).unwrap(); let cap_50 = crit_damage_cap(1, 51, 100).unwrap(); let cap_100 = crit_damage_cap(1, 101, 100).unwrap(); assert!(cap_100 < cap_50); assert!(cap_50 < cap_10); } #[test] fn resolve_attack_can_hit_or_miss() { use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); let a = attacker(); let d = defender(); for _ in 0..100 { let round = resolve_attack(&mut rng, &a, &d); assert_eq!(round.attacker, "Hero"); assert_eq!(round.defender, "Goblin"); assert!(round.defender_hp <= d.hp); assert!(round.defender_hp >= 0); } } #[test] fn resolve_combat_produces_winner() { use rand::SeedableRng; let mut rng = rand::rngs::StdRng::seed_from_u64(42); let a = attacker(); let d = defender(); let log = resolve_combat(a.clone(), d.clone(), 10, 5, 10, &mut rng); assert!(!log.rounds.is_empty()); assert!(log.winner == a.name || log.winner == d.name); assert!(log.loser != log.winner); } #[test] fn parse_attack_type_maps_known_types() { assert!(matches!(parse_attack_type("slash"), AttackType::Slash)); assert!(matches!(parse_attack_type("crush"), AttackType::Crush)); assert!(matches!(parse_attack_type("puncture"), AttackType::Puncture)); assert!(matches!(parse_attack_type("unarmed"), AttackType::Crush)); assert!(matches!(parse_attack_type("unknown"), AttackType::Crush)); } }