reikhelm/combat/combat-core/tests/determinism.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

115 lines
4.3 KiB
Rust

//! End-to-end determinism + public-API smoke tests (spec §10).
//!
//! The whole analysis story rests on one guarantee: a fight is a bit-reproducible
//! function of `(rules, seed, actors, controllers)`. These tests drive the crate
//! exactly as `combat-sim` does and assert that guarantee at the event-log level.
use combat_core::actor::{compile_actor, StatBlock};
use combat_core::config::CombatConfig;
use combat_core::controller::{Controller, HoldAndPunish, ScriptedPlayer};
use combat_core::engine::{Encounter, EncounterOptions};
use combat_core::{Actor, ActorId};
use std::collections::BTreeMap;
fn duelist(name: &str, team: u8, level: i32) -> StatBlock {
let mut competency = BTreeMap::new();
competency.insert("martial".to_string(), 50);
competency.insert("restoration".to_string(), 30);
StatBlock {
name: name.into(),
team,
level,
max_vigor: 120,
regen_idle_per_sec: 12,
regen_combat_per_sec: 6,
armor_bp: 1500,
competency,
loadout: vec![
"auto_attack".into(),
"quick_jab".into(),
"heavy_strike".into(),
"second_wind".into(),
"guard".into(),
],
start_vigor_bp: 10_000,
start_fatigue_bp: 0,
}
}
fn config() -> CombatConfig {
let json = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../configs/default.json"))
.expect("default config readable");
serde_json::from_str(&json).expect("default config parses")
}
fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> Vec<Actor> {
let rules = cfg.compile();
blocks
.iter()
.enumerate()
.map(|(i, sb)| {
let (loadout, missing) = rules.resolve_loadout(&sb.loadout);
assert!(missing.is_empty(), "config loadout has unknown abilities: {missing:?}");
compile_actor(i as ActorId, sb, loadout, rules.tick_rate)
})
.collect()
}
fn controllers() -> Vec<Box<dyn Controller>> {
vec![Box::new(ScriptedPlayer::new(80)), Box::new(HoldAndPunish::default())]
}
#[test]
fn full_fight_event_log_is_bit_reproducible() {
let cfg = config();
let blocks = vec![duelist("P", 0, 10), duelist("E", 1, 12)];
let opts = EncounterOptions { max_ticks: 6_000, log_events: true, sample_every: 0 };
let run = || {
let actors = build(&cfg, &blocks);
Encounter::new(actors, controllers(), cfg.compile(), 12345, opts).run_with_events()
};
let (m1, e1) = run();
let (m2, e2) = run();
assert_eq!(m1.ticks, m2.ticks);
assert_eq!(format!("{:?}", m1.outcome), format!("{:?}", m2.outcome));
// Event logs must be identical event-for-event — the strongest determinism
// claim the crate makes.
let j1 = serde_json::to_string(&e1).unwrap();
let j2 = serde_json::to_string(&e2).unwrap();
assert_eq!(j1, j2, "event logs diverged under identical inputs");
assert!(!e1.is_empty(), "a real fight should emit events");
}
#[test]
fn different_seeds_can_diverge() {
let cfg = config();
let blocks = vec![duelist("P", 0, 10), duelist("E", 1, 10)];
let opts = EncounterOptions::default();
let outcome_for = |seed: u64| {
let actors = build(&cfg, &blocks);
let m = Encounter::new(actors, controllers(), cfg.compile(), seed, opts).run();
(m.ticks, format!("{:?}", m.outcome))
};
// Across many seeds an even-con fight produces a spread of durations (not a
// single fixed value), evidence the RNG actually drives variety.
let durations: std::collections::BTreeSet<u64> =
(0..40).map(|s| outcome_for(s).0).collect();
assert!(durations.len() > 3, "expected varied fight lengths across seeds");
}
#[test]
fn config_round_trips_and_compiles() {
let cfg = config();
let rules = cfg.compile();
// The shipped library resolves and the headline abilities exist.
for id in ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard", "fireball"] {
assert!(rules.abilities.contains_key(id), "missing ability {id}");
}
// Re-serialize and re-parse to confirm the config is stable through serde.
let j = serde_json::to_string(&cfg).unwrap();
let back: CombatConfig = serde_json::from_str(&j).unwrap();
assert_eq!(back.compile().abilities.len(), rules.abilities.len());
}