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.
371 lines
11 KiB
Rust
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));
|
|
}
|
|
}
|