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>
115 lines
4.3 KiB
Rust
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());
|
|
}
|