overlord_event_system/fight/clock.rs
1//! Combat scheduler owned by the fight logic itself.
2//!
3//! Replaces the `System` delayed/cron plugins for everything combat: delayed
4//! combat events (cast wind-ups, projectile flight, `StartFight`/`EndFight`
5//! grace periods, the next `PrepareFight`) live in a due-tick heap, and the
6//! `FightProgress` cadence is the heartbeat. The clock is owned by
7//! `OverlordLogic`, so the live `System` loop and the
8//! `FightEngine` drain the exact same scheduling state — combat behaves
9//! identically no matter which loop pumps it.
10//!
11//! Same-due-tick events fire in insertion order (deterministic refinement of
12//! the old `DelayedPlugin`'s unspecified heap order); the heartbeat fires
13//! after due delayed events, mirroring the old delayed-then-cron plugin
14//! order. `clear` is called when a new fight is prepared, so stale events
15//! from a previous fight can never leak into the next one.
16
17use std::cmp::Reverse;
18use std::collections::BinaryHeap;
19
20use essences::combat_origin::CombatEventOrigin;
21
22use crate::event::OverlordEvent;
23
24#[derive(Debug, Clone)]
25struct Scheduled {
26 due_tick: u64,
27 /// Insertion order — deterministic tie-break for equal `due_tick`.
28 seq: u64,
29 event: OverlordEvent,
30}
31
32impl PartialEq for Scheduled {
33 fn eq(&self, other: &Self) -> bool {
34 self.due_tick == other.due_tick && self.seq == other.seq
35 }
36}
37
38impl Eq for Scheduled {}
39
40impl PartialOrd for Scheduled {
41 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
42 Some(self.cmp(other))
43 }
44}
45
46impl Ord for Scheduled {
47 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
48 (self.due_tick, self.seq).cmp(&(other.due_tick, other.seq))
49 }
50}
51
52#[derive(Debug, Clone)]
53struct Heartbeat {
54 event: OverlordEvent,
55 rate_ticks: u64,
56 last_emitted: Option<u64>,
57}
58
59#[derive(Debug, Clone)]
60pub struct FightClock {
61 /// Current logical tick, stamped by the handler on every event.
62 now: u64,
63 pending: BinaryHeap<Reverse<Scheduled>>,
64 next_seq: u64,
65 heartbeat: Option<Heartbeat>,
66 /// Provenance of the event currently being dispatched, armed by
67 /// `OverlordLogic::pre_event`. Handlers divert delayed work straight onto
68 /// this clock instead of returning it, so without this the result-level
69 /// restamp would miss every delayed child of a modifier's work.
70 dispatch_origin: CombatEventOrigin,
71}
72
73impl Default for FightClock {
74 fn default() -> Self {
75 Self {
76 now: 0,
77 pending: BinaryHeap::new(),
78 next_seq: 0,
79 heartbeat: None,
80 dispatch_origin: CombatEventOrigin::Core,
81 }
82 }
83}
84
85impl FightClock {
86 /// Stamp the current tick. Called by the event handler before dispatch so
87 /// `schedule` resolves relative delays against the right base.
88 pub fn set_now(&mut self, tick: u64) {
89 self.now = tick;
90 }
91
92 /// The tick the dispatch in progress is running at. `OverlordLogic::pre_event`
93 /// stamps it in every dispatch path, so a hook that needs the current tick
94 /// but does not take it as an argument (the stone triggers' per-slot
95 /// cooldown) reads it from here rather than growing a parameter through
96 /// `apply_success_hooks`.
97 pub fn now(&self) -> u64 {
98 self.now
99 }
100
101 /// Arm the provenance every subsequent `schedule` inherits. Called by
102 /// `OverlordLogic::pre_event` with the dispatching event's provenance and
103 /// reset to `Core` when the dispatch ends.
104 pub fn set_dispatch_origin(&mut self, origin: CombatEventOrigin) {
105 self.dispatch_origin = origin;
106 }
107
108 /// Schedule `event` to fire `delay_ticks` after the current tick.
109 ///
110 /// Inside a non-Core dispatch the event is stamped with that provenance —
111 /// the delayed half of the inheritance rule. Stamping only ever *upgrades*
112 /// (Core is the ambient default), so an event that was already marked by
113 /// its emitter, or by the result-level restamp, is never downgraded here.
114 pub fn schedule(&mut self, mut event: OverlordEvent, delay_ticks: u64) {
115 if !self.dispatch_origin.is_core() {
116 event.set_origin(self.dispatch_origin);
117 }
118 self.pending.push(Reverse(Scheduled {
119 due_tick: self.now + delay_ticks,
120 seq: self.next_seq,
121 event,
122 }));
123 self.next_seq += 1;
124 }
125
126 /// Install (or replace) the recurring combat heartbeat. Fires immediately
127 /// on the next collection, then every `rate_ticks`.
128 pub fn set_heartbeat(&mut self, event: OverlordEvent, rate_ticks: u64) {
129 self.heartbeat = Some(Heartbeat {
130 event,
131 rate_ticks,
132 last_emitted: None,
133 });
134 }
135
136 /// Drop all pending events and the heartbeat. Called when a new fight is
137 /// prepared so nothing from the previous fight leaks into it.
138 pub fn clear(&mut self) {
139 self.pending.clear();
140 self.heartbeat = None;
141 }
142
143 /// Drain everything due at `tick`: pending events in `(due_tick, seq)`
144 /// order, then the heartbeat if its interval elapsed.
145 pub fn collect_due(&mut self, tick: u64) -> Vec<OverlordEvent> {
146 self.now = tick;
147
148 let mut due = Vec::new();
149 while self
150 .pending
151 .peek()
152 .is_some_and(|Reverse(s)| s.due_tick <= tick)
153 {
154 due.push(self.pending.pop().unwrap().0.event);
155 }
156
157 if let Some(heartbeat) = &mut self.heartbeat
158 && heartbeat
159 .last_emitted
160 .is_none_or(|last| tick.saturating_sub(last) >= heartbeat.rate_ticks)
161 {
162 due.push(heartbeat.event.clone());
163 heartbeat.last_emitted = Some(tick);
164 }
165
166 due
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn damage(origin: CombatEventOrigin) -> OverlordEvent {
175 OverlordEvent::Damage {
176 by_entity_id: None,
177 entity_id: uuid::Uuid::from_u128(1),
178 damage: 1,
179 damage_data: Default::default(),
180 origin,
181 source: essences::fight_breakdown::CombatSource::Other,
182 }
183 }
184
185 /// The delayed half of the inheritance rule: handlers divert work straight
186 /// onto the clock instead of returning it, so the clock is what stamps it.
187 #[test]
188 fn scheduling_inside_a_non_core_dispatch_marks_the_event() {
189 let mut clock = FightClock::default();
190 clock.set_dispatch_origin(CombatEventOrigin::Proc);
191 clock.schedule(damage(CombatEventOrigin::Core), 100);
192 clock.set_dispatch_origin(CombatEventOrigin::Core);
193
194 let due = clock.collect_due(100);
195 assert_eq!(due.len(), 1);
196 assert_eq!(due[0].combat_origin(), Some(CombatEventOrigin::Proc));
197 }
198
199 /// Stamping only ever upgrades. An event already marked by its emitter must
200 /// survive a Core dispatch scheduling it — the engine drains the clock and
201 /// re-schedules after `post_event` has already disarmed it.
202 #[test]
203 fn a_core_dispatch_never_downgrades_an_already_marked_event() {
204 let mut clock = FightClock::default();
205 clock.set_dispatch_origin(CombatEventOrigin::Core);
206 clock.schedule(damage(CombatEventOrigin::Proc), 100);
207
208 let due = clock.collect_due(100);
209 assert_eq!(due[0].combat_origin(), Some(CombatEventOrigin::Proc));
210 }
211
212 /// A Core dispatch is the ambient case: nothing is restamped.
213 #[test]
214 fn a_core_dispatch_leaves_core_events_alone() {
215 let mut clock = FightClock::default();
216 clock.schedule(damage(CombatEventOrigin::Core), 100);
217
218 let due = clock.collect_due(100);
219 assert_eq!(due[0].combat_origin(), Some(CombatEventOrigin::Core));
220 }
221}