overlord_event_system/fight/
mod.rs

1//! Deterministic, bounded fight simulation engine.
2//!
3//! Combat owns its scheduling: delayed combat events and the `FightProgress`
4//! heartbeat live in the [`FightClock`] inside `OverlordLogic`,
5//! not in the `System` delayed/cron plugins. The live game loop drains the
6//! clock through `OverlordLogic::collect_due_scheduled`; this engine
7//! drains the same clock directly. Both paths therefore run *identical*
8//! combat machinery — the only difference is the driver.
9//!
10//! Engine properties:
11//!
12//! - `FightEngine::run` executes at most `max_game_ticks` steps and always
13//!   returns — there is no wall-clock timeout and no way to loop forever.
14//!   A fight that doesn't finish within the budget is `FightResult::Undecided`.
15//! - No DB, no async, no `System`, no `PureEventHandler`: callers are
16//!   PvP precalculation, balance simulations, and tests.
17//! - `EndFight` is the fight boundary: the outcome is taken from the event
18//!   and it is *not* processed, so progression side effects (rating,
19//!   vassals, chapter advance) never run inside a simulation.
20
21mod breakdown;
22mod clock;
23mod metrics;
24
25pub use breakdown::{BreakdownAccumulator, BreakdownActor};
26pub use clock::FightClock;
27pub use metrics::{
28    DISPERSION_IDLE_SHARE_THRESHOLD_PCT, EntityMetrics, FightMetrics, FightMetricsSummary,
29    STUN_UNTIL_TICK_ATTR, TeamMetrics,
30};
31
32use std::sync::Arc;
33
34use event_system::random::Seed;
35use rand::SeedableRng;
36
37use crate::{
38    BehaviorRegistry,
39    event::{OverlordEvent, PrepareFightType},
40    logic::handler::OverlordLogic,
41    state::OverlordState,
42};
43
44/// Ticker units per fight step. The system ticker counts milliseconds
45/// (`TICKER_UNIT_DURATION_MS = 1`), and the live game loop advances the
46/// ticker by 100 units per 100ms game tick — one step equals one game tick.
47pub const GAME_TICK_TICKS: u64 = 100;
48
49/// Default cap on events processed within a single step's cascade. The same
50/// wall the live `System` applies per root event — shared so the engine can
51/// never silently diverge from live combat on cascade depth.
52pub const DEFAULT_CASCADE_MAX_DEPTH: u32 = event_system::system::EVENT_SUBGRAPH_MAX_DEPTH;
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum FightResult {
56    Win,
57    Loss,
58    /// The tick budget was exhausted before the fight ended. Callers decide
59    /// policy (PvP precalculation treats it as a loss).
60    Undecided,
61}
62
63#[derive(Debug, Clone, Copy)]
64pub struct FightOutcome {
65    pub result: FightResult,
66    /// Elapsed ticker units (1 unit = 1ms of game time).
67    pub duration_ticks: u64,
68}
69
70#[derive(Debug, thiserror::Error)]
71pub enum FightError {
72    #[error("prepare fight produced no active fight")]
73    NotPrepared,
74    #[error("fight event cascade exceeded max depth {max_depth}")]
75    CascadeDepthExceeded { max_depth: u32 },
76}
77
78/// A self-contained fight in progress. The scheduled work lives in the
79/// engine handler's [`FightClock`]; the sim carries the simulated state and
80/// logical time.
81pub struct FightSim {
82    state: OverlordState,
83    tick: u64,
84    ended: Option<FightResult>,
85    /// Combat-feel metrics. A pure observer that lives outside `state`, so it
86    /// never perturbs the simulation (see [`FightMetrics`]).
87    metrics: FightMetrics,
88}
89
90impl FightSim {
91    pub fn state(&self) -> &OverlordState {
92        &self.state
93    }
94
95    pub fn tick(&self) -> u64 {
96        self.tick
97    }
98
99    /// Mutable access to the simulated state, for installing a **frozen
100    /// reference build** between [`FightEngine::start`] and the first step.
101    ///
102    /// The A2-BAL-001 calibration needs a player whose strength is the authored
103    /// `S_ref` for the chapter rather than whatever gear a run happened to roll
104    /// — otherwise `D_old / D_new_unscaled` measures the loot, not the change.
105    /// Mutating a fight that has already been stepped is not supported: the
106    /// engine's determinism guarantee covers `start` → `run`, not arbitrary
107    /// edits in between.
108    pub fn state_mut(&mut self) -> &mut OverlordState {
109        &mut self.state
110    }
111
112    /// The raw combat-feel metrics collected so far.
113    pub fn metrics(&self) -> &FightMetrics {
114        &self.metrics
115    }
116
117    /// Structured, serde-serializable combat-feel summary (per-entity, per-team,
118    /// hero breakout, dispersion). The hero is the fight's `player_id`.
119    pub fn metrics_summary(&self) -> FightMetricsSummary {
120        let player_id = self.state.active_fight.as_ref().map(|f| f.player_id);
121        self.metrics.summary(player_id)
122    }
123}
124
125/// Bounded, deterministic driver for fight simulations.
126pub struct FightEngine {
127    handler: OverlordLogic,
128    /// Shared with `handler`; kept here so the metrics collector can reach the
129    /// content lookups (ability range/target-type) when sampling. Read-only.
130    behaviors: Arc<BehaviorRegistry>,
131    seed: Seed,
132    ts: u64,
133    cascade_max_depth: u32,
134}
135
136impl FightEngine {
137    pub fn new(
138        game_config: configs::SharedGameConfig,
139        behaviors: Arc<BehaviorRegistry>,
140        seed: Seed,
141    ) -> Self {
142        Self {
143            // `frontend=true` turns case-opening events into no-op successes
144            // so simulations never mint items.
145            handler: OverlordLogic::new(game_config, behaviors.clone(), true),
146            behaviors,
147            seed,
148            ts: 0,
149            cascade_max_depth: DEFAULT_CASCADE_MAX_DEPTH,
150        }
151    }
152
153    /// Per-source damage/heal breakdown of the fight this engine last ran.
154    ///
155    /// The engine stops at `EndFight` without processing it, so the summary
156    /// never reaches `OverlordState` here — this is how a simulation, a PvP
157    /// precalculation or a test reads it.
158    pub fn breakdown(
159        &self,
160        outcome: FightOutcome,
161    ) -> Option<essences::fight_breakdown::FightBreakdown> {
162        self.handler.fight_breakdown(
163            matches!(outcome.result, FightResult::Win),
164            outcome.duration_ticks,
165        )
166    }
167
168    /// Prepare a fight on a copy of the player's state. Any pre-existing
169    /// `active_fight` is discarded — the sim owns the whole fight lifecycle.
170    pub fn start(
171        &mut self,
172        mut state: OverlordState,
173        prepare_fight_type: PrepareFightType,
174    ) -> Result<FightSim, FightError> {
175        state.active_fight = None;
176        let mut sim = FightSim {
177            state,
178            tick: 0,
179            ended: None,
180            metrics: FightMetrics::default(),
181        };
182
183        // Draw from the fight's own RNG stream — the very stream the live fight
184        // replays. `OverlordLogic::event_rng` only switches onto it once a seed
185        // is armed and `pre_event` consumes it on the PvP `PrepareFight`;
186        // without arming here the engine silently falls back to the driver's
187        // `with_ts` stream, so the precalculated outcome the player has already
188        // been booked diverges from the fight they then watch. Gated to PvP to
189        // mirror `pre_event`'s own gating — a PvE sim keeps the driver stream,
190        // exactly as before.
191        if matches!(prepare_fight_type, PrepareFightType::PVPFight { .. }) {
192            self.handler.arm_fight_seed(self.seed.clone());
193        }
194
195        self.run_cascade(&mut sim, OverlordEvent::PrepareFight { prepare_fight_type })?;
196
197        if sim.state.active_fight.is_none() {
198            return Err(FightError::NotPrepared);
199        }
200
201        Ok(sim)
202    }
203
204    /// Advance the fight by exactly one game tick (100 ticker units).
205    /// Returns `Some(result)` once the fight has ended.
206    pub fn step(&mut self, sim: &mut FightSim) -> Result<Option<FightResult>, FightError> {
207        if let Some(result) = sim.ended {
208            return Ok(Some(result));
209        }
210
211        // Mirrors the live ticker pause: while the fight is paused the clock
212        // does not advance and nothing fires. The caller's step budget still
213        // shrinks, so a paused fight runs out of ticks instead of spinning.
214        if event_system::state::State::is_ticker_paused(&sim.state) {
215            return Ok(None);
216        }
217
218        sim.tick += GAME_TICK_TICKS;
219
220        // Same drain the live System does via run_plugins: due delayed
221        // combat events in (due_tick, seq) order, then the heartbeat.
222        let due = self.handler.collect_due_scheduled(sim.tick);
223
224        for event in due {
225            if let Some(result) = Self::try_finish(sim, &event) {
226                return Ok(Some(result));
227            }
228
229            self.run_cascade(sim, event)?;
230            if let Some(result) = sim.ended {
231                return Ok(Some(result));
232            }
233        }
234
235        // Once per game tick, after the tick's events have settled and while the
236        // fight is still ongoing: sample body-occupancy / idle-in-contact. Pure
237        // read of the just-computed state (see `FightMetrics`).
238        sim.metrics
239            .sample(&sim.state, self.behaviors.lookups(), sim.tick);
240
241        Ok(None)
242    }
243
244    /// Run the fight to completion, executing at most `max_game_ticks` steps.
245    /// Total by construction: always returns, with `FightResult::Undecided`
246    /// when the budget is exhausted.
247    pub fn run(
248        &mut self,
249        state: OverlordState,
250        prepare_fight_type: PrepareFightType,
251        max_game_ticks: u64,
252    ) -> Result<FightOutcome, FightError> {
253        let mut sim = self.start(state, prepare_fight_type)?;
254        self.run_prepared(&mut sim, max_game_ticks)
255    }
256
257    /// Drive an already-[`start`](Self::start)ed fight to completion.
258    ///
259    /// Split out of [`run`](Self::run) so a caller that must touch the prepared
260    /// state before the first step — the A2-BAL-001 calibration installing a
261    /// frozen reference build, see [`FightSim::state_mut`] — still goes through
262    /// the same bounded, deterministic loop rather than reimplementing it.
263    pub fn run_prepared(
264        &mut self,
265        sim: &mut FightSim,
266        max_game_ticks: u64,
267    ) -> Result<FightOutcome, FightError> {
268        if let Some(result) = sim.ended {
269            return Ok(FightOutcome {
270                result,
271                duration_ticks: sim.tick,
272            });
273        }
274
275        for _ in 0..max_game_ticks {
276            if let Some(result) = self.step(sim)? {
277                return Ok(FightOutcome {
278                    result,
279                    duration_ticks: sim.tick,
280                });
281            }
282        }
283
284        Ok(FightOutcome {
285            result: FightResult::Undecided,
286            duration_ticks: sim.tick,
287        })
288    }
289
290    /// The fight boundary: take the outcome from `EndFight` and mark the sim
291    /// ended without processing the event — progression side effects (rating,
292    /// vassals, chapter advance) belong to the live pipeline only. Single
293    /// policy point for both the clock-drain (`step`) and cascade paths.
294    fn try_finish(sim: &mut FightSim, event: &OverlordEvent) -> Option<FightResult> {
295        let OverlordEvent::EndFight { is_win, .. } = event else {
296            return None;
297        };
298        let result = if *is_win {
299            FightResult::Win
300        } else {
301            FightResult::Loss
302        };
303        sim.ended = Some(result);
304        Some(result)
305    }
306
307    /// Process one event and everything it spawns, depth-first — the
308    /// `EventSubgraph` semantics. Combat scheduling happens inside the
309    /// handlers via the fight clock; any delayed/cron marks still emitted by
310    /// non-combat handlers during a sim are routed onto the same clock
311    /// (delays) or dropped with a warning (crons — none exist in fight
312    /// scope, and a simulation has no business running unrelated crons).
313    fn run_cascade(&mut self, sim: &mut FightSim, root: OverlordEvent) -> Result<(), FightError> {
314        let mut stack = vec![root];
315        let mut processed: u32 = 0;
316
317        while let Some(event) = stack.pop() {
318            if Self::try_finish(sim, &event).is_some() {
319                return Ok(());
320            }
321
322            processed += 1;
323            if processed > self.cascade_max_depth {
324                return Err(FightError::CascadeDepthExceeded {
325                    max_depth: self.cascade_max_depth,
326                });
327            }
328
329            // Observe this event against the pre-handler state (whiffs,
330            // hits-while-casting). Read-only — does not touch the RNG stream,
331            // emit events, or mutate `sim.state`.
332            sim.metrics.observe_event(&event, &sim.state);
333
334            let rng = rand::rngs::StdRng::seed_from_u64(self.seed.with_ts(self.ts));
335            let result = self
336                .handler
337                .handle_event(&event, sim.state.clone(), rng, sim.tick);
338            self.ts += 1;
339
340            let (_success, mut new_state, out_events) = result.into_state_and_events();
341
342            // Mirror System::set_state: recompute derived fields on change
343            // (player power/attributes feed back into the fight entity).
344            let mut next_events: Vec<OverlordEvent> = Vec::new();
345            if new_state != sim.state {
346                next_events = self.handler.compute_fields(&mut new_state, &sim.state);
347            }
348            sim.state = new_state;
349
350            for pluginized in out_events {
351                let (event, delayed, cron) = pluginized.into_parts();
352                if let Some(delayed) = delayed {
353                    self.handler.fight_clock.schedule(event, delayed.ticks);
354                } else if cron.is_some() {
355                    tracing::warn!("Ignoring cron mark for {event} inside fight simulation");
356                } else {
357                    next_events.push(event);
358                }
359            }
360
361            // LIFO stack: push reversed so events run in emission order,
362            // children before siblings (EventSubgraph::add_events).
363            stack.extend(next_events.into_iter().rev());
364        }
365
366        Ok(())
367    }
368}