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 clock;
22
23pub use clock::FightClock;
24
25use std::sync::Arc;
26
27use event_system::random::Seed;
28use rand::SeedableRng;
29
30use crate::{
31 BehaviorRegistry,
32 event::{OverlordEvent, PrepareFightType},
33 logic::handler::OverlordLogic,
34 state::OverlordState,
35};
36
37/// Ticker units per fight step. The system ticker counts milliseconds
38/// (`TICKER_UNIT_DURATION_MS = 1`), and the live game loop advances the
39/// ticker by 100 units per 100ms game tick — one step equals one game tick.
40pub const GAME_TICK_TICKS: u64 = 100;
41
42/// Default cap on events processed within a single step's cascade. The same
43/// wall the live `System` applies per root event — shared so the engine can
44/// never silently diverge from live combat on cascade depth.
45pub const DEFAULT_CASCADE_MAX_DEPTH: u32 = event_system::system::EVENT_SUBGRAPH_MAX_DEPTH;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum FightResult {
49 Win,
50 Loss,
51 /// The tick budget was exhausted before the fight ended. Callers decide
52 /// policy (PvP precalculation treats it as a loss).
53 Undecided,
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct FightOutcome {
58 pub result: FightResult,
59 /// Elapsed ticker units (1 unit = 1ms of game time).
60 pub duration_ticks: u64,
61}
62
63#[derive(Debug, thiserror::Error)]
64pub enum FightError {
65 #[error("prepare fight produced no active fight")]
66 NotPrepared,
67 #[error("fight event cascade exceeded max depth {max_depth}")]
68 CascadeDepthExceeded { max_depth: u32 },
69}
70
71/// A self-contained fight in progress. The scheduled work lives in the
72/// engine handler's [`FightClock`]; the sim carries the simulated state and
73/// logical time.
74pub struct FightSim {
75 state: OverlordState,
76 tick: u64,
77 ended: Option<FightResult>,
78}
79
80impl FightSim {
81 pub fn state(&self) -> &OverlordState {
82 &self.state
83 }
84
85 pub fn tick(&self) -> u64 {
86 self.tick
87 }
88}
89
90/// Bounded, deterministic driver for fight simulations.
91pub struct FightEngine {
92 handler: OverlordLogic,
93 seed: Seed,
94 ts: u64,
95 cascade_max_depth: u32,
96}
97
98impl FightEngine {
99 pub fn new(
100 game_config: configs::SharedGameConfig,
101 behaviors: Arc<BehaviorRegistry>,
102 seed: Seed,
103 ) -> Self {
104 Self {
105 // `frontend=true` turns case-opening events into no-op successes
106 // so simulations never mint items.
107 handler: OverlordLogic::new(game_config, behaviors, true),
108 seed,
109 ts: 0,
110 cascade_max_depth: DEFAULT_CASCADE_MAX_DEPTH,
111 }
112 }
113
114 /// Prepare a fight on a copy of the player's state. Any pre-existing
115 /// `active_fight` is discarded — the sim owns the whole fight lifecycle.
116 pub fn start(
117 &mut self,
118 mut state: OverlordState,
119 prepare_fight_type: PrepareFightType,
120 ) -> Result<FightSim, FightError> {
121 state.active_fight = None;
122 let mut sim = FightSim {
123 state,
124 tick: 0,
125 ended: None,
126 };
127
128 self.run_cascade(&mut sim, OverlordEvent::PrepareFight { prepare_fight_type })?;
129
130 if sim.state.active_fight.is_none() {
131 return Err(FightError::NotPrepared);
132 }
133
134 Ok(sim)
135 }
136
137 /// Advance the fight by exactly one game tick (100 ticker units).
138 /// Returns `Some(result)` once the fight has ended.
139 pub fn step(&mut self, sim: &mut FightSim) -> Result<Option<FightResult>, FightError> {
140 if let Some(result) = sim.ended {
141 return Ok(Some(result));
142 }
143
144 // Mirrors the live ticker pause: while the fight is paused the clock
145 // does not advance and nothing fires. The caller's step budget still
146 // shrinks, so a paused fight runs out of ticks instead of spinning.
147 if event_system::state::State::is_ticker_paused(&sim.state) {
148 return Ok(None);
149 }
150
151 sim.tick += GAME_TICK_TICKS;
152
153 // Same drain the live System does via run_plugins: due delayed
154 // combat events in (due_tick, seq) order, then the heartbeat.
155 let due = self.handler.collect_due_scheduled(sim.tick);
156
157 for event in due {
158 if let Some(result) = Self::try_finish(sim, &event) {
159 return Ok(Some(result));
160 }
161
162 self.run_cascade(sim, event)?;
163 if let Some(result) = sim.ended {
164 return Ok(Some(result));
165 }
166 }
167
168 Ok(None)
169 }
170
171 /// Run the fight to completion, executing at most `max_game_ticks` steps.
172 /// Total by construction: always returns, with `FightResult::Undecided`
173 /// when the budget is exhausted.
174 pub fn run(
175 &mut self,
176 state: OverlordState,
177 prepare_fight_type: PrepareFightType,
178 max_game_ticks: u64,
179 ) -> Result<FightOutcome, FightError> {
180 let mut sim = self.start(state, prepare_fight_type)?;
181
182 if let Some(result) = sim.ended {
183 return Ok(FightOutcome {
184 result,
185 duration_ticks: sim.tick,
186 });
187 }
188
189 for _ in 0..max_game_ticks {
190 if let Some(result) = self.step(&mut sim)? {
191 return Ok(FightOutcome {
192 result,
193 duration_ticks: sim.tick,
194 });
195 }
196 }
197
198 Ok(FightOutcome {
199 result: FightResult::Undecided,
200 duration_ticks: sim.tick,
201 })
202 }
203
204 /// The fight boundary: take the outcome from `EndFight` and mark the sim
205 /// ended without processing the event — progression side effects (rating,
206 /// vassals, chapter advance) belong to the live pipeline only. Single
207 /// policy point for both the clock-drain (`step`) and cascade paths.
208 fn try_finish(sim: &mut FightSim, event: &OverlordEvent) -> Option<FightResult> {
209 let OverlordEvent::EndFight { is_win, .. } = event else {
210 return None;
211 };
212 let result = if *is_win {
213 FightResult::Win
214 } else {
215 FightResult::Loss
216 };
217 sim.ended = Some(result);
218 Some(result)
219 }
220
221 /// Process one event and everything it spawns, depth-first — the
222 /// `EventSubgraph` semantics. Combat scheduling happens inside the
223 /// handlers via the fight clock; any delayed/cron marks still emitted by
224 /// non-combat handlers during a sim are routed onto the same clock
225 /// (delays) or dropped with a warning (crons — none exist in fight
226 /// scope, and a simulation has no business running unrelated crons).
227 fn run_cascade(&mut self, sim: &mut FightSim, root: OverlordEvent) -> Result<(), FightError> {
228 let mut stack = vec![root];
229 let mut processed: u32 = 0;
230
231 while let Some(event) = stack.pop() {
232 if Self::try_finish(sim, &event).is_some() {
233 return Ok(());
234 }
235
236 processed += 1;
237 if processed > self.cascade_max_depth {
238 return Err(FightError::CascadeDepthExceeded {
239 max_depth: self.cascade_max_depth,
240 });
241 }
242
243 let rng = rand::rngs::StdRng::seed_from_u64(self.seed.with_ts(self.ts));
244 let result = self
245 .handler
246 .handle_event(&event, sim.state.clone(), rng, sim.tick);
247 self.ts += 1;
248
249 let (_success, mut new_state, out_events) = result.into_state_and_events();
250
251 // Mirror System::set_state: recompute derived fields on change
252 // (player power/attributes feed back into the fight entity).
253 let mut next_events: Vec<OverlordEvent> = Vec::new();
254 if new_state != sim.state {
255 next_events = self.handler.compute_fields(&mut new_state, &sim.state);
256 }
257 sim.state = new_state;
258
259 for pluginized in out_events {
260 let (event, delayed, cron) = pluginized.into_parts();
261 if let Some(delayed) = delayed {
262 self.handler.fight_clock.schedule(event, delayed.ticks);
263 } else if cron.is_some() {
264 tracing::warn!("Ignoring cron mark for {event} inside fight simulation");
265 } else {
266 next_events.push(event);
267 }
268 }
269
270 // LIFO stack: push reversed so events run in emission order,
271 // children before siblings (EventSubgraph::add_events).
272 stack.extend(next_events.into_iter().rev());
273 }
274
275 Ok(())
276 }
277}