//! The engine (spec §10): a fixed-tick, deterministic [`Encounter`] loop that //! enforces the **canonical within-tick resolution order** — itself a determinism //! invariant, because reordering changes outcomes (regen-before-damage yields //! fewer staggers; sequential hits differ from batched). //! //! ```text //! 0. Controllers decide (ascending actor-id) → pay cost, open cooldowns/windows, //! enqueue instant hits / start casts. //! 1. Expire finishing statuses, dazes, and defense windows (inclusive of tick t). //! 2. Resolve casts completing this tick → enqueue their hits / install statuses. //! 3. Apply queued hits sequentially in ascending source-id order — each followed //! immediately by its stagger check and fatigue add. //! 4. Tick DoT/HoT and other per-tick effects. //! 5. Apply regen (clamped to cap). //! ``` //! //! A fight is a pure function of `(rules, seed, actors, controllers)`. Every roll //! is a `root_rng.fork("actor{id}:{kind}:tick{t}")`, so adding an effect to one //! ability never reshuffles another actor's or tick's stream (§10). use crate::actor::{Actor, ActorId, HitOutcome, Status, StatusKind}; use crate::ability::Ability; use crate::config::Rules; use crate::controller::{Action, Controller, View}; use crate::effect::{EffectKind, EffectSpec, Valence}; use crate::event::{Event, FightMetrics, FightRecorder, Outcome, BlockedReason}; use crate::fixed::{apply_bp, Milli}; use crate::mitigation::{self, MitigationInputs, MitigationStage}; use crate::rng::Rng; /// Knobs for a single fight that aren't balance (those live in [`Rules`]). #[derive(Clone, Copy, Debug)] pub struct EncounterOptions { /// Hard cap on ticks; reaching it is a `Timeout` outcome. pub max_ticks: u64, /// Retain the raw event log (for inspecting one fight; off for sweeps). pub log_events: bool, /// Sample cap/fill time series every N ticks (0 = off). pub sample_every: u32, } impl Default for EncounterOptions { fn default() -> Self { Self { max_ticks: 6_000, log_events: false, sample_every: 0 } } } /// A resolved instant magnitude waiting for sequential application at step 3. #[derive(Clone, Debug)] struct PendingHit { source: ActorId, source_eff_level: i32, target: ActorId, ability_id: String, seq: u64, payload: HitPayload, } #[derive(Clone, Debug)] enum HitPayload { /// Fill damage, pre-con/pre-mitigation (potency already folded). Fill { pre_mitigation: Milli }, /// Direct fatigue (a curse), pre-con-resist. Ceiling { magnitude: Milli }, /// Fatigue healed (friendly, no resist). Recovery { magnitude: Milli }, /// Instant fill restore (friendly). Heal { magnitude: Milli }, } /// The fight. pub struct Encounter { pub actors: Vec, controllers: Vec>, rules: Rules, root_rng: Rng, tick: u64, opts: EncounterOptions, recorder: FightRecorder, pending: Vec, seq: u64, } impl Encounter { /// Build an encounter. `actors` must carry dense ids `0..n` matching their /// index, paired with a controller each. pub fn new( actors: Vec, controllers: Vec>, rules: Rules, seed: u64, opts: EncounterOptions, ) -> Self { assert_eq!(actors.len(), controllers.len(), "one controller per actor"); for (i, a) in actors.iter().enumerate() { assert_eq!(a.id as usize, i, "actor ids must be dense 0..n in index order"); } let recorder = FightRecorder::new(&actors, seed, rules.tick_rate, opts.log_events, opts.sample_every); Self { actors, controllers, rules, root_rng: Rng::from_seed(seed), tick: 0, opts, recorder, pending: Vec::new(), seq: 0, } } /// Run to resolution and return the rolled-up metrics. pub fn run(mut self) -> FightMetrics { loop { self.step(); if let Some(outcome) = self.resolution() { return self.recorder.finish(self.tick, outcome, &self.actors); } if self.tick >= self.opts.max_ticks { return self.recorder.finish(self.tick, Outcome::Timeout, &self.actors); } self.tick += 1; } } /// Run and also return the raw event log (only populated if `log_events`). pub fn run_with_events(mut self) -> (FightMetrics, Vec) { let metrics = loop { self.step(); if let Some(outcome) = self.resolution() { break self.recorder.clone().finish(self.tick, outcome, &self.actors); } if self.tick >= self.opts.max_ticks { break self.recorder.clone().finish(self.tick, Outcome::Timeout, &self.actors); } self.tick += 1; }; let events = self.recorder.into_events(); (metrics, events) } /// Advance exactly one tick, for a real-time front-end driving the engine on /// a wall clock (spec §10). Returns `Some(outcome)` the tick the fight /// resolves (or times out); `None` while it continues. Idempotent once /// resolved — calling again past resolution is a no-op returning the outcome. pub fn tick_once(&mut self) -> Option { if let Some(o) = self.resolution() { return Some(o); } self.step(); if let Some(o) = self.resolution() { return Some(o); } if self.tick >= self.opts.max_ticks { return Some(Outcome::Timeout); } self.tick += 1; None } /// The current tick index (for a front-end clock / display). pub fn current_tick(&self) -> u64 { self.tick } /// Drain events recorded since the last drain (empty unless `log_events`). /// Lets a front-end pull per-tick events for damage numbers / log lines /// without retaining the whole fight. pub fn drain_events(&mut self) -> Vec { self.recorder.take_events() } /// One full tick through the canonical order. fn step(&mut self) { self.pending.clear(); self.decide_and_commit(); // step 0 self.expire(); // step 1 self.resolve_casts(); // step 2 self.apply_hits(); // step 3 self.tick_over_time(); // step 4 self.apply_regen(); // step 5 self.sample(); } // --- step 0 ------------------------------------------------------------ fn decide_and_commit(&mut self) { let n = self.actors.len(); // Pass 1: ALL controllers decide against the *same* start-of-tick snapshot // (no commits happen yet). Within one 0.1s tick combatants act // simultaneously — a higher-id actor must not get sub-tick reaction to a // lower-id actor's spend, or a perfect mirror match would be unfair. let mut decisions: Vec<(ActorId, Action)> = Vec::with_capacity(n); for id in 0..n as ActorId { let idx = id as usize; if !self.actors[idx].is_active() { continue; // unconscious/dead: out of the fight } if !self.actors[idx].can_act() { // Dazed or mid-cast: no fresh decision. Dazed = blocked; casting // proceeds toward step 2. if matches!(self.actors[idx].stagger, crate::actor::StaggerState::Dazed { .. }) { self.recorder.blocked(self.tick, id, "", BlockedReason::Dazed); } continue; } // Global cooldown / swing timer: still mid-recovery from the last // action, so no new one can start this tick (§10 attack delay). if self.tick < self.actors[idx].next_action_at { continue; } let action = { let view = View { me: &self.actors[idx], actors: &self.actors, tick: self.tick, rules: &self.rules, }; let mut rng = self.root_rng.fork(&format!("actor{id}:decide:tick{}", self.tick)); self.controllers[idx].decide(&view, &mut rng) }; decisions.push((id, action)); } // Pass 2: commit in ascending id order (payment + cast-start are still // ordered, the §10 step-0 invariant — only the *decision* is simultaneous). for (id, action) in decisions { match action { Action::Idle => self.recorder.idle(id), Action::Use { ability_id, targets } => self.commit_use(id, &ability_id, targets), } } } fn commit_use(&mut self, source: ActorId, ability_id: &str, targets: Vec) { let idx = source as usize; let Some(ability) = self.actors[idx].ability(ability_id).cloned() else { // Controller named an ability not in the loadout — treat as a no-target // misfire so it's visible rather than silent. self.recorder.blocked(self.tick, source, ability_id, BlockedReason::NoTarget); return; }; if self.actors[idx].on_cooldown(&ability.id, self.tick) { self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::OnCooldown); return; } if !self.actors[idx].can_afford(ability.cost) { // The §6 affordability gate: heavy hitters fall away as the ceiling // grinds down. This is a real "wanted to act but couldn't" signal. self.recorder.blocked(self.tick, source, &ability.id, BlockedReason::CantAfford); return; } // Pay at cast-start (§6): fizzle/interrupt later = paid for nothing. let fill_before = self.actors[idx].vigor; self.actors[idx].pay(ability.cost); self.recorder.spent(source, ability.cost.fill); // Open the global cooldown — no new action until it elapses. self.actors[idx].next_action_at = self.tick + self.rules.global_cooldown as u64; if ability.cooldown > 0 { self.actors[idx] .cooldowns .insert(ability.id.clone(), self.tick + ability.cooldown as u64); } self.recorder.action(self.tick, &self.actors[idx], &ability.id, fill_before); self.classify_action(source, &ability); if ability.cast_time == 0 { // Instant: resolve now (step 0). Self-buffs install immediately so a // block protects this very tick; hits queue for step 3. self.resolve_ability(source, &ability, &targets); } else { let completes_at = self.tick + ability.cast_time as u64; self.actors[idx].casting = Some(crate::actor::Cast { ability_id: ability.id.clone(), targets, completes_at, }); self.recorder.cast_started(self.tick, source, &ability.id, completes_at); } } fn classify_action(&mut self, source: ActorId, ability: &Ability) { if ability.is_support() { self.recorder.recovery_used(source); } else if ability.cost.fill == 0 && ability.is_offensive() { self.recorder.auto_attack(source); } else { self.recorder.ability_used(source); } } // --- step 1 ------------------------------------------------------------ fn expire(&mut self) { let tick = self.tick; for a in &mut self.actors { a.expire_statuses(tick); a.expire_daze(tick); } } // --- step 2 ------------------------------------------------------------ fn resolve_casts(&mut self) { let n = self.actors.len(); for id in 0..n as ActorId { let idx = id as usize; let due = self.actors[idx] .casting .as_ref() .filter(|c| c.completes_at == self.tick) .map(|c| (c.ability_id.clone(), c.targets.clone())); if let Some((ability_id, targets)) = due { self.actors[idx].casting = None; if let Some(ability) = self.actors[idx].ability(&ability_id).cloned() { self.resolve_ability(id, &ability, &targets); } } } } /// Roll fizzle, then route every effect: install timed statuses immediately, /// queue instant magnitudes for step 3. fn resolve_ability(&mut self, source: ActorId, ability: &Ability, targets: &[ActorId]) { // Fizzle (§9): competency vs difficulty. Paid for nothing on failure. let comp = self.actors[source as usize].competency_in(&ability.school); let chance = self.rules.fizzle_chance_bp(comp, ability.difficulty); if chance > 0 { let mut rng = self .root_rng .fork(&format!("actor{source}:fizzle:tick{}", self.tick)); if rng.chance_bp(chance) { self.recorder.fizzle(self.tick, source, &ability.id); return; } } let eff_level = self.actors[source as usize] .effective_level(&ability.school, self.rules.competency_level_per_point_bp); for (order, effect) in ability.effects.iter().enumerate() { let chosen = self.select_targets(source, ability, effect, targets); for target in chosen { self.route_effect(source, eff_level, target, ability, effect, order as u64); } } } /// Pick targets for one effect by valence, honoring the controller's request /// and topping up to `ability.targets` from the appropriate side. fn select_targets( &self, source: ActorId, ability: &Ability, effect: &EffectSpec, requested: &[ActorId], ) -> Vec { let want = ability.targets.max(1) as usize; let me = &self.actors[source as usize]; let mut out: Vec = Vec::new(); match effect.valence() { Valence::Friendly => { // Self first, then requested allies, then nearest allies. let push = |id: ActorId, out: &mut Vec| { if out.len() < want && !out.contains(&id) { out.push(id); } }; // Honor explicit friendly requests (e.g. heal an ally), else self. for &r in requested { if let Some(t) = self.actors.get(r as usize) { if t.team == me.team && t.is_alive() { push(r, &mut out); } } } push(source, &mut out); for a in &self.actors { if a.team == me.team && a.is_alive() { push(a.id, &mut out); } } } Valence::Hostile => { let push = |id: ActorId, out: &mut Vec| { if out.len() < want && !out.contains(&id) { out.push(id); } }; for &r in requested { if let Some(t) = self.actors.get(r as usize) { if t.team != me.team && t.is_active() { push(r, &mut out); } } } // Top up with the most-vulnerable enemies (lowest cap). let mut enemies: Vec<&Actor> = self .actors .iter() .filter(|a| a.team != me.team && a.is_active()) .collect(); enemies.sort_by_key(|a| (a.cap(), a.vigor, a.id)); for a in enemies { push(a.id, &mut out); } } } out } /// Apply a status immediately or queue an instant magnitude. fn route_effect( &mut self, source: ActorId, source_eff_level: i32, target: ActorId, ability: &Ability, effect: &EffectSpec, order: u64, ) { let potency = ability.potency_bp; let tmax = self.actors[target as usize].max_vigor; let scaled = |bp: i64| apply_bp(tmax, apply_bp(bp, potency)); let _ = order; match effect.kind { EffectKind::FillDamage { magnitude_bp } => { let payload = HitPayload::Fill { pre_mitigation: scaled(magnitude_bp) }; let seq = self.next_seq(); self.queue(PendingHit { source, source_eff_level, target, ability_id: ability.id.clone(), seq, payload, }); } EffectKind::CeilingDamage { magnitude_bp } => { let payload = HitPayload::Ceiling { magnitude: scaled(magnitude_bp) }; let seq = self.next_seq(); self.queue(PendingHit { source, source_eff_level, target, ability_id: ability.id.clone(), seq, payload, }); } EffectKind::Recovery { magnitude_bp } => { let payload = HitPayload::Recovery { magnitude: scaled(magnitude_bp) }; let seq = self.next_seq(); self.queue(PendingHit { source, source_eff_level, target, ability_id: ability.id.clone(), seq, payload, }); } EffectKind::Hot { per_tick_bp, duration: 0 } => { let payload = HitPayload::Heal { magnitude: scaled(per_tick_bp) }; let seq = self.next_seq(); self.queue(PendingHit { source, source_eff_level, target, ability_id: ability.id.clone(), seq, payload, }); } // --- timed statuses: install now, tick later --- EffectKind::Dot { per_tick_bp, duration } => self.install( target, StatusKind::Dot { per_tick_bp: apply_bp(per_tick_bp, potency), source_eff_level }, duration, source, ), EffectKind::Hot { per_tick_bp, duration } => self.install( target, StatusKind::Hot { per_tick_bp: apply_bp(per_tick_bp, potency) }, duration, source, ), EffectKind::RegenSabotage { reduce_bp, duration } => { self.install(target, StatusKind::RegenSabotage { reduce_bp }, duration, source) } EffectKind::FatigueAmp { amp_bp, duration } => { self.install(target, StatusKind::FatigueAmp { amp_bp }, duration, source) } EffectKind::MitigationBuff { stage, amount_bp, duration } => self.install( target, StatusKind::Mitigation { stage, amount_bp: apply_bp(amount_bp, potency) }, duration, source, ), EffectKind::MitigationDebuff { stage, amount_bp, duration } => self.install( target, // A debuff strips mitigation → stored negative so the pipeline // amplifies the hit (a vulnerability/sunder). StatusKind::Mitigation { stage, amount_bp: -apply_bp(amount_bp, potency) }, duration, source, ), } } fn install(&mut self, target: ActorId, kind: StatusKind, duration: u32, source: ActorId) { if duration == 0 { return; } let expires_at = self.tick + duration as u64 - 1; self.actors[target as usize].add_status(Status { kind, expires_at, source }); } fn queue(&mut self, hit: PendingHit) { self.pending.push(hit); } fn next_seq(&mut self) -> u64 { self.seq += 1; self.seq } /// Apply deterministic ± damage variance to a magnitude. Returns the input /// unchanged when variance is disabled. Keyed by `(source, target, seq, tick)` /// so it never reshuffles another hit's stream (§10). fn varied(&self, amount: Milli, source: ActorId, target: ActorId, seq: u64) -> Milli { let var = self.rules.damage_variance_bp; if var <= 0 { return amount; } let mut rng = self.root_rng.fork(&format!( "hit:src{source}:tgt{target}:seq{seq}:tick{}", self.tick )); // Uniform factor in [BP_ONE - var, BP_ONE + var]. let factor = (crate::fixed::BP_ONE - var) + rng.range(0, (2 * var + 1) as i32) as i64; apply_bp(amount, factor) } // --- step 3 ------------------------------------------------------------ fn apply_hits(&mut self) { // Sequential in ascending source-id order, stable within a source: hit 1 // lowers fill before hit 2's stagger check (§10). let mut pending = std::mem::take(&mut self.pending); pending.sort_by_key(|h| (h.source, h.seq)); for h in pending { self.apply_one(h); } } fn apply_one(&mut self, h: PendingHit) { let tidx = h.target as usize; if self.actors[tidx].stagger.is_dead() { return; } match h.payload { HitPayload::Fill { pre_mitigation } => { // Per-hit damage variance (deterministic, keyed by the hit). The // genre's damage range; also what desyncs a mirror match. let pre_mitigation = self.varied(pre_mitigation, h.source, h.target, h.seq); let con = self .rules .con_mult_bp(h.source_eff_level, self.actors[tidx].level); let inp = MitigationInputs { armor_reduce_bp: self.actors[tidx].armor_bp, con_mult_bp: con, active_defense_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::ActiveDefense), stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance), buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff), }; let mit = mitigation::run(pre_mitigation, inp); // Capture cast-in-progress to detect interruption. let was_casting = self.actors[tidx] .casting .as_ref() .map(|c| c.ability_id.clone()); let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger); self.recorder .hit(self.tick, h.source, &h.ability_id, &self.actors[tidx], mit, outcome); if matches!(outcome, HitOutcome::Dazed | HitOutcome::Unconscious | HitOutcome::Killed) { if let Some(cast_id) = was_casting { if self.actors[tidx].casting.is_none() { self.recorder.interrupted(self.tick, h.target, &cast_id); } } } if matches!(outcome, HitOutcome::Killed) { self.recorder.death(self.tick, h.target); } } HitPayload::Ceiling { magnitude } => { // Con effectiveness gates how much of the curse lands (§7). let con = self .rules .con_mult_bp(h.source_eff_level, self.actors[tidx].level); let landed = apply_bp(magnitude, con); let before_down = !self.actors[tidx].is_active(); self.actors[tidx].add_fatigue(landed); self.recorder .ceiling_damage(self.tick, h.source, h.target, &h.ability_id, landed); if !before_down && !self.actors[tidx].is_active() { // Collapse from the curse counts as a death-adjacent event only // if it actually killed; unconscious is logged via state. if self.actors[tidx].stagger.is_dead() { self.recorder.death(self.tick, h.target); } } } HitPayload::Recovery { magnitude } => { let was_down = !self.actors[tidx].is_active(); self.actors[tidx] .recover_fatigue(magnitude, self.tick, self.rules.revive_daze); let revived = was_down && self.actors[tidx].is_active(); self.recorder .recovery(self.tick, h.source, h.target, &h.ability_id, magnitude, revived); } HitPayload::Heal { magnitude } => { let was_down = !self.actors[tidx].is_active(); self.actors[tidx] .restore_fill(magnitude, self.tick, self.rules.revive_daze); let revived = was_down && self.actors[tidx].is_active(); self.recorder.fill_restored(h.target, magnitude); if revived { self.recorder .recovery(self.tick, h.source, h.target, &h.ability_id, 0, true); } } } } // --- step 4 ------------------------------------------------------------ fn tick_over_time(&mut self) { let n = self.actors.len(); for tidx in 0..n { if self.actors[tidx].stagger.is_dead() { continue; } // Snapshot the over-time statuses to apply (avoid borrow conflicts). let tmax = self.actors[tidx].max_vigor; let tlevel = self.actors[tidx].level; let mut dots: Vec<(Milli, i32, ActorId)> = Vec::new(); let mut hots: Vec = Vec::new(); for s in &self.actors[tidx].statuses { match s.kind { StatusKind::Dot { per_tick_bp, source_eff_level } => { dots.push((apply_bp(tmax, per_tick_bp), source_eff_level, s.source)); } StatusKind::Hot { per_tick_bp } => { hots.push(apply_bp(tmax, per_tick_bp)); } _ => {} } } for (pre, src_level, src) in dots { let con = self.rules.con_mult_bp(src_level, tlevel); let inp = MitigationInputs { armor_reduce_bp: self.actors[tidx].armor_bp, con_mult_bp: con, active_defense_reduce_bp: 0, // a poison isn't parried stance_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Stance), buff_reduce_bp: self.actors[tidx].mitigation_at(MitigationStage::Buff), }; let mit = mitigation::run(pre, inp); let outcome = self.actors[tidx].take_hit(mit.landed, self.tick, &self.rules.stagger); self.recorder .hit(self.tick, src, "dot", &self.actors[tidx], mit, outcome); if matches!(outcome, HitOutcome::Killed) { self.recorder.death(self.tick, self.actors[tidx].id); } } for amount in hots { self.actors[tidx] .restore_fill(amount, self.tick, self.rules.revive_daze); self.recorder.fill_restored(self.actors[tidx].id, amount); } } } // --- step 5 ------------------------------------------------------------ fn apply_regen(&mut self) { for a in &mut self.actors { let before = a.vigor; a.regen_tick(true, &self.rules.regen); let gained = a.vigor - before; if gained > 0 { self.recorder.regen(a.id, gained); } } } fn sample(&mut self) { let tick = self.tick; // Snapshot ids first to avoid borrowing actors while recorder mutates. for i in 0..self.actors.len() { let snapshot = self.actors[i].clone(); self.recorder.sample(tick, &snapshot); } } // --- resolution -------------------------------------------------------- /// `Some(outcome)` once the fight is decided: one team holds the only /// conscious actor(s) (win), or none are conscious (draw). fn resolution(&self) -> Option { let mut teams_up: Vec = Vec::new(); for a in &self.actors { if a.is_active() && !teams_up.contains(&a.team) { teams_up.push(a.team); } } match teams_up.len() { 0 => Some(Outcome::Draw), 1 => Some(Outcome::Win { team: teams_up[0] }), _ => None, } } } #[cfg(test)] mod tests { use super::*; use crate::ability::{AbilitySpec, Delivery, RawCost}; use crate::actor::{compile_actor, StatBlock}; use crate::config::CombatConfig; use crate::effect::EffectSpec; use std::collections::BTreeMap; fn swing_spec() -> AbilitySpec { AbilitySpec { id: "swing".into(), name: "Swing".into(), school: "martial".into(), delivery: Delivery::Melee, effects: vec![EffectSpec::new(EffectKind::FillDamage { magnitude_bp: 1_000 }, 0)], potency_bp: 10_000, targets: 1, cast_time: 0, cooldown: 10, cost_override: Some(RawCost { fill: 0, fatigue: 1 }), fatigue_units: None, difficulty: None, } } fn fighter(name: &str, team: u8, level: i32) -> StatBlock { StatBlock { name: name.into(), team, level, max_vigor: 100, regen_idle_per_sec: 10, regen_combat_per_sec: 5, armor_bp: 0, competency: BTreeMap::new(), loadout: vec!["swing".into()], start_vigor_bp: 10_000, start_fatigue_bp: 0, } } fn build(cfg: &CombatConfig, blocks: &[StatBlock]) -> (Vec, Rules) { let rules = cfg.compile(); let actors = blocks .iter() .enumerate() .map(|(i, sb)| { let (loadout, missing) = rules.resolve_loadout(&sb.loadout); assert!(missing.is_empty(), "missing abilities: {missing:?}"); compile_actor(i as ActorId, sb, loadout, rules.tick_rate) }) .collect(); (actors, rules) } #[test] fn deterministic_even_fight_resolves() { let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() }; let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 10)]; let (actors, rules) = build(&cfg, &blocks); let controllers: Vec> = vec![ Box::new(crate::controller::SwingOnCooldown), Box::new(crate::controller::SwingOnCooldown), ]; let enc = Encounter::new(actors, controllers, rules, 42, EncounterOptions::default()); let m = enc.run(); // Two identical swingers: it ends (someone goes down) and doesn't time out. assert!(matches!(m.outcome, Outcome::Win { .. } | Outcome::Draw)); assert!(m.ticks < 6_000); } #[test] fn same_seed_same_outcome() { let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() }; let blocks = vec![fighter("A", 0, 10), fighter("B", 1, 12)]; let run_once = |seed: u64| { let (actors, rules) = build(&cfg, &blocks); let controllers: Vec> = vec![ Box::new(crate::controller::SwingOnCooldown), Box::new(crate::controller::SwingOnCooldown), ]; Encounter::new(actors, controllers, rules, seed, EncounterOptions::default()).run() }; let a = run_once(7); let b = run_once(7); assert_eq!(a.ticks, b.ticks); assert_eq!(format!("{:?}", a.outcome), format!("{:?}", b.outcome)); assert_eq!(a.actors[0].fill_damage_dealt, b.actors[0].fill_damage_dealt); } #[test] fn higher_con_attacker_wins() { let cfg = CombatConfig { abilities: vec![swing_spec()], ..Default::default() }; // +5 levels of con advantage should win reliably. let blocks = vec![fighter("Strong", 0, 15), fighter("Weak", 1, 10)]; let (actors, rules) = build(&cfg, &blocks); let controllers: Vec> = vec![ Box::new(crate::controller::SwingOnCooldown), Box::new(crate::controller::SwingOnCooldown), ]; let m = Encounter::new(actors, controllers, rules, 1, EncounterOptions::default()).run(); assert_eq!(m.outcome, Outcome::Win { team: 0 }); } }