essences/
mana.rs

1use crate::prelude::*;
2
3/// Fight-local mana of one combatant.
4///
5/// Character combatants (hero, party ally, human PvP opponent) carry `Some`;
6/// mobs carry `None` and are never mana-gated. The pool is refilled to `max` at
7/// every fight start and is not carried between fights — outside a fight there
8/// is no mana at all, so nothing can spend it.
9///
10/// `current` is a float because regen is a per-second rate applied on the
11/// millisecond fight clock; `updated_tick` makes the accrual drift-free and
12/// independent of the heartbeat cadence (see [`ManaPool::regen_to`]).
13#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
14#[tsify(from_wasm_abi, into_wasm_abi)]
15pub struct ManaPool {
16    /// Full pool size, from `mana_settings.pool`.
17    pub max: f64,
18    /// Points available right now.
19    pub current: f64,
20    /// Points restored per second of fight time, from `mana_settings.regen_per_second`.
21    pub regen_per_second: f64,
22    /// Ticker unit the pool was last accrued at (1 unit = 1 ms).
23    pub updated_tick: u64,
24}
25
26impl PartialEq for ManaPool {
27    fn eq(&self, other: &Self) -> bool {
28        self.max.to_bits() == other.max.to_bits()
29            && self.current.to_bits() == other.current.to_bits()
30            && self.regen_per_second.to_bits() == other.regen_per_second.to_bits()
31            && self.updated_tick == other.updated_tick
32    }
33}
34
35impl Eq for ManaPool {}
36
37impl ManaPool {
38    /// A full pool at fight start.
39    pub fn full(max: f64, regen_per_second: f64, current_tick: u64) -> Self {
40        let max = if max.is_finite() && max > 0.0 {
41            max
42        } else {
43            0.0
44        };
45        let regen_per_second = if regen_per_second.is_finite() && regen_per_second > 0.0 {
46            regen_per_second
47        } else {
48            0.0
49        };
50        Self {
51            max,
52            current: max,
53            regen_per_second,
54            updated_tick: current_tick,
55        }
56    }
57
58    /// Accrues regen for the time elapsed since the last accrual and clamps to
59    /// `max`. Idempotent within one tick, so both the fight heartbeat and the
60    /// cast path can call it freely.
61    pub fn regen_to(&mut self, current_tick: u64) {
62        let elapsed_ms = current_tick.saturating_sub(self.updated_tick);
63        self.updated_tick = current_tick;
64        if elapsed_ms == 0 || self.regen_per_second <= 0.0 {
65            return;
66        }
67        let gained = self.regen_per_second * (elapsed_ms as f64) / 1000.0;
68        self.current = (self.current + gained).min(self.max);
69    }
70
71    /// Spends `cost` if the pool covers it. Returns `false` (and changes
72    /// nothing) when it does not — the caller must then wait, not cast.
73    pub fn try_spend(&mut self, cost: f64) -> bool {
74        if !cost.is_finite() || cost <= 0.0 {
75            return true;
76        }
77        if self.current + f64::EPSILON < cost {
78            return false;
79        }
80        self.current = (self.current - cost).max(0.0);
81        true
82    }
83
84    /// Milliseconds of fight time until the pool covers `cost`.
85    ///
86    /// `Some(0)` when it already does. `None` when regen can never get there
87    /// (no regen at all, or a cost above the whole pool) — the caller decides
88    /// the fallback. This is what turns a starving ability's wait into ONE
89    /// scheduled retry instead of a 10 Hz poll of the actions queue.
90    pub fn ms_until_affordable(&self, cost: f64) -> Option<u64> {
91        if !cost.is_finite() || cost <= self.current {
92            return Some(0);
93        }
94        if self.regen_per_second <= 0.0 || cost > self.max {
95            return None;
96        }
97        let ms = (cost - self.current) / self.regen_per_second * 1000.0;
98        Some(ms.ceil().max(0.0) as u64)
99    }
100
101    /// Share of the pool that is currently missing, in `[0, 1]`. Feeds the
102    /// "Отдача" stone (damage scaling on missing mana).
103    pub fn missing_fraction(&self) -> f64 {
104        if self.max <= 0.0 {
105            return 0.0;
106        }
107        ((self.max - self.current) / self.max).clamp(0.0, 1.0)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn full_pool_starts_at_max() {
117        let pool = ManaPool::full(100.0, 5.0, 42);
118        assert_eq!(pool.current, 100.0);
119        assert_eq!(pool.max, 100.0);
120        assert_eq!(pool.updated_tick, 42);
121    }
122
123    #[test]
124    fn regen_accrues_by_elapsed_milliseconds_and_clamps() {
125        let mut pool = ManaPool::full(100.0, 10.0, 0);
126        assert!(pool.try_spend(50.0));
127        assert_eq!(pool.current, 50.0);
128
129        pool.regen_to(1_000);
130        assert_eq!(pool.current, 60.0);
131
132        pool.regen_to(100_000);
133        assert_eq!(pool.current, 100.0);
134    }
135
136    #[test]
137    fn spend_rejects_insufficient_mana_without_mutating() {
138        let mut pool = ManaPool::full(10.0, 0.0, 0);
139        assert!(pool.try_spend(10.0));
140        assert_eq!(pool.current, 0.0);
141        assert!(!pool.try_spend(1.0));
142        assert_eq!(pool.current, 0.0);
143    }
144
145    #[test]
146    fn wait_time_is_the_exact_refill_time() {
147        let mut pool = ManaPool::full(100.0, 10.0, 0);
148        assert!(pool.try_spend(100.0));
149
150        // Already affordable.
151        assert_eq!(pool.ms_until_affordable(0.0), Some(0));
152        // 25 mana at 10/s = 2.5s.
153        assert_eq!(pool.ms_until_affordable(25.0), Some(2_500));
154        // Rounded up to the next whole millisecond.
155        assert_eq!(pool.ms_until_affordable(0.0001), Some(1));
156        // Never affordable: more than the whole pool.
157        assert_eq!(pool.ms_until_affordable(101.0), None);
158
159        // No regen: no amount of waiting helps.
160        let no_regen = ManaPool::full(100.0, 0.0, 0);
161        assert_eq!(no_regen.ms_until_affordable(150.0), None);
162        assert_eq!(no_regen.ms_until_affordable(50.0), Some(0));
163    }
164
165    #[test]
166    fn missing_fraction_tracks_spent_share() {
167        let mut pool = ManaPool::full(200.0, 0.0, 0);
168        assert_eq!(pool.missing_fraction(), 0.0);
169        assert!(pool.try_spend(50.0));
170        assert_eq!(pool.missing_fraction(), 0.25);
171    }
172}