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}