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 crate::event::OverlordEvent;
21
22#[derive(Debug, Clone)]
23struct Scheduled {
24    due_tick: u64,
25    /// Insertion order — deterministic tie-break for equal `due_tick`.
26    seq: u64,
27    event: OverlordEvent,
28}
29
30impl PartialEq for Scheduled {
31    fn eq(&self, other: &Self) -> bool {
32        self.due_tick == other.due_tick && self.seq == other.seq
33    }
34}
35
36impl Eq for Scheduled {}
37
38impl PartialOrd for Scheduled {
39    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
40        Some(self.cmp(other))
41    }
42}
43
44impl Ord for Scheduled {
45    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
46        (self.due_tick, self.seq).cmp(&(other.due_tick, other.seq))
47    }
48}
49
50#[derive(Debug, Clone)]
51struct Heartbeat {
52    event: OverlordEvent,
53    rate_ticks: u64,
54    last_emitted: Option<u64>,
55}
56
57#[derive(Debug, Default, Clone)]
58pub struct FightClock {
59    /// Current logical tick, stamped by the handler on every event.
60    now: u64,
61    pending: BinaryHeap<Reverse<Scheduled>>,
62    next_seq: u64,
63    heartbeat: Option<Heartbeat>,
64}
65
66impl FightClock {
67    /// Stamp the current tick. Called by the event handler before dispatch so
68    /// `schedule` resolves relative delays against the right base.
69    pub fn set_now(&mut self, tick: u64) {
70        self.now = tick;
71    }
72
73    /// Schedule `event` to fire `delay_ticks` after the current tick.
74    pub fn schedule(&mut self, event: OverlordEvent, delay_ticks: u64) {
75        self.pending.push(Reverse(Scheduled {
76            due_tick: self.now + delay_ticks,
77            seq: self.next_seq,
78            event,
79        }));
80        self.next_seq += 1;
81    }
82
83    /// Install (or replace) the recurring combat heartbeat. Fires immediately
84    /// on the next collection, then every `rate_ticks`.
85    pub fn set_heartbeat(&mut self, event: OverlordEvent, rate_ticks: u64) {
86        self.heartbeat = Some(Heartbeat {
87            event,
88            rate_ticks,
89            last_emitted: None,
90        });
91    }
92
93    /// Drop all pending events and the heartbeat. Called when a new fight is
94    /// prepared so nothing from the previous fight leaks into it.
95    pub fn clear(&mut self) {
96        self.pending.clear();
97        self.heartbeat = None;
98    }
99
100    /// Drain everything due at `tick`: pending events in `(due_tick, seq)`
101    /// order, then the heartbeat if its interval elapsed.
102    pub fn collect_due(&mut self, tick: u64) -> Vec<OverlordEvent> {
103        self.now = tick;
104
105        let mut due = Vec::new();
106        while self
107            .pending
108            .peek()
109            .is_some_and(|Reverse(s)| s.due_tick <= tick)
110        {
111            due.push(self.pending.pop().unwrap().0.event);
112        }
113
114        if let Some(heartbeat) = &mut self.heartbeat
115            && heartbeat
116                .last_emitted
117                .is_none_or(|last| tick.saturating_sub(last) >= heartbeat.rate_ticks)
118        {
119            due.push(heartbeat.event.clone());
120            heartbeat.last_emitted = Some(tick);
121        }
122
123        due
124    }
125}