skara-brae-server/crates/sb-core/src/combat.rs
Jan-Peter von Hunnius 6410e0a86d Implement auth, persistence, typed responses, and injectable RNG
Auth: JWT tokens (sb-auth) with argon2 password hashing, login/register
endpoints, Bearer token middleware protecting all routes except auth.
Authorization checks ensure users can only access their own characters.

Persistence: SQLite via rusqlite (sb-db) with write-through on all
mutations. Optional via SB_DB_PATH env var; falls back to in-memory.

Typed responses: ~20 response DTOs in sb-shared replacing ad-hoc JSON.

Combat: Injectable RNG via &mut impl Rng for deterministic tests.

Clean Architecture: Combat types and response DTOs in sb-shared; domain
services in sb-core; auth in sb-auth; persistence in sb-db.
2026-08-09 16:51:52 +02:00

371 lines
11 KiB
Rust

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<HashMap<String, HashMap<String, CritEntry>>> = 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<i32> {
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<String> {
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<CritEntry> {
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));
}
}