//! Configuration — "the table" (spec §11). A single serde-loadable //! [`CombatConfig`] holds every tunable; **nothing balance-relevant is //! hardcoded**. It compiles once into [`Rules`], the integer-fixed-point form the //! engine actually runs on (f64 is confined to this parse/compile boundary, §10). //! //! Editing config is the primary balancing act; only structural rule changes //! require a recompile. use crate::ability::{compile as compile_ability, Ability, AbilitySpec, CostModel}; use crate::actor::{RegenParams, StaggerParams}; use crate::con::{ConAnchor, ConCurve}; use crate::fixed::{mul_div_round, Milli, UNIT}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// Regen tunables (§3). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct RegenConfig { /// Fraction of normal regen while dazed. `5_000` == half. pub dazed_regen_bp: i64, /// The non-zero floor (units/sec) so an actor can always claw back toward /// affording an action — no soft-lock (§3/§6). pub floor_per_sec: i64, } impl Default for RegenConfig { fn default() -> Self { Self { dazed_regen_bp: 5_000, floor_per_sec: 1 } } } /// Stagger cascade tunables (§4) plus the damage-taken fatigue share (§3). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct StaggerConfig { /// `hit/capacity` above this is at least a daze. `10_000` == 1.0×. pub daze_threshold_bp: i64, /// `hit/capacity` at/above this is unconscious. `15_000` == 1.5×. pub unconscious_threshold_bp: i64, /// Daze duration in ticks. pub daze_duration: u32, /// Daze granted when a recovery revives an unconscious actor (ticks). pub revive_daze: u32, /// Fatigue share of every hit taken (§3), bp of the landed damage. pub fatigue_ratio_bp: i64, } impl Default for StaggerConfig { fn default() -> Self { Self { daze_threshold_bp: 10_000, unconscious_threshold_bp: 15_000, daze_duration: 8, revive_daze: 12, fatigue_ratio_bp: 2_000, } } } /// Competency → effectiveness coupling (§9). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CompetencyConfig { /// Effective-level bonus per competency point, bp. `1_000` == +0.1 level/pt /// (so 50 competency ≈ +5 effective levels). pub level_per_point_bp: i64, } impl Default for CompetencyConfig { fn default() -> Self { Self { level_per_point_bp: 1_000 } } } /// Fizzle model (§9): chance an ability fails to fire (paid for nothing). /// `chance_bp = clamp(base + max(0, difficulty − competency) × per_gap, 0, max)`. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct FizzleConfig { /// Floor fizzle chance even at/above competency (bp). pub base_bp: i64, /// Added fizzle per point that difficulty exceeds competency (bp). pub per_gap_bp: i64, /// Hard ceiling on fizzle chance (bp). pub max_bp: i64, } impl Default for FizzleConfig { fn default() -> Self { Self { base_bp: 200, per_gap_bp: 150, max_bp: 9_000 } } } /// The whole tunable surface (§11), serde-loadable from JSON. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CombatConfig { /// Fixed tick rate (ticks/second, §10). #[serde(default = "default_tick_rate")] pub tick_rate: u32, /// Global cooldown (ticks): the minimum gap between an actor's actions — the /// genre's swing timer (§10 "attack delay"). Bounds how fast *any* policy can /// act, so a button-masher can't out-DPS the intended pace. `0` disables it. #[serde(default)] pub global_cooldown: u32, /// ± spread on every landed fill hit, bp. `1_500` == ±15%. The genre's damage /// range; also the symmetry-breaker that turns a deterministic mirror match /// from a coin-flip-of-draws into smooth win-rate curves. #[serde(default)] pub damage_variance_bp: i64, #[serde(default)] pub regen: RegenConfig, #[serde(default)] pub stagger: StaggerConfig, #[serde(default)] pub competency: CompetencyConfig, #[serde(default)] pub fizzle: FizzleConfig, #[serde(default)] pub cost_model: CostModel, /// Con curve anchors (§7). Empty ⇒ the spec's default curve. #[serde(default)] pub con_curve: Vec, /// The ability library (§6) — data, not code. #[serde(default)] pub abilities: Vec, /// Optional named archetypes the sim can reference when assembling scenarios. #[serde(default)] pub stat_blocks: BTreeMap, } fn default_tick_rate() -> u32 { 10 } impl Default for CombatConfig { fn default() -> Self { Self { tick_rate: 10, global_cooldown: 0, damage_variance_bp: 0, regen: RegenConfig::default(), stagger: StaggerConfig::default(), competency: CompetencyConfig::default(), fizzle: FizzleConfig::default(), cost_model: CostModel::default(), con_curve: ConCurve::default_anchors(), abilities: Vec::new(), stat_blocks: BTreeMap::new(), } } } /// The compiled, integer-fixed-point ruleset the engine runs on. #[derive(Clone, Debug)] pub struct Rules { pub tick_rate: u32, pub global_cooldown: u32, pub damage_variance_bp: i64, pub stagger: StaggerParams, pub regen: RegenParams, pub revive_daze: u32, pub con: ConCurve, pub competency_level_per_point_bp: i64, pub fizzle: FizzleConfig, pub cost_model: CostModel, /// Default spend-mark ratio (§3), mirrored from the cost model for clarity. pub spend_fatigue_ratio_bp: i64, /// Compiled abilities keyed by id (resolution for loadouts). pub abilities: BTreeMap, } impl CombatConfig { /// Compile config into [`Rules`]: convert units/sec → milli/tick, fold the /// con anchors, and compose every ability's absolute cost up front. pub fn compile(&self) -> Rules { let tick_rate = self.tick_rate.max(1); let stagger = StaggerParams { daze_threshold_bp: self.stagger.daze_threshold_bp, unconscious_threshold_bp: self.stagger.unconscious_threshold_bp, daze_duration: self.stagger.daze_duration, fatigue_ratio_bp: self.stagger.fatigue_ratio_bp, }; let regen = RegenParams { dazed_regen_bp: self.regen.dazed_regen_bp, regen_floor: per_sec_to_per_tick(self.regen.floor_per_sec, tick_rate), }; let con = if self.con_curve.is_empty() { ConCurve::default() } else { ConCurve::new(self.con_curve.clone()) }; let abilities = self .abilities .iter() .map(|spec| (spec.id.clone(), compile_ability(spec, &self.cost_model))) .collect(); Rules { tick_rate, global_cooldown: self.global_cooldown, damage_variance_bp: self.damage_variance_bp.max(0), stagger, regen, revive_daze: self.stagger.revive_daze, con, competency_level_per_point_bp: self.competency.level_per_point_bp, fizzle: self.fizzle.clone(), cost_model: self.cost_model.clone(), spend_fatigue_ratio_bp: self.cost_model.fatigue_ratio_bp, abilities, } } } impl Rules { /// Resolve a loadout of ability ids against the compiled library, dropping /// (and reporting) any unknown ids so a typo in config fails loud-ish but the /// rest of the actor still builds. pub fn resolve_loadout(&self, ids: &[String]) -> (Vec, Vec) { let mut out = Vec::new(); let mut missing = Vec::new(); for id in ids { match self.abilities.get(id) { Some(a) => out.push(a.clone()), None => missing.push(id.clone()), } } (out, missing) } /// Fizzle chance (bp) for `difficulty` attempted at `competency` (§9). /// Trivial abilities (`difficulty <= 0`, e.g. a basic auto-attack) never /// fizzle — the base floor only applies to real skills/spells. pub fn fizzle_chance_bp(&self, competency: i64, difficulty: i64) -> i64 { if difficulty <= 0 { return 0; } let gap = (difficulty - competency).max(0); let raw = self.fizzle.base_bp + gap * self.fizzle.per_gap_bp; raw.clamp(0, self.fizzle.max_bp) } /// Con multiplier (bp) for an attacker/caster of `attacker_eff_level` against /// `target_level` (§7). pub fn con_mult_bp(&self, attacker_eff_level: i32, target_level: i32) -> i64 { self.con.multiplier_bp(attacker_eff_level - target_level) } } /// units/sec → milli/tick: `per_sec × 1000 / tick_rate`, rounded half-to-even. fn per_sec_to_per_tick(per_sec: i64, tick_rate: u32) -> Milli { mul_div_round(per_sec * UNIT, 1, tick_rate.max(1) as i64) } #[cfg(test)] mod tests { use super::*; #[test] fn default_config_compiles() { let cfg = CombatConfig::default(); let rules = cfg.compile(); assert_eq!(rules.tick_rate, 10); assert_eq!(rules.con.multiplier_bp(0), 10_000); // floor 1 unit/sec at 10 tps -> 100 milli/tick. assert_eq!(rules.regen.regen_floor, 100); } #[test] fn fizzle_grows_with_difficulty_gap() { let rules = CombatConfig::default().compile(); // competency >= difficulty -> just the base. assert_eq!(rules.fizzle_chance_bp(50, 20), 200); // a 10-point gap adds 10 * 150 = 1500. assert_eq!(rules.fizzle_chance_bp(20, 30), 200 + 1_500); // clamps at max. assert_eq!(rules.fizzle_chance_bp(0, 1_000), 9_000); } #[test] fn config_roundtrips_json() { let cfg = CombatConfig::default(); let j = serde_json::to_string(&cfg).unwrap(); let back: CombatConfig = serde_json::from_str(&j).unwrap(); assert_eq!(back.tick_rate, cfg.tick_rate); assert_eq!(back.con_curve.len(), cfg.con_curve.len()); } #[test] fn partial_json_fills_defaults() { // Only override tick rate; everything else should default. let cfg: CombatConfig = serde_json::from_str(r#"{ "tick_rate": 20 }"#).unwrap(); assert_eq!(cfg.tick_rate, 20); assert_eq!(cfg.stagger.unconscious_threshold_bp, 15_000); } }