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}