//! 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 { 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> { 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 = (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()); }