reikhelm/combat/combat-core/src/ability.rs
Parley Hatch 5055feb4c3 feat(combat): deterministic combined-pool combat engine + sim + analysis
Build the full combat-system experiment from its spec, no slices deferred.

combat-core — pure, headless, deterministic engine (the single source of truth):
- One combined Vigor pool (HP+mana+stamina merged) with the two-axis
  (fill, ceiling) cost model; offense spends survival.
- Stagger cascade (absorbed/dazed/unconscious/dead), capacity = current fill,
  ceiling collapse via fatigue as the real loss condition; revive-on-recovery.
- Unified ability model (skills == spells) with the delivery+effects+potency+
  targets cost composer and a full effect library (fill/ceiling damage, DoT/HoT,
  regen-sabotage, fatigue-amp, mitigation buff/debuff, recovery).
- Ordered mitigation pipeline, config-driven con curve, competency/fizzle/resist,
  loadout/memorization, active-defense windows.
- Symmetric actors; pluggable controllers (SwingOnCooldown, ScriptedPlayer{skill},
  HoldAndPunish). Fixed-tick loop with the canonical within-tick resolution order;
  decisions are simultaneous within a tick so mirror matches are fair.
- Determinism is load-bearing: vendored order-independent RNG fork (+ integer
  chance_bp so no f64 touches the hot path) and integer fixed-point magnitudes
  with banker's rounding. 48 tests incl. bit-reproducible event-log integration.

combat-sim — batch harness: loads the config "table", runs con-tier x skill and
archetype sweeps (tens of thousands of fights), exports per-fight CSV, an
aggregated summary, and one fight's full event log.

analysis — pandas/matplotlib layer: difficulty curve, duration tent, anti-turtle
guardrail, stagger frequency, and one fight's fill/ceiling time series.

The sim did its job: the first default numbers produced a one-tick cliff and 82%
mutual-KO draws and surfaced the spec's deepest risk live (a poke skill strictly
worse than free auto-attack, so auto-only out-won the full kit). Tuning the table
moved it to the intended shape — duration tent peaking ~22s at even con, a
monotone skill->win-rate gradient (auto ~3% -> skill100 44% at even con), and a
green anti-turtle guardrail. That sim->measure->tune loop is the deliverable.

Own Cargo workspace, fully decoupled from the root reikhelm workspace.

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

330 lines
11 KiB
Rust
Raw 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.

//! Abilities (spec §6): skills and spells are **one thing**. "Kick" = melee
//! delivery + a fill-damage effect. "Fireball" = projectile delivery + a fire
//! (fill-damage) effect with a wind-up. Both resolve through the same composed
//! two-axis cost grid.
//!
//! An [`AbilitySpec`] is authored data; [`Ability`] is its compiled form with an
//! **absolute** [`AbilityCost`] baked in by [`compose_cost`]. The only structural
//! axis separating a skill from a spell is `cast_time` (instant vs. wind-up).
use crate::effect::{EffectSpec, Valence};
use crate::fixed::{apply_bp, units, Milli};
use serde::{Deserialize, Serialize};
/// Delivery / shape — sets targeting and a base cost contribution.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Delivery {
/// Affects only the caster (recovery, self-buffs).
SelfCast,
/// One adjacent target, no wind-up cost premium.
Touch,
/// One target in melee — the auto-attack / weapon-skill shape.
Melee,
/// One target at range; the canonical spell shape.
Projectile,
/// One target, channel-flavored ranged.
Beam,
/// Up to `n` targets in an area.
Aoe { n: u32 },
}
impl Delivery {
/// Whether this delivery can reach more than one enemy (informational; the
/// authoritative target count is [`AbilitySpec::targets`]).
pub fn max_targets(&self) -> u32 {
match self {
Delivery::Aoe { n } => *n,
_ => 1,
}
}
}
/// Coefficients turning a spec into a cost — "the table" for cost (spec §11). All
/// base costs are in **displayed units** of fill; fatigue defaults to a ratio of
/// the composed fill unless an ability overrides it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CostModel {
pub base_fill_self: i64,
pub base_fill_touch: i64,
pub base_fill_melee: i64,
pub base_fill_projectile: i64,
pub base_fill_beam: i64,
pub base_fill_aoe: i64,
/// Default fatigue ("spend leaves a mark", §3): `fatigue = fill × this_bp`.
/// Applied when an ability does not author `fatigue_units`.
pub fatigue_ratio_bp: i64,
/// Difficulty per unit of composed fill cost (drives fizzle, §9), in bp.
/// `difficulty = round(fill_units × this_bp / 10_000)` when not authored.
pub difficulty_per_fill_bp: i64,
}
impl Default for CostModel {
fn default() -> Self {
Self {
base_fill_self: 0,
base_fill_touch: 1,
base_fill_melee: 1,
base_fill_projectile: 2,
base_fill_beam: 3,
base_fill_aoe: 4,
fatigue_ratio_bp: 2_000, // 20% of every point spent becomes fatigue
difficulty_per_fill_bp: 10_000, // difficulty ≈ fill cost in units
}
}
}
impl CostModel {
fn base_fill(&self, d: Delivery) -> i64 {
match d {
Delivery::SelfCast => self.base_fill_self,
Delivery::Touch => self.base_fill_touch,
Delivery::Melee => self.base_fill_melee,
Delivery::Projectile => self.base_fill_projectile,
Delivery::Beam => self.base_fill_beam,
Delivery::Aoe { .. } => self.base_fill_aoe,
}
}
}
/// An authored, explicit cost in displayed units (used by `cost_override` for
/// shapes the composer can't express cleanly, e.g. auto-attack's `(0, small)`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RawCost {
pub fill: i64,
pub fatigue: i64,
}
/// The compiled, absolute cost of an ability, in milli-units. `fatigue` is the
/// **net** ceiling change from paying (positive = a mark; recovery's net gain
/// comes from its `Recovery` effect, not from a negative cost here).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct AbilityCost {
pub fill: Milli,
pub fatigue: Milli,
}
/// Authored ability data (config). Compiled to [`Ability`] via [`compile`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AbilitySpec {
pub id: String,
pub name: String,
/// Competency school this draws on for fizzle/effectiveness (§9).
pub school: String,
pub delivery: Delivery,
#[serde(default)]
pub effects: Vec<EffectSpec>,
/// Scalar on effect magnitude *and* cost. bp; `10_000` == ×1.0.
#[serde(default = "default_potency")]
pub potency_bp: i64,
#[serde(default = "default_targets")]
pub targets: u32,
/// Wind-up in ticks. `0` == instant (a skill); `>0` == a spell.
#[serde(default)]
pub cast_time: u32,
/// Ticks before this ability is reusable.
#[serde(default)]
pub cooldown: u32,
/// Explicit cost in displayed units; bypasses the composer when set.
#[serde(default)]
pub cost_override: Option<RawCost>,
/// Explicit fatigue in displayed units; overrides only the fatigue column.
#[serde(default)]
pub fatigue_units: Option<i64>,
/// Explicit difficulty (drives fizzle); derived from cost when absent.
#[serde(default)]
pub difficulty: Option<i64>,
}
fn default_potency() -> i64 {
10_000
}
fn default_targets() -> u32 {
1
}
/// A compiled ability: spec data plus its baked-in absolute cost and difficulty.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Ability {
pub id: String,
pub name: String,
pub school: String,
pub delivery: Delivery,
pub effects: Vec<EffectSpec>,
pub potency_bp: i64,
pub targets: u32,
pub cast_time: u32,
pub cooldown: u32,
pub cost: AbilityCost,
pub difficulty: i64,
}
impl Ability {
/// Whether this ability only ever helps its targets (all effects friendly) —
/// used by controllers/targeting to route a heal at an ally rather than a foe.
pub fn is_support(&self) -> bool {
!self.effects.is_empty()
&& self.effects.iter().all(|e| e.valence() == Valence::Friendly)
}
/// Whether this ability has any hostile effect (it attacks someone).
pub fn is_offensive(&self) -> bool {
self.effects.iter().any(|e| e.valence() == Valence::Hostile)
}
}
/// Compile a spec into an [`Ability`], composing its absolute cost.
pub fn compile(spec: &AbilitySpec, model: &CostModel) -> Ability {
let cost = compose_cost(spec, model);
let difficulty = spec.difficulty.unwrap_or_else(|| {
// Derived difficulty ≈ composed fill cost in units, scaled.
let fill_units = cost.fill / crate::fixed::UNIT;
crate::fixed::mul_div_round(fill_units, model.difficulty_per_fill_bp, 10_000)
});
Ability {
id: spec.id.clone(),
name: spec.name.clone(),
school: spec.school.clone(),
delivery: spec.delivery,
effects: spec.effects.clone(),
potency_bp: spec.potency_bp,
targets: spec.targets.max(1),
cast_time: spec.cast_time,
cooldown: spec.cooldown,
cost,
difficulty: difficulty.max(0),
}
}
/// Compose an absolute `(fill, fatigue)` cost (spec §6): base delivery cost plus
/// each effect's contribution, scaled by potency, multiplied by target count.
///
/// `fill = (base_delivery + Σ effect.cost_fill) × potency × targets`, then
/// `fatigue = fill × fatigue_ratio` unless authored. An explicit `cost_override`
/// short-circuits the whole thing.
pub fn compose_cost(spec: &AbilitySpec, model: &CostModel) -> AbilityCost {
if let Some(o) = spec.cost_override {
return AbilityCost {
fill: units(o.fill),
fatigue: units(o.fatigue),
};
}
let targets = spec.targets.max(1) as i64;
let base_units: i64 = model.base_fill(spec.delivery)
+ spec.effects.iter().map(|e| e.cost_fill).sum::<i64>();
// Scale by potency in milli-space, then multiply by target count.
let mut fill = apply_bp(units(base_units), spec.potency_bp);
fill *= targets;
let fatigue = match spec.fatigue_units {
Some(f) => units(f) * targets, // authored fatigue still scales with targets
None => apply_bp(fill, model.fatigue_ratio_bp),
};
AbilityCost { fill, fatigue }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::effect::EffectKind;
use crate::fixed::UNIT;
fn fill_dmg(mag: i64, cost: i64) -> EffectSpec {
EffectSpec::new(EffectKind::FillDamage { magnitude_bp: mag }, cost)
}
#[test]
fn composes_a_simple_skill_cost() {
let model = CostModel::default();
let spec = AbilitySpec {
id: "kick".into(),
name: "Kick".into(),
school: "martial".into(),
delivery: Delivery::Melee,
effects: vec![fill_dmg(1_000, 4)], // melee base 1 + effect 4 = 5 fill
potency_bp: 10_000,
targets: 1,
cast_time: 0,
cooldown: 0,
cost_override: None,
fatigue_units: None,
difficulty: None,
};
let ab = compile(&spec, &model);
assert_eq!(ab.cost.fill, 5 * UNIT);
// 20% fatigue ratio -> 1.0 unit fatigue.
assert_eq!(ab.cost.fatigue, UNIT);
assert!(ab.is_offensive());
}
#[test]
fn potency_and_targets_scale_cost() {
let model = CostModel::default();
let base = AbilitySpec {
id: "nuke".into(),
name: "Nuke".into(),
school: "invocation".into(),
delivery: Delivery::Aoe { n: 6 },
effects: vec![fill_dmg(2_000, 6)], // aoe base 4 + 6 = 10 fill at 1x,1 target
potency_bp: 20_000, // ×2 potency
targets: 6, // ×6 targets
cast_time: 20,
cooldown: 0,
cost_override: None,
fatigue_units: None,
difficulty: None,
};
let ab = compile(&base, &model);
// (10 units × 2.0) × 6 = 120 units fill.
assert_eq!(ab.cost.fill, 120 * UNIT);
// difficulty derived from fill units (120) × 1.0.
assert_eq!(ab.difficulty, 120);
}
#[test]
fn cost_override_makes_auto_attack_shape() {
let model = CostModel::default();
let spec = AbilitySpec {
id: "swing".into(),
name: "Swing".into(),
school: "martial".into(),
delivery: Delivery::Melee,
effects: vec![fill_dmg(1_000, 0)],
potency_bp: 10_000,
targets: 1,
cast_time: 0,
cooldown: 10,
cost_override: Some(RawCost { fill: 0, fatigue: 1 }), // (0 fill, +small)
fatigue_units: None,
difficulty: None,
};
let ab = compile(&spec, &model);
assert_eq!(ab.cost.fill, 0);
assert_eq!(ab.cost.fatigue, UNIT);
}
#[test]
fn support_ability_is_detected() {
let model = CostModel::default();
let spec = AbilitySpec {
id: "mend".into(),
name: "Mend".into(),
school: "restoration".into(),
delivery: Delivery::SelfCast,
effects: vec![EffectSpec::new(EffectKind::Recovery { magnitude_bp: 1_500 }, 2)],
potency_bp: 10_000,
targets: 1,
cast_time: 0,
cooldown: 30,
cost_override: None,
fatigue_units: None,
difficulty: None,
};
let ab = compile(&spec, &model);
assert!(ab.is_support());
assert!(!ab.is_offensive());
}
}