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}