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>
297 lines
10 KiB
Rust
297 lines
10 KiB
Rust
//! 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);
|
||
}
|
||
}
|