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>
330 lines
11 KiB
Rust
330 lines
11 KiB
Rust
//! 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());
|
||
}
|
||
}
|