reikhelm/combat/combat-core/src/config.rs
Parley Hatch 57dba5e149 feat(combat): order-of-magnitude slowdown — fights now ~20-30s
Fights were ~3-5s; the user wanted 20-30s. Tuned the table (and added one
mechanic) so they land there, re-validated against the sim throughout.

- Engine: a `global_cooldown` (the genre swing timer, §10 "attack delay") — an
  actor can't start a new action until it elapses, so no button-masher can
  out-DPS the intended tempo. Config-driven (`global_cooldown` ticks; 0 = off).
  Actor gains `next_action_at`; engine gates decisions on it.
- Controller: ScriptedPlayer now pressures with its heavy proactively when
  healthy (not only on the exact overrun frame), so the sim measures what a real
  player experiences instead of an unrealistically patient bound.
- Config rebalance: damage cut + flattened, fatigue/regen/recovery co-tuned, and
  the kit made roughly **DPS-neutral** with auto-attack (each ability's cooldown
  matched to auto's damage-per-second). Under the global cooldown that's the key
  insight: abilities win through stagger leverage + timing, not raw throughput —
  otherwise strong abilities trivialize the pace and flat ones aren't worth their
  fill (the guardrail kept inverting until this landed). Auto-attacks (difficulty
  0) also no longer fizzle.
- Front-end: cache-bust the config fetch so edits are always picked up; widened
  the sim's archetype sweep to all six monsters.

Result (sim, skilled play ≈ real play): monster fights 16-38s (centered ~24s);
guardrail steep and green (even-con auto 0% → skill20 2% → skill50 12% →
skill80 44% → skill100 78%); all six monsters winnable. Verified live in-browser:
realistic play lands ~15-30s and a clean mid-fight reads perfectly on the dual bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:34:10 -06:00

297 lines
10 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<ConAnchor>,
/// The ability library (§6) — data, not code.
#[serde(default)]
pub abilities: Vec<AbilitySpec>,
/// Optional named archetypes the sim can reference when assembling scenarios.
#[serde(default)]
pub stat_blocks: BTreeMap<String, crate::actor::StatBlock>,
}
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<String, Ability>,
}
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<Ability>, Vec<String>) {
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);
}
}