saptest/pets/
combat.rs

1use rand::{random, Rng, SeedableRng};
2use rand_chacha::ChaCha12Rng;
3
4use crate::{
5    effects::{
6        actions::Action,
7        effect::EffectModify,
8        state::{Outcome, Position, Status},
9        stats::Statistics,
10        trigger::*,
11    },
12    foods::names::FoodName,
13    pets::pet::{Pet, MAX_PET_STATS, MIN_PET_STATS},
14    Team,
15};
16
17use std::{
18    collections::VecDeque,
19    fmt::Display,
20    ops::Sub,
21    sync::{Arc, RwLock, Weak},
22};
23
24/// The minimum damage any attack can do.
25pub const MIN_DMG: isize = 1;
26/// The maximum damage any attack can do.
27pub const MAX_DMG: isize = 150;
28const FULL_DMG_NEG_ITEMS: [FoodName; 2] = [FoodName::Coconut, FoodName::Melon];
29const ALLOWED_FOOD_EFFECT_TRIGGER: [Status; 2] = [Status::AnyDmgCalc, Status::AttackDmgCalc];
30
31/// Gets the maximum damage a pet can receive.
32fn max_dmg_received(pet: &Pet) -> isize {
33    // If has coconut, maximum dmg is 0. Otherwise, the normal 150.
34    if pet.has_food_ability(&Action::Invincible, true) {
35        0
36    } else {
37        MAX_DMG
38    }
39}
40
41/// Calculate minimum damage that a pet can receive.
42/// * `1` is the default.
43fn min_dmg_received(pet: &Pet) -> isize {
44    // If has melon or coconut, minimum dmg can be 0, Otherwise, should be 1.
45    if pet
46        .item
47        .as_ref()
48        .map_or(false, |food| FULL_DMG_NEG_ITEMS.contains(&food.name))
49    {
50        0
51    } else {
52        MIN_DMG
53    }
54}
55
56/// Final damage calculation considering death's touch and endure actions.
57fn final_dmg_calculation(pet: &Pet, dmg: isize, enemy: &Pet) -> isize {
58    // Insta-kill if all apply:
59    // * Any amount of damage is dealt.
60    // * Enemy has death's touch.
61    // * Pet being attacked has more health than 1.
62    if dmg != 0 && enemy.has_food_ability(&Action::Kill, true) && pet.stats.health > 1 {
63        0
64    } else {
65        let health = pet.stats.health.sub(dmg);
66        // If has endure, stay alive at 1 health.
67        // Otherwise do normal damage calculation.
68        if pet.has_food_ability(&Action::Endure, true) {
69            health.clamp(1, MAX_PET_STATS)
70        } else {
71            health.clamp(MIN_PET_STATS, MAX_PET_STATS)
72        }
73    }
74}
75
76/// Implements combat mechanics for a single [`Pet`].
77pub trait PetCombat {
78    /// Perform damage calculation for a direct [`attack`](crate::PetCombat::attack) returning new health for self and opponent.
79    /// # Example
80    /// ```
81    /// use saptest::{Pet, PetName, PetCombat};
82    /// let (ant_1, ant_2) = (
83    ///     Pet::try_from(PetName::Ant).unwrap(),
84    ///     Pet::try_from(PetName::Ant).unwrap()
85    /// );
86    /// let (new_ant_1_health, new_ant_2_health) = ant_1.calculate_new_health(&ant_2);
87    /// assert!(new_ant_1_health == 0 && new_ant_2_health == 0)
88    /// ```
89    fn calculate_new_health(&self, enemy: &Pet) -> (isize, isize);
90
91    /// Handle the logic of [`Pet`] interaction during the battle phase.
92    /// * Decrements a held [`Food`](crate::Food) uses.
93    /// # Example
94    /// ```
95    /// use saptest::{Pet, PetName, Food, FoodName, PetCombat};
96    ///
97    /// let (mut ant_1, mut ant_2) = (
98    ///     Pet::try_from(PetName::Ant).unwrap(),
99    ///     Pet::try_from(PetName::Ant).unwrap()
100    /// );
101    /// // Give first ant melon.
102    /// ant_1.item = Some(Food::try_from(FoodName::Melon).unwrap());
103    ///
104    /// // Original stats and effect uses.
105    /// assert!(ant_1.stats.health == 2 && ant_2.stats.health == 2);
106    /// assert_eq!(ant_1.item.as_ref().unwrap().ability.uses, Some(1));
107    ///
108    /// // Attack alters attack, health, and held item uses.
109    /// ant_1.attack(&mut ant_2);
110    /// assert!(ant_1.stats.health == 2 && ant_2.stats.health == 0);
111    /// assert_eq!(ant_1.item.as_ref().unwrap().ability.uses, Some(0));
112    /// ```
113    fn attack(&mut self, enemy: &mut Pet) -> AttackOutcome;
114
115    /// Perform a projectile/indirect attack on a [`Pet`].
116    /// * Health stat in [`Statistics`] is ignored.
117    /// # Examples
118    /// ```
119    /// use saptest::{Pet, PetName, PetCombat, Statistics};
120    ///
121    /// let mut ant = Pet::try_from(PetName::Ant).unwrap();
122    /// assert_eq!(ant.stats.health, 2);
123    ///
124    /// // Deal damage with attack value of 2.
125    /// ant.indirect_attack(&Statistics {attack: 2, health: 0});
126    ///
127    /// assert_eq!(ant.stats.health, 0);
128    /// ```
129    fn indirect_attack(&mut self, dmg: &Statistics) -> AttackOutcome;
130
131    /// Get triggers for both pets when health is altered.
132    /// # Example
133    /// ```
134    /// use saptest::{Pet, PetName, PetCombat, effects::trigger::TRIGGER_SELF_UNHURT};
135    ///
136    /// let mut ant_1 = Pet::try_from(PetName::Ant).unwrap();
137    /// // New health is identical.
138    /// let outcome = ant_1.get_atk_outcomes(2);
139    /// // Unhurt trigger for friends.
140    /// assert_eq!(
141    ///     outcome.friends.front().unwrap(),
142    ///     &TRIGGER_SELF_UNHURT
143    /// );
144    /// ```
145    fn get_atk_outcomes(&self, new_health: isize) -> AttackOutcome;
146
147    /// Gets the [`Statistic`](crate::Statistics) modifiers of held foods that alter a pet's stats during battle.
148    /// # Examples
149    /// ---
150    /// **Nothing** - Gives no additional stats in damage calculation.
151    /// ```
152    /// use saptest::{Pet, PetName, Statistics, PetCombat};
153    ///
154    /// let mut ant_1 = Pet::try_from(PetName::Ant).unwrap();
155    /// assert_eq!(
156    ///     ant_1.get_food_stat_modifier(),
157    ///     None
158    /// );
159    /// ```
160    /// ---
161    /// **Melon** - Gives `20` additional health in damage calculation.
162    /// ```
163    /// use saptest::{Pet, PetName, Food, FoodName, Statistics, PetCombat};
164    ///
165    /// let mut ant_1 = Pet::try_from(PetName::Ant).unwrap();
166    /// ant_1.item = Some(Food::try_from(FoodName::Melon).unwrap());
167    /// assert_eq!(
168    ///     ant_1.get_food_stat_modifier(),
169    ///     Some(Statistics::new(0, 20).unwrap())
170    /// );
171    /// ```
172    /// ---
173    /// **MeatBone** - Gives `3` additional attack in damage calculation.
174    /// ```
175    /// use saptest::{Pet, PetName, Food, FoodName, Statistics, PetCombat};
176    ///
177    /// let mut ant_1 = Pet::try_from(PetName::Ant).unwrap();
178    /// ant_1.item = Some(Food::try_from(FoodName::MeatBone).unwrap());
179    /// assert_eq!(
180    ///     ant_1.get_food_stat_modifier(),
181    ///     Some(Statistics::new(3, 0).unwrap())
182    /// );
183    /// ```
184    fn get_food_stat_modifier(&self) -> Option<Statistics>;
185
186    /// Check if a [`Pet`]'s [`Food`](crate::Food) has this [`Action`].
187    /// * Matches only on the enum variant.
188    /// # Example
189    /// ```
190    /// use saptest::{
191    ///     Pet, PetName, PetCombat,
192    ///     Food, FoodName, effects::actions::Action
193    /// };
194    /// let mut ant_1 = Pet::try_from(PetName::Ant).unwrap();
195    /// ant_1.item = Some(Food::try_from(FoodName::Peanut).unwrap());
196    ///
197    /// assert!(ant_1.has_food_ability(&Action::Kill, true))
198    /// ```
199    fn has_food_ability(&self, ability: &Action, check_uses: bool) -> bool;
200
201    /// Check if a [`Pet`]'s [`Effect`](crate::Effect) has this [`Action`].
202    /// * Matches only on the enum variant.
203    /// # Example
204    /// ```
205    /// use saptest::{
206    ///     Pet, PetName, PetCombat, Statistics,
207    ///     effects::actions::{Action, StatChangeType}
208    /// };
209    /// let mut ant_1 = Pet::try_from(PetName::Ant).unwrap();
210    /// let add_action = Action::Add(StatChangeType::Static(Statistics::new(2,1).unwrap()));
211    ///
212    /// assert!(ant_1.has_effect_ability(&add_action, true))
213    /// ```
214    fn has_effect_ability(&self, ability: &Action, check_uses: bool) -> bool;
215
216    /// Check if pet effect has effect trigger.
217    fn has_effect_trigger(&self, trigger: &Status, check_uses: bool) -> bool;
218}
219
220impl PetCombat for Pet {
221    fn indirect_attack(&mut self, dmg: &Statistics) -> AttackOutcome {
222        // If pet already dead, return early.
223        if self.stats.health == 0 {
224            return AttackOutcome::default();
225        }
226        // Get food status modifier. ex. Melon/Garlic
227        let stat_modifier = self.get_food_stat_modifier().unwrap_or_default();
228
229        let min_enemy_dmg = min_dmg_received(self);
230        let max_enemy_dmg = max_dmg_received(self);
231        let enemy_dmg = dmg
232            .attack
233            .sub(stat_modifier.health)
234            // Must do a minimum of 1 damage.
235            .clamp(min_enemy_dmg, max_enemy_dmg);
236
237        let mut new_health = self.stats.health.sub(enemy_dmg);
238
239        // Account for endure ability.
240        new_health = if self.has_food_ability(&Action::Endure, true) {
241            new_health.clamp(1, MAX_PET_STATS)
242        } else {
243            new_health.clamp(MIN_PET_STATS, MAX_PET_STATS)
244        };
245
246        // Reduce uses from ability if possible.
247        self.item.as_mut().map(|item| item.ability.remove_uses(1));
248
249        // Use health difference to determine outcome.
250        let mut outcome = self.get_atk_outcomes(new_health);
251
252        // If kill by indirect, still counts as knockout.
253        if new_health == 0 {
254            outcome.opponents.insert(0, TRIGGER_KNOCKOUT)
255        }
256
257        // Set new health.
258        self.stats.health = new_health.clamp(MIN_PET_STATS, MAX_PET_STATS);
259        outcome
260    }
261
262    fn get_atk_outcomes(&self, new_health: isize) -> AttackOutcome {
263        let health_diff = self
264            .stats
265            .health
266            .sub(new_health)
267            .clamp(MIN_PET_STATS, MAX_PET_STATS);
268        let health_diff_stats = Statistics {
269            health: health_diff,
270            attack: 0,
271        };
272        let mut outcomes: VecDeque<Outcome> = VecDeque::new();
273        let mut enemy_outcomes: VecDeque<Outcome> = VecDeque::new();
274
275        // If difference between health before and after battle is equal the before battle health,
276        // pet lost all health during fight and has fainted.
277        if health_diff == self.stats.health {
278            let [self_faint, any_faint, ahead_faint] =
279                get_self_faint_triggers(&Some(health_diff_stats));
280            let [mut spec_enemy_faint, enemy_any_faint] =
281                get_self_enemy_faint_triggers(&Some(health_diff_stats));
282            spec_enemy_faint.position = Position::Relative(self.pos.unwrap_or(0) as isize);
283
284            outcomes.extend([self_faint, any_faint, ahead_faint]);
285            enemy_outcomes.extend([spec_enemy_faint, enemy_any_faint]);
286        } else if health_diff == 0 {
287            // If original health - new health is 0, pet wasn't hurt.
288            let mut self_unhurt = TRIGGER_SELF_UNHURT;
289            self_unhurt.stat_diff = Some(health_diff_stats);
290
291            outcomes.push_back(self_unhurt)
292        } else {
293            // Otherwise, pet was hurt.
294            let mut self_hurt = TRIGGER_SELF_HURT;
295            let mut any_hurt = TRIGGER_ANY_HURT;
296            self_hurt.stat_diff = Some(health_diff_stats);
297            any_hurt.stat_diff = Some(health_diff_stats);
298
299            let enemy_any_hurt = TRIGGER_ANY_ENEMY_HURT;
300
301            outcomes.extend([self_hurt, any_hurt]);
302            enemy_outcomes.push_back(enemy_any_hurt)
303        };
304        AttackOutcome {
305            friends: outcomes,
306            opponents: enemy_outcomes,
307            friend_stat_change: health_diff_stats,
308            enemy_stat_change: Statistics::default(),
309        }
310    }
311
312    fn get_food_stat_modifier(&self) -> Option<Statistics> {
313        if let Some(food) = self.item.as_ref().filter(|food| {
314            food.ability.position == Position::OnSelf
315                && ALLOWED_FOOD_EFFECT_TRIGGER.contains(&food.ability.trigger.status)
316        }) {
317            let food_effect = if let Some(n_uses) = food.ability.uses {
318                if n_uses > 0 {
319                    // Return the food effect.
320                    &food.ability.action
321                } else {
322                    return None;
323                }
324            } else {
325                // None means unlimited uses.
326                &food.ability.action
327            };
328
329            match food_effect {
330                // Get stat modifiers from effects.
331                Action::Add(stat_change) | Action::Remove(stat_change) => {
332                    stat_change.to_stats(Some(self.stats), None, false).ok()
333                }
334                Action::Negate(stats) => {
335                    let mut mod_stats = *stats;
336                    // Reverse values so that (2 atk, 0 health) -> (0 atk, 2 health).
337                    mod_stats.invert();
338                    Some(mod_stats)
339                }
340                Action::Critical(prob) => {
341                    let mut rng = ChaCha12Rng::seed_from_u64(self.seed.unwrap_or_else(random));
342                    let prob = (*prob).clamp(0, 100) as f64 / 100.0;
343                    // Deal double damage (Add attack twice) if probabilty yields true.
344                    let dmg = if rng.gen_bool(prob) {
345                        self.stats.attack
346                    } else {
347                        0
348                    };
349
350                    Some(Statistics {
351                        attack: dmg,
352                        health: 0,
353                    })
354                }
355                // Otherwise, no change.
356                _ => None,
357            }
358        } else {
359            None
360        }
361    }
362
363    fn has_food_ability(&self, ability: &Action, check_uses: bool) -> bool {
364        if let Some(food) = self.item.as_ref() {
365            let valid_uses = if check_uses {
366                food.ability.uses != Some(0)
367            } else {
368                true
369            };
370            std::mem::discriminant(&food.ability.action) == std::mem::discriminant(ability)
371                && valid_uses
372        } else {
373            false
374        }
375    }
376
377    fn has_effect_trigger(&self, trigger: &Status, check_uses: bool) -> bool {
378        self.effect.iter().any(|effect| {
379            let valid_uses = if check_uses {
380                effect.uses != Some(0)
381            } else {
382                true
383            };
384            std::mem::discriminant(&effect.trigger.status) == std::mem::discriminant(trigger)
385                && valid_uses
386        })
387    }
388
389    fn has_effect_ability(&self, ability: &Action, check_uses: bool) -> bool {
390        self.effect.iter().any(|effect| {
391            let valid_uses = if check_uses {
392                effect.uses != Some(0)
393            } else {
394                true
395            };
396            std::mem::discriminant(&effect.action) == std::mem::discriminant(ability) && valid_uses
397        })
398    }
399
400    fn calculate_new_health(&self, enemy: &Pet) -> (isize, isize) {
401        // Get stat modifier from food.
402        let stat_modifier = self.get_food_stat_modifier().unwrap_or_default();
403        let enemy_stat_modifier = enemy.get_food_stat_modifier().unwrap_or_default();
404
405        let min_enemy_dmg = min_dmg_received(self);
406        let min_dmg = min_dmg_received(enemy);
407
408        // If has coconut, maximum dmg is 0. Otherwise, the normal 150.
409        let max_enemy_dmg = max_dmg_received(self);
410        let max_dmg = max_dmg_received(enemy);
411
412        // Any modifiers must apply to ATTACK as we want to only temporarily modify the health attribute of a pet.
413        let enemy_dmg = (enemy.stats.attack + enemy_stat_modifier.attack)
414            .sub(stat_modifier.health)
415            .clamp(min_enemy_dmg, max_enemy_dmg);
416
417        let dmg = (self.stats.attack + stat_modifier.attack)
418            .sub(enemy_stat_modifier.health)
419            .clamp(min_dmg, max_dmg);
420
421        let new_health = final_dmg_calculation(self, enemy_dmg, enemy);
422        let new_enemy_health = final_dmg_calculation(enemy, dmg, self);
423
424        (new_health, new_enemy_health)
425    }
426
427    fn attack(&mut self, enemy: &mut Pet) -> AttackOutcome {
428        let (new_health, new_enemy_health) = self.calculate_new_health(enemy);
429
430        // Decrement number of uses on items, if any.
431        self.item.as_mut().map(|item| item.ability.remove_uses(1));
432        enemy.item.as_mut().map(|item| item.ability.remove_uses(1));
433
434        // Get outcomes for both pets.
435        // This doesn't factor in splash effects as pets outside of battle are affected.
436        let mut outcome = self.get_atk_outcomes(new_health);
437        let mut enemy_outcome = enemy.get_atk_outcomes(new_enemy_health);
438
439        // Add outcome for attacking pet.
440        enemy_outcome.friends.insert(0, TRIGGER_SELF_ATTACK);
441        outcome.friends.insert(0, TRIGGER_SELF_ATTACK);
442
443        // Add specific trigger if directly knockout.
444        if new_health == 0 {
445            enemy_outcome.friends.insert(0, TRIGGER_KNOCKOUT)
446        }
447        if new_enemy_health == 0 {
448            outcome.friends.insert(0, TRIGGER_KNOCKOUT)
449        }
450
451        // Set the new health of a pet.
452        self.stats.health = new_health;
453        enemy.stats.health = new_enemy_health;
454
455        // Extend outcomes from both sides.
456        outcome.friends.extend(enemy_outcome.opponents);
457        enemy_outcome.friends.extend(outcome.opponents);
458
459        AttackOutcome {
460            friends: VecDeque::from_iter(outcome.friends),
461            opponents: VecDeque::from_iter(enemy_outcome.friends),
462            friend_stat_change: outcome.friend_stat_change,
463            enemy_stat_change: enemy_outcome.friend_stat_change,
464        }
465    }
466}
467
468/// All [`Outcome`]s of a single attack for friends and enemies.
469#[derive(Debug, PartialEq, Default)]
470pub struct AttackOutcome {
471    /// [`Outcome`] for friends.
472    pub friends: VecDeque<Outcome>,
473    /// [`Outcome`] for opponents.
474    pub opponents: VecDeque<Outcome>,
475    /// Friend [`Statisitics`](crate::Statistics) change.
476    pub friend_stat_change: Statistics,
477    /// Enemy [`Statisitics`](crate::Statistics) change.
478    pub enemy_stat_change: Statistics,
479}
480
481impl AttackOutcome {
482    /// Dump attack outcomes into their respective teams, marking which pets were affected and afflict damage or lethal attacks.
483    pub(crate) fn unload_atk_outcomes(
484        &mut self,
485        team: &mut Team,
486        opponent: Option<&mut Team>,
487        affected: &Arc<RwLock<Pet>>,
488        afflicting: Option<Weak<RwLock<Pet>>>,
489    ) {
490        // Update triggers from where they came from.
491        // Knockout a special exception as affected pet is pet causing knockout.
492        for trigger in self.friends.iter_mut().chain(self.opponents.iter_mut()) {
493            if trigger.status == Status::KnockOut {
494                trigger.affected_pet = afflicting.clone();
495                trigger.set_afflicting(affected);
496            } else {
497                trigger.set_affected(affected);
498                trigger.afflicting_pet = afflicting.clone();
499            }
500        }
501
502        // Collect triggers for both teams.
503        team.triggers.extend(self.friends.drain(..));
504        if let Some(opponent) = opponent {
505            opponent.triggers.extend(self.opponents.drain(..));
506        }
507    }
508}
509
510impl Display for AttackOutcome {
511    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512        write!(
513            f,
514            "Friends: {:?}\nOpponent: {:?}",
515            self.friends, self.opponents,
516        )
517    }
518}