Skip to main content

zzstat/
resource.rs

1//! Resource pool management and time-based effects.
2//!
3//! Provides a `ResourcePool` to track stateful values like Current HP or MP,
4//! along with Damage over Time (DoT) and Heal over Time (HoT) effects.
5
6use crate::context::StatContext;
7use crate::numeric::StatNumeric;
8use crate::resolver::StatResolver;
9use crate::stat_id::StatId;
10use serde::{Deserialize, Serialize};
11
12/// Condition for a threshold trigger.
13#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
14pub enum TriggerCondition {
15    /// Triggers when the resource falls strictly below this value.
16    Below(f64),
17    /// Triggers when the resource rises strictly above this value.
18    Above(f64),
19    /// Triggers when the resource is exactly 0 (or lower).
20    Empty,
21    /// Triggers when the resource is exactly at its max capacity (or higher).
22    Full,
23}
24
25/// A trigger that emits an event when a condition is met.
26#[derive(Serialize, Deserialize, Debug, Clone)]
27pub struct ThresholdTrigger {
28    pub condition: TriggerCondition,
29    pub event_name: String,
30}
31
32/// A time-based effect applied to a resource.
33#[derive(Serialize, Deserialize, Debug, Clone)]
34pub struct TimeEffect {
35    /// A unique identifier for this effect (e.g., "Poison", "Regen").
36    pub name: String,
37    /// The amount to change the resource per tick.
38    /// Negative values reduce the resource (DoT), positive increase it (HoT).
39    pub amount_per_tick: f64,
40    /// How many ticks this effect will last.
41    pub ticks_remaining: u32,
42}
43
44/// Emitted when a threshold trigger condition is met.
45#[derive(Debug, Clone, PartialEq)]
46pub struct ResourceEvent {
47    pub event_name: String,
48}
49
50/// A stateful resource pool (e.g., Current HP) bounded by a maximum stat.
51#[derive(Debug, Clone)]
52pub struct ResourcePool {
53    /// The stat ID that defines the maximum capacity of this pool (e.g., "MAX_HP").
54    pub max_stat_id: StatId,
55    /// The current value of the resource.
56    pub current_value: f64,
57    /// Active time-based effects.
58    pub active_effects: Vec<TimeEffect>,
59    /// Triggers for this resource.
60    pub triggers: Vec<ThresholdTrigger>,
61}
62
63impl ResourcePool {
64    /// Creates a new ResourcePool starting at full capacity based on the current resolver state.
65    pub fn new(max_stat_id: StatId, resolver: &mut StatResolver, context: &StatContext) -> Self {
66        let max_val = resolver
67            .resolve(&max_stat_id, context)
68            .map(|r| r.value.to_f64())
69            .unwrap_or(0.0);
70
71        Self {
72            max_stat_id,
73            current_value: max_val,
74            active_effects: Vec::new(),
75            triggers: Vec::new(),
76        }
77    }
78
79    /// Adds a time effect (DoT or HoT) to the pool.
80    pub fn add_effect(&mut self, effect: TimeEffect) {
81        self.active_effects.push(effect);
82    }
83
84    /// Adds a threshold trigger to the pool.
85    pub fn add_trigger(&mut self, trigger: ThresholdTrigger) {
86        self.triggers.push(trigger);
87    }
88
89    /// Explicitly applies direct damage to the resource.
90    /// Returns any events triggered by the change.
91    pub fn apply_damage(
92        &mut self,
93        amount: f64,
94        resolver: &mut StatResolver,
95        context: &StatContext,
96    ) -> Vec<ResourceEvent> {
97        // Damage reduces the value, so delta is negative
98        self.change_value(-amount, resolver, context)
99    }
100
101    /// Explicitly applies direct healing to the resource.
102    /// Returns any events triggered by the change.
103    pub fn apply_heal(
104        &mut self,
105        amount: f64,
106        resolver: &mut StatResolver,
107        context: &StatContext,
108    ) -> Vec<ResourceEvent> {
109        self.change_value(amount, resolver, context)
110    }
111
112    /// Advances the pool by one tick, applying active effects.
113    /// Returns any events triggered by the state change.
114    pub fn tick(
115        &mut self,
116        resolver: &mut StatResolver,
117        context: &StatContext,
118    ) -> Vec<ResourceEvent> {
119        let mut total_change = 0.0;
120
121        self.active_effects.retain_mut(|effect| {
122            if effect.ticks_remaining > 0 {
123                total_change += effect.amount_per_tick;
124                effect.ticks_remaining -= 1;
125                effect.ticks_remaining > 0
126            } else {
127                false
128            }
129        });
130
131        if total_change != 0.0 {
132            self.change_value(total_change, resolver, context)
133        } else {
134            // Even if no time effects changed the value, check triggers
135            // in case max capacity changed or existing state meets conditions.
136            // BUG FIX: we must still clamp the value in case max_capacity changed!
137            self.change_value(0.0, resolver, context)
138        }
139    }
140
141    /// Internal function to apply a delta and check triggers.
142    fn change_value(
143        &mut self,
144        delta: f64,
145        resolver: &mut StatResolver,
146        context: &StatContext,
147    ) -> Vec<ResourceEvent> {
148        let max_val = resolver
149            .resolve(&self.max_stat_id, context)
150            .map(|r| r.value.to_f64())
151            .unwrap_or(0.0);
152
153        self.current_value = (self.current_value + delta).clamp(0.0, max_val);
154        self.check_triggers(resolver, context)
155    }
156
157    /// Evaluates all triggers against the current state.
158    fn check_triggers(
159        &self,
160        resolver: &mut StatResolver,
161        context: &StatContext,
162    ) -> Vec<ResourceEvent> {
163        let mut events = Vec::new();
164        let max_val = resolver
165            .resolve(&self.max_stat_id, context)
166            .map(|r| r.value.to_f64())
167            .unwrap_or(0.0);
168
169        for trigger in &self.triggers {
170            let is_met = match trigger.condition {
171                TriggerCondition::Below(threshold) => self.current_value < threshold,
172                TriggerCondition::Above(threshold) => self.current_value > threshold,
173                TriggerCondition::Empty => self.current_value <= 0.0,
174                TriggerCondition::Full => self.current_value >= max_val,
175            };
176
177            if is_met {
178                events.push(ResourceEvent {
179                    event_name: trigger.event_name.clone(),
180                });
181            }
182        }
183
184        events
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::source::ConstantSource;
192
193    #[test]
194    fn test_resource_pool_tick_and_triggers() {
195        let mut resolver = StatResolver::new();
196        let context = StatContext::new();
197        let max_hp_id = StatId::from("MAX_HP");
198
199        // Set MAX_HP to 100
200        resolver.register_source(max_hp_id.clone(), Box::new(ConstantSource(100.0)));
201
202        let mut pool = ResourcePool::new(max_hp_id, &mut resolver, &context);
203        assert_eq!(pool.current_value, 100.0);
204
205        // Add a trigger for Death
206        pool.add_trigger(ThresholdTrigger {
207            condition: TriggerCondition::Empty,
208            event_name: "DEATH".to_string(),
209        });
210
211        // Add a Poison DoT: -10 HP per tick for 3 ticks
212        pool.add_effect(TimeEffect {
213            name: "Poison".to_string(),
214            amount_per_tick: -10.0,
215            ticks_remaining: 3,
216        });
217
218        // Tick 1
219        let events = pool.tick(&mut resolver, &context);
220        assert_eq!(pool.current_value, 90.0);
221        assert!(events.is_empty());
222
223        // Tick 2
224        pool.tick(&mut resolver, &context);
225        assert_eq!(pool.current_value, 80.0);
226
227        // Tick 3
228        pool.tick(&mut resolver, &context);
229        assert_eq!(pool.current_value, 70.0);
230
231        // Tick 4 (Effect should be gone)
232        pool.tick(&mut resolver, &context);
233        assert_eq!(pool.current_value, 70.0); // Remains 70
234
235        // Apply fatal damage
236        let events = pool.apply_damage(100.0, &mut resolver, &context);
237        assert_eq!(pool.current_value, 0.0);
238        assert_eq!(events.len(), 1);
239        assert_eq!(events[0].event_name, "DEATH");
240    }
241
242    #[test]
243    fn test_resource_pool_healing_and_full() {
244        let mut resolver = StatResolver::new();
245        let context = StatContext::new();
246        let max_hp_id = StatId::from("MAX_HP");
247
248        resolver.register_source(max_hp_id.clone(), Box::new(ConstantSource(100.0)));
249
250        let mut pool = ResourcePool::new(max_hp_id, &mut resolver, &context);
251        pool.apply_damage(50.0, &mut resolver, &context); // Drop to 50
252        assert_eq!(pool.current_value, 50.0);
253
254        pool.add_trigger(ThresholdTrigger {
255            condition: TriggerCondition::Full,
256            event_name: "FULLY_HEALED".to_string(),
257        });
258
259        // Regen HoT: +20 HP per tick for 3 ticks
260        pool.add_effect(TimeEffect {
261            name: "Regen".to_string(),
262            amount_per_tick: 20.0,
263            ticks_remaining: 3,
264        });
265
266        // Tick 1 (70)
267        pool.tick(&mut resolver, &context);
268        // Tick 2 (90)
269        pool.tick(&mut resolver, &context);
270        // Tick 3 (100 - capped)
271        let events = pool.tick(&mut resolver, &context);
272        assert_eq!(pool.current_value, 100.0);
273
274        assert_eq!(events.len(), 1);
275        assert_eq!(events[0].event_name, "FULLY_HEALED");
276    }
277}