arelith/
combat.rs

1use super::{
2    character::Character,
3    dice::Dice,
4    item::{DamageResult, ItemProperty},
5};
6use crate::string::align_string;
7use serde::{Serialize, Deserialize};
8use std::cmp::max;
9
10#[derive(Debug, PartialEq, Serialize, Deserialize)]
11#[allow(unused)]
12pub enum HitResult {
13    Hit,
14    CriticalHit,
15    Miss,
16    TargetConcealed,
17    EpicDodged,
18}
19
20#[allow(unused)]
21impl HitResult {
22    pub fn is_missed(&self) -> bool {
23        match *self {
24            Self::Miss | Self::TargetConcealed | Self::EpicDodged => true,
25            _ => false,
26        }
27    }
28    pub fn is_crit(&self) -> bool {
29        if *self == Self::CriticalHit {
30            true
31        } else {
32            false
33        }
34    }
35}
36
37#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
38pub enum AttackType {
39    MainHand,
40    OffHand,
41    Extra,
42}
43
44impl std::fmt::Display for AttackType {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        write!(
47            f,
48            "AttackType::{}",
49            match self {
50                Self::MainHand => "MainHand",
51                Self::OffHand => "OffHand",
52                Self::Extra => "Extra",
53            }
54        )
55    }
56}
57
58#[derive(Debug, PartialEq, Serialize, Deserialize)]
59pub struct AttackInfo {
60    pub ab: i32,
61    pub type_: AttackType,
62}
63
64impl AttackInfo {
65    pub fn new(ab: i32, type_: AttackType) -> Self {
66        Self { ab, type_ }
67    }
68}
69
70#[derive(Clone, Default, Debug, Serialize, Deserialize)]
71pub struct CombatStatistics {
72    pub total_hits: i64,
73    pub critical_hits: i64,
74    pub total_misses: i64,
75    pub concealed_attacks: i64,
76    pub epic_dodged_attacks: i64,
77    pub dmg_dealt: DamageResult,
78}
79
80impl CombatStatistics {
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    pub fn total_attacks(&self) -> i64 {
86        self.total_hits + self.total_misses
87    }
88}
89
90impl ToString for CombatStatistics {
91    fn to_string(&self) -> String {
92        let mut string_list: Vec<String> = vec![];
93
94        string_list.push(align_string(
95            "TOTAL ATTACK",
96            self.total_attacks().to_string(),
97        ));
98        string_list.push(align_string("TOTAL HIT", self.total_hits.to_string()));
99        string_list.push(align_string(
100            "    * CRITICAL HIT",
101            self.critical_hits.to_string(),
102        ));
103        string_list.push("".into());
104        string_list.push(align_string("TOTAL MISS", self.total_misses.to_string()));
105        string_list.push(align_string(
106            "    * CONCEALED",
107            self.concealed_attacks.to_string(),
108        ));
109        string_list.push(align_string(
110            "    * EPIC DODGED",
111            self.epic_dodged_attacks.to_string(),
112        ));
113        string_list.push("".into());
114        string_list.push(align_string(
115            "TOTAL DAMAGE",
116            self.dmg_dealt.total_dmg().to_string(),
117        ));
118
119        for type_ in self.dmg_dealt.get_types_sorted() {
120            string_list.push(align_string(
121                format!("    * {}", type_.to_string().to_uppercase()).as_str(),
122                self.dmg_dealt.get(type_).to_string(),
123            ));
124        }
125
126        string_list.join("\n")
127    }
128}
129
130pub struct Combat<'a> {
131    attacker: &'a Character,
132    defender: &'a Character,
133}
134
135impl<'a> Combat<'a> {
136    pub fn new(attacker: &'a Character, defender: &'a Character) -> Self {
137        Self { attacker, defender }
138    }
139
140    // Returns the final concealment of defender after various
141    // factors are considered.
142    fn resolve_concealment(attacker: &Character, defender: &Character) -> f32 {
143        if attacker.has_blind_fight() {
144            (defender.concealment.pow(2) as f32) / 100.0
145        } else {
146            defender.concealment as f32
147        }
148    }
149
150    fn resolve_damage(
151        attacker: &Character,
152        defender: &Character,
153        atk_info: AttackInfo,
154        is_crit: bool,
155    ) -> DamageResult {
156        let dmg_result = DamageResult::new();
157
158        let multiplier = if !is_crit {
159            1
160        } else {
161            attacker.weapon_crit_multiplier()
162        };
163
164        // TODO: Get the damage type of weapon that defender has less immunity / reduction / resistance
165        //       against if weapon has multiple damage types.
166        // TODO: Add unarmed support. Currently if there is no weapon provided to character,
167        //       Rust panics because of unwrapping weapon damage type which is null.
168        let weapon_base_dmg_type = *attacker.weapon.base.damage_type.first().unwrap();
169
170        // STR mod
171        let str_mod_bonus = ((attacker.abilities.str.get_mod()
172            + if attacker.is_weapon_twohanded() {
173                let str_mod = attacker.abilities.str.get_mod();
174                max(0, ((str_mod as f32 * 1.5) as i32) - str_mod)
175            } else {
176                0
177            })
178            / if atk_info.type_ == AttackType::OffHand {
179                2
180            } else {
181                1
182            })
183            * multiplier;
184
185        dmg_result.add(weapon_base_dmg_type, str_mod_bonus);
186
187        // Weapon base damage
188        let weapon_base_dmg = attacker.weapon.base.damage.roll_m(multiplier);
189        dmg_result.add(weapon_base_dmg_type, weapon_base_dmg);
190
191        // Weapon damage bonuses
192        let _ = attacker
193            .weapon
194            .item_properties
195            .iter()
196            .filter(|x| match x {
197                ItemProperty::EnchantmentBonus(_) => true,
198                ItemProperty::DamageBonus(_) => true,
199                ItemProperty::MassiveCrit(_) => {
200                    if is_crit {
201                        true
202                    } else {
203                        false
204                    }
205                }
206                _ => false,
207            })
208            .map(|x| match x {
209                ItemProperty::EnchantmentBonus(bonus) => {
210                    dmg_result.add(weapon_base_dmg_type, bonus * multiplier);
211                }
212                ItemProperty::DamageBonus(dmg) => {
213                    dmg_result.add(dmg.type_, dmg.roll_m(multiplier));
214                }
215                ItemProperty::MassiveCrit(dice) => {
216                    dmg_result.add(weapon_base_dmg_type, dice.roll());
217                }
218                _ => (),
219            })
220            .collect::<Vec<_>>();
221
222        // Bane of Enemies
223        if attacker.has_bane_of_enemies() {
224            dmg_result.add(weapon_base_dmg_type, Dice::from("2d6").roll_m(multiplier));
225        }
226
227        // Overwhelming Critical
228        if attacker.has_overwhelming_critical() {
229            dmg_result.add(weapon_base_dmg_type, Dice::from("1d6").roll_m(multiplier));
230        }
231
232        // Weapon Specialization
233        if attacker.has_weapon_spec() {
234            dmg_result.add(weapon_base_dmg_type, 2 * multiplier);
235        }
236
237        // Epic Weapon Specialization
238        if attacker.has_epic_weapon_spec() {
239            dmg_result.add(weapon_base_dmg_type, 4 * multiplier);
240        }
241
242        // Apply damage immunity and reduction
243        let dmg_types = dmg_result.get_types();
244
245        for dmg_type in dmg_types {
246            let defender_dmg_immunity = defender.damage_immunity(dmg_type);
247            let defender_dmg_reduction = defender.damage_reduction(dmg_type);
248
249            if defender_dmg_immunity > 0 {
250                dmg_result.sub(
251                    dmg_type,
252                    dmg_result.get(dmg_type) * defender_dmg_immunity / 100,
253                );
254            }
255
256            if defender_dmg_reduction > 0 {
257                dmg_result.sub(dmg_type, defender_dmg_reduction);
258            }
259        }
260
261        dmg_result
262    }
263
264    pub fn resolve_round(&self) -> CombatStatistics {
265        let mut round_statistics = CombatStatistics::default();
266        let mut defender_can_epic_dodge = true;
267
268        for atk_no in 1..=self.attacker.total_apr() {
269            let atk_info = if let Some(atk_info) = self.attacker.atk_ab(atk_no) {
270                atk_info
271            } else {
272                println!("Combat::round() - Attack info is none!");
273                continue;
274            };
275
276            let defender_concealment = Self::resolve_concealment(self.attacker, self.defender);
277
278            // Concealment check
279            if defender_concealment > 0.0
280                && (Dice::from("1d100").roll() as f32) < defender_concealment
281            {
282                round_statistics.concealed_attacks += 1;
283                round_statistics.total_misses += 1;
284
285                continue;
286            }
287
288            let hit_roll = Dice::from("1d20").roll();
289
290            if hit_roll != 1 && (hit_roll == 20 || (atk_info.ab + hit_roll >= self.defender.ac)) {
291                if self.defender.has_epic_dodge() && defender_can_epic_dodge {
292                    defender_can_epic_dodge = false;
293
294                    round_statistics.epic_dodged_attacks += 1;
295                    round_statistics.total_misses += 1;
296
297                    continue;
298                }
299
300                // Critical check
301                let is_crit = if !self.defender.is_crit_immune()
302                    && hit_roll >= self.attacker.weapon_threat_range()
303                    && atk_info.ab + Dice::from("1d20").roll() >= self.defender.ac
304                {
305                    round_statistics.critical_hits += 1;
306                    true
307                } else {
308                    false
309                };
310
311                round_statistics.total_hits += 1;
312
313                // Calculate damage
314                let dmg_result =
315                    Self::resolve_damage(self.attacker, self.defender, atk_info, is_crit);
316
317                round_statistics.dmg_dealt.add_from(&dmg_result);
318            } else {
319                round_statistics.total_misses += 1;
320            }
321        }
322
323        round_statistics
324    }
325}
326
327#[cfg(test)]
328mod test {
329    use crate::{
330        character::{AbilityList, Character, CharacterBuilder},
331        combat::{AttackInfo, AttackType, Combat},
332        dice::Dice,
333        feat::feat_db::get_feat,
334        item::{Damage, DamageResult, DamageType, ItemProperty, Weapon, WeaponBase},
335        size::SizeCategory,
336    };
337
338    #[test]
339    fn combat() {
340        let character: Character = Character::builder()
341            .ab(50)
342            .base_apr(4)
343            .extra_apr(1)
344            .feats(vec![])
345            .build();
346
347        assert_eq!(
348            character.atk_ab(1).unwrap(),
349            AttackInfo::new(50, AttackType::MainHand)
350        );
351        assert_eq!(
352            character.atk_ab(2).unwrap(),
353            AttackInfo::new(45, AttackType::MainHand)
354        );
355        assert_eq!(
356            character.atk_ab(3).unwrap(),
357            AttackInfo::new(40, AttackType::MainHand)
358        );
359        assert_eq!(
360            character.atk_ab(4).unwrap(),
361            AttackInfo::new(35, AttackType::MainHand)
362        );
363        assert_eq!(
364            character.atk_ab(5).unwrap(),
365            AttackInfo::new(50, AttackType::Extra)
366        );
367        assert_eq!(character.atk_ab(6), None);
368
369        let character2 = CharacterBuilder::from(character)
370            .ab(48)
371            .feats(vec![get_feat("Dual Wielding")])
372            .build();
373
374        assert_eq!(
375            character2.atk_ab(1).unwrap(),
376            AttackInfo::new(48, AttackType::MainHand)
377        );
378        assert_eq!(
379            character2.atk_ab(2).unwrap(),
380            AttackInfo::new(43, AttackType::MainHand)
381        );
382        assert_eq!(
383            character2.atk_ab(3).unwrap(),
384            AttackInfo::new(38, AttackType::MainHand)
385        );
386        assert_eq!(
387            character2.atk_ab(4).unwrap(),
388            AttackInfo::new(33, AttackType::MainHand)
389        );
390        assert_eq!(
391            character2.atk_ab(5).unwrap(),
392            AttackInfo::new(50, AttackType::Extra)
393        );
394        assert_eq!(
395            character2.atk_ab(6).unwrap(),
396            AttackInfo::new(48, AttackType::OffHand)
397        );
398        assert_eq!(
399            character2.atk_ab(7).unwrap(),
400            AttackInfo::new(43, AttackType::OffHand)
401        );
402        assert_eq!(character2.atk_ab(8), None);
403
404        let monk_character = CharacterBuilder::from(character2)
405            .ab(48)
406            .feats(vec![get_feat("Dual Wielding"), get_feat("Monk")])
407            .build();
408
409        assert_eq!(
410            monk_character.atk_ab(1).unwrap(),
411            AttackInfo::new(48, AttackType::MainHand)
412        );
413        assert_eq!(
414            monk_character.atk_ab(2).unwrap(),
415            AttackInfo::new(45, AttackType::MainHand)
416        );
417        assert_eq!(
418            monk_character.atk_ab(3).unwrap(),
419            AttackInfo::new(42, AttackType::MainHand)
420        );
421        assert_eq!(
422            monk_character.atk_ab(4).unwrap(),
423            AttackInfo::new(39, AttackType::MainHand)
424        );
425        assert_eq!(
426            monk_character.atk_ab(5).unwrap(),
427            AttackInfo::new(50, AttackType::Extra)
428        );
429        assert_eq!(
430            monk_character.atk_ab(6).unwrap(),
431            AttackInfo::new(48, AttackType::OffHand)
432        );
433        assert_eq!(
434            monk_character.atk_ab(7).unwrap(),
435            AttackInfo::new(45, AttackType::OffHand)
436        );
437        assert_eq!(monk_character.atk_ab(8), None);
438
439        let attacker = Character::builder()
440            .ab(50)
441            .feats(vec![get_feat("Blind Fight")])
442            .build();
443
444        let defender = Character::builder().concealment(50).build();
445        assert_eq!(Combat::resolve_concealment(&attacker, &defender), 25.0);
446
447        let defender = Character::builder().concealment(25).build();
448        assert_eq!(Combat::resolve_concealment(&attacker, &defender), 6.25);
449
450        let defender = Character::builder().concealment(0).build();
451        assert_eq!(Combat::resolve_concealment(&attacker, &defender), 0.0);
452    }
453
454    #[test]
455    fn damage() {
456        let attacker = Character::builder()
457            .abilities(AbilityList::builder().str(38).build())
458            .weapon(Weapon::new(
459                "".into(),
460                WeaponBase::new(
461                    "".into(),
462                    SizeCategory::Medium,
463                    Dice::from(6),
464                    18,
465                    2,
466                    vec![DamageType::Slashing],
467                ),
468                vec![
469                    ItemProperty::Keen,
470                    ItemProperty::EnchantmentBonus(4),
471                    ItemProperty::DamageBonus(Damage::new(
472                        DamageType::Divine,
473                        Dice::from(4),
474                        true,
475                        true,
476                    )),
477                    ItemProperty::MassiveCrit(Dice::from(6)),
478                ],
479            ))
480            .feats(vec![get_feat("Increased Multiplier")])
481            .build();
482
483        let defender = Character::builder()
484            .physical_immunity(10)
485            .physical_damage_reduction(5)
486            .build();
487
488        let round_result = Combat::resolve_damage(
489            &attacker,
490            &defender,
491            AttackInfo::new(50, AttackType::MainHand),
492            false,
493        );
494
495        assert_eq!(round_result.get(DamageType::Slashing), 17);
496        assert_eq!(round_result.get(DamageType::Divine), 4);
497        assert_eq!(round_result.total_dmg(), 21);
498
499        let round_result = Combat::resolve_damage(
500            &attacker,
501            &defender,
502            AttackInfo::new(50, AttackType::MainHand),
503            true,
504        );
505
506        assert_eq!(round_result.get(DamageType::Slashing), 66);
507        assert_eq!(round_result.get(DamageType::Divine), 12);
508        assert_eq!(round_result.total_dmg(), 78);
509
510        // Test twohand weapon damage bonus
511        let attacker = Character::builder()
512            .abilities(AbilityList::builder().str(38).build())
513            .weapon(Weapon::new(
514                "".into(),
515                WeaponBase::new(
516                    "".into(),
517                    SizeCategory::Large,
518                    Dice::from(6),
519                    18,
520                    2,
521                    vec![DamageType::Slashing],
522                ),
523                vec![
524                    ItemProperty::Keen,
525                    ItemProperty::EnchantmentBonus(4),
526                    ItemProperty::DamageBonus(Damage::new(
527                        DamageType::Divine,
528                        Dice::from(4),
529                        true,
530                        true,
531                    )),
532                    ItemProperty::MassiveCrit(Dice::from(6)),
533                ],
534            ))
535            .feats(vec![get_feat("Increased Multiplier")])
536            .build();
537
538        let defender = Character::builder()
539            .physical_immunity(0)
540            .physical_damage_reduction(0)
541            .build();
542
543        let round_result = Combat::resolve_damage(
544            &attacker,
545            &defender,
546            AttackInfo::new(50, AttackType::MainHand),
547            false,
548        );
549
550        assert_eq!(round_result.get(DamageType::Slashing), 31);
551
552        // Test offhand damage penalty
553        let round_result = Combat::resolve_damage(
554            &attacker,
555            &defender,
556            AttackInfo::new(50, AttackType::OffHand),
557            false,
558        );
559
560        assert_eq!(round_result.get(DamageType::Slashing), 20);
561
562        let mut dmg1 = DamageResult::new();
563        dmg1.add(DamageType::Acid, 4);
564        dmg1.add(DamageType::Bludgeoning, 6);
565
566        assert_eq!(dmg1.total_dmg(), 10);
567
568        let dmg2 = DamageResult::new();
569        dmg2.add(DamageType::Cold, 2);
570        dmg2.add(DamageType::Divine, 1);
571
572        dmg1.add_from(&dmg2);
573
574        assert_eq!(dmg1.get(DamageType::Cold), 2);
575        assert_eq!(dmg1.get(DamageType::Divine), 1);
576        assert_eq!(dmg1.total_dmg(), 13);
577    }
578}