Skip to main content

sf_api/simulate/
fighter.rs

1use std::hash::Hash;
2
3use enum_map::EnumMap;
4use fastrand::Rng;
5
6use crate::{
7    command::AttributeType,
8    gamestate::{character::Class, items::*},
9    misc::EnumMapGet,
10    simulate::{damage::*, upgradeable::UpgradeableFighter, *},
11};
12
13/// Contains all information, that is necessary for battles to be simulated.
14/// It is derived by converting any of the things that can fight (player,
15/// companion, etc.) to a fighter through the From<T> traits.
16/// Contains all information, that is necessary for battles to be simulated.
17/// It is derived by converting any of the things that can fight (player,
18/// companion, etc.) to a fighter through the From<T> traits.
19/// Further information is available on the `simulate_battle` function.
20#[derive(Debug, Clone)]
21pub struct Fighter {
22    pub ident: FighterIdent,
23    /// The name, or alternative identification of this fighter. Only used for
24    /// display purposes, does not affect combat.
25    pub name: std::sync::Arc<str>,
26    /// The class of the fighter (e.g., Warrior, Mage).
27    pub class: Class,
28    /// The level of the fighter.
29    pub level: u16,
30    /// The attributes of the fighter
31    pub attributes: EnumMap<AttributeType, u32>,
32    /// The health the fighter has before going into battle.
33    pub max_health: f64,
34    /// The armor value that reduces incoming damage. Sum of all equipment.
35    pub armor: u32,
36    /// The fighter's first weapon, if equipped.
37    pub first_weapon: Option<Weapon>,
38    /// The fighter's second weapon, if the fighter is an assassin. Shields are
39    /// not tracked
40    pub second_weapon: Option<Weapon>,
41    /// Check if this fighter has the enchantment to take the first action
42    pub has_reaction_enchant: bool,
43    /// The critical hit multiplier for the fighter.
44    pub crit_dmg_multi: f64,
45    /// The resistances of the fighter to various elements from runes.
46    pub resistances: EnumMap<Element, i32>,
47    /// The damage bonus the fighter receives from guild portal.
48    pub portal_dmg_bonus: f64,
49    /// Indicates whether the fighter is a companion.
50    pub is_companion: bool,
51    /// The level of the gladiator building in the underworld.
52    pub gladiator_lvl: u32,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub struct FighterIdent(u32);
57
58impl FighterIdent {
59    pub fn new() -> Self {
60        FighterIdent(fastrand::u32(..))
61    }
62}
63
64impl Default for FighterIdent {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl From<&Monster> for Fighter {
71    fn from(monster: &Monster) -> Fighter {
72        let mut weapon = Weapon {
73            rune_value: 0,
74            rune_type: None,
75            damage: DamageRange {
76                min: f64::from(monster.min_dmg),
77                max: f64::from(monster.max_dmg),
78            },
79        };
80        let mut resistances = EnumMap::default();
81
82        if let Some(runes) = &monster.runes {
83            resistances = runes.resistances;
84            weapon.rune_value = runes.damage;
85            weapon.rune_type = Some(runes.damage_type);
86        }
87
88        // TODO: is this real?
89        let second_weapon =
90            (monster.class == Class::Assassin).then(|| weapon.clone());
91
92        Fighter {
93            ident: FighterIdent::new(),
94            name: std::sync::Arc::from(monster.name),
95            class: monster.class,
96            level: monster.level,
97            attributes: monster.attributes,
98            max_health: monster.hp as f64,
99            armor: monster.armor,
100            second_weapon,
101            first_weapon: Some(weapon),
102            has_reaction_enchant: false,
103            crit_dmg_multi: 2.0,
104            resistances,
105            portal_dmg_bonus: 0.0,
106            is_companion: false,
107            gladiator_lvl: 0,
108        }
109    }
110}
111
112impl From<&UpgradeableFighter> for Fighter {
113    fn from(char: &UpgradeableFighter) -> Self {
114        use RuneType as RT;
115
116        let attributes = char.attributes();
117        let health = char.hit_points(&attributes) as f64;
118
119        let mut resistances = EnumMap::default();
120        let mut has_reaction = false;
121        let mut extra_crit_dmg = 0.0;
122        let mut armor = 0;
123        let mut weapon = None;
124        let mut offhand = None;
125
126        for (slot, item) in &char.equipment.0 {
127            let Some(item) = item else {
128                continue;
129            };
130            armor += item.armor();
131            match item.enchantment {
132                Some(Enchantment::SwordOfVengeance) => {
133                    extra_crit_dmg = 0.05;
134                }
135                Some(Enchantment::ShadowOfTheCowboy) => {
136                    has_reaction = true;
137                }
138                _ => {}
139            }
140
141            if let Some(rune) = item.rune {
142                let mut apply = |element| {
143                    *resistances.get_mut(element) += i32::from(rune.value);
144                };
145                match rune.typ {
146                    RT::FireResistance => apply(Element::Fire),
147                    RT::ColdResistence => apply(Element::Cold),
148                    RT::LightningResistance => apply(Element::Lightning),
149                    RT::TotalResistence => {
150                        for val in &mut resistances.values_mut() {
151                            *val += i32::from(rune.value);
152                        }
153                    }
154                    _ => {}
155                }
156            }
157
158            match item.typ {
159                ItemType::Weapon { min_dmg, max_dmg } => {
160                    let mut res = Weapon {
161                        rune_value: 0,
162                        rune_type: None,
163                        damage: DamageRange {
164                            min: f64::from(min_dmg),
165                            max: f64::from(max_dmg),
166                        },
167                    };
168                    if let Some(rune) = item.rune {
169                        res.rune_type = match rune.typ {
170                            RT::FireDamage => Some(Element::Fire),
171                            RT::ColdDamage => Some(Element::Cold),
172                            RT::LightningDamage => Some(Element::Lightning),
173                            _ => None,
174                        };
175                        res.rune_value = rune.value.into();
176                    }
177                    match slot {
178                        EquipmentSlot::Weapon => weapon = Some(res),
179                        EquipmentSlot::Shield => offhand = Some(res),
180                        _ => {}
181                    }
182                }
183                ItemType::Shield { block_chance: _ } => {
184                    // TODO: What about the block chance of this?
185                    // Should this not be used?
186                }
187                _ => (),
188            }
189        }
190
191        let crit_multiplier =
192            2.0 + extra_crit_dmg + f64::from(char.gladiator) * 0.11;
193
194        Fighter {
195            ident: FighterIdent::new(),
196            name: char.name.clone(),
197            class: char.class,
198            level: char.level,
199            attributes,
200            max_health: health,
201            armor,
202            first_weapon: weapon,
203            second_weapon: offhand,
204            has_reaction_enchant: has_reaction,
205            crit_dmg_multi: crit_multiplier,
206            resistances,
207            portal_dmg_bonus: f64::from(char.portal_dmg_bonus),
208            is_companion: char.is_companion,
209            gladiator_lvl: char.gladiator,
210        }
211    }
212}
213
214// TODO: Impl From OtherPlayer / Pet
215
216/// Contains all relevant information about a fighter, that has entered combat
217/// against another fighter, that are relevant to resolve this 1on1 battle.
218/// If this fighter has won a 1on1 battle and is matched up with another enemy,
219/// the stats must be updated using `update_opponent()`.
220#[derive(Debug, Clone)]
221pub(crate) struct InBattleFighter {
222    /// The name, or alternative identification of this fighter. Only used for
223    /// display purposes, does not affect combat.
224    #[allow(unused)]
225    pub name: Arc<str>,
226    /// The class of the fighter (e.g., Warrior, Mage).
227    pub class: Class,
228    /// The amount of health this fighter has started the battle with
229    pub max_health: f64,
230    /// The amount of health this fighter currently has. May be negative, or
231    /// zero
232    pub health: f64,
233    /// The amount of damage this fighter can do with a normal (weapon 1)
234    /// attack on the first turn
235    pub damage: DamageRange,
236    /// The reaction speed of the fighter, affecting turn order. `1` if this
237    /// fighter has an item with the relevant enchantment
238    pub reaction: u8,
239    /// The chance to land a critical hit against the opponent
240    pub crit_chance: f64,
241    /// The amount of damage a crit does compared to a normal attack
242    pub crit_dmg_multi: f64,
243    /// Just a flag that stores if the opponent is a mage. We could also store
244    /// the class of the opponent here, but we only ever really care about
245    /// mage.
246    pub opponent_is_mage: bool,
247
248    /// All the metadata a fighter needs to keep track of during a fight, that
249    /// is unique to their class.
250    pub class_data: ClassData,
251}
252
253/// The class specific metadata a fighter needs to keep track of during a fight.
254#[derive(Debug, Clone)]
255pub(crate) enum ClassData {
256    Warrior {
257        /// The chance to block an attack with the shield
258        block_chance: i32,
259    },
260    Mage,
261    Scout,
262    Assassin {
263        /// The weapon damage from the secondary weapon
264        secondary_damage: DamageRange,
265    },
266    BattleMage {
267        /// The damage a fireball does against the enemy on the first turn
268        fireball_dmg: f64,
269    },
270    Berserker {
271        /// The amount of times the berserker has attacked consecutively in
272        /// a frenzy
273        frenzy_attacks: u32,
274    },
275    DemonHunter {
276        /// The amount of times the demon hunter has revived
277        revive_count: u32,
278    },
279    Druid {
280        /// Is this character currently in bear form
281        is_in_bear_form: bool,
282        /// The chance to crit whilst in rage (bear form)
283        rage_crit_chance: f64,
284        /// Have we just an enemies attack, which would lead us to transform
285        /// into a bear on our next turn?
286        has_just_dodged: bool,
287        /// The chance to do a swoop attack
288        swoop_chance: f64,
289        /// The amount of damage a swoop attack does compared to a normal
290        /// attack
291        swoop_dmg_multi: f64,
292    },
293    Bard {
294        /// The amount of turns the melody is still active for
295        melody_remaining_rounds: i32,
296        /// The amount of turns until we can start playing a new melody
297        melody_cooldown_rounds: i32,
298        /// The amount of damage an attack does based on the current melody
299        /// compared to a generic attack
300        melody_dmg_multi: f64,
301    },
302    Necromancer {
303        // TODO: When exactly is this applied
304        damage_multi: f64,
305        /// The type of minion, that we have summoned, if any
306        minion: Option<Minion>,
307        /// The amount of rounds the minion is going to remain active for
308        minion_remaining_rounds: i32,
309        /// The amount of times the skeleton has revived
310        skeleton_revived: i32,
311    },
312    Paladin {
313        // TODO: What exactly is this? Is this a damage bonus? Why is it named
314        // this?
315        initial_armor_reduction: f64,
316        /// The current stance, that the paladin is in
317        stance: Stance,
318    },
319    PlagueDoctor {
320        /// The amount of rounds the current tincture is still active for
321        poison_remaining_round: usize,
322        /// The damage multipliers the three turns of poison inflict extra on
323        /// the opponent
324        poison_dmg_multis: [f64; 3],
325    },
326}
327
328/// The type of minion a necromancer can summon
329#[derive(Debug, PartialEq, Eq, Clone, Copy)]
330pub(crate) enum Minion {
331    Skeleton,
332    Hound,
333    Golem,
334}
335
336/// The stance a paladin can enter
337#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub(crate) enum Stance {
339    Regular,
340    Defensive,
341    Offensive,
342}
343
344impl Stance {
345    pub(crate) fn damage_multiplier(self) -> f64 {
346        match self {
347            Stance::Regular => 1.0,
348            Stance::Defensive => 1.0 / 0.833 * 0.568,
349            Stance::Offensive => 1.0 / 0.833 * 1.253,
350        }
351    }
352
353    pub(crate) fn block_chance(self) -> u8 {
354        match self {
355            Stance::Regular => 30,
356            Stance::Defensive => 50,
357            Stance::Offensive => 25,
358        }
359    }
360}
361/// Calculates for `main` to crit `opponent`
362pub(crate) fn calculate_crit_chance(
363    main: &Fighter,
364    opponent: &Fighter,
365    cap: f64,
366    crit_bonus: f64,
367) -> f64 {
368    let luck_factor = f64::from(main.attributes[AttributeType::Luck]) * 5.0;
369    let opponent_level_factor = f64::from(opponent.level) * 2.0;
370    let crit_chance = luck_factor / opponent_level_factor / 100.0 + crit_bonus;
371    crit_chance.min(cap)
372}
373
374impl InBattleFighter {
375    /// Shorthand to check if this fighter is a mage
376    pub fn is_mage(&self) -> bool {
377        self.class == Class::Mage
378    }
379
380    /// Update the stats that are affected by the opponent with a new oponent
381    /// without resetting persistent data points
382    pub fn update_opponent(
383        &mut self,
384        main: &Fighter,
385        opponent: &Fighter,
386        reduce_gladiator: bool,
387    ) {
388        self.damage = calculate_damage(main, opponent, false);
389
390        let mut crit_dmg_multi = main.crit_dmg_multi;
391        if reduce_gladiator {
392            let glad_lvl = main.gladiator_lvl.min(opponent.gladiator_lvl);
393            crit_dmg_multi -= f64::from(glad_lvl) * 0.11;
394        }
395        self.crit_dmg_multi = crit_dmg_multi;
396        self.crit_chance = calculate_crit_chance(main, opponent, 0.5, 0.0);
397
398        self.class_data.update_opponent(main, opponent);
399        self.opponent_is_mage = opponent.class == Class::Mage;
400    }
401
402    /// Does a full attack turn for this fighter against the target. Returns
403    /// true, if the opponent has won
404    pub fn attack(
405        &mut self,
406        target: &mut InBattleFighter,
407        round: &mut u32,
408        rng: &mut Rng,
409    ) -> bool {
410        match &mut self.class_data {
411            ClassData::Assassin { secondary_damage } => {
412                let secondary_damage = *secondary_damage;
413
414                // Main hand attack
415                *round += 1;
416                if target.will_take_attack(rng) {
417                    let first_weapon_damage =
418                        self.calc_basic_hit_damage(*round, rng);
419                    if target.take_attack_dmg(first_weapon_damage, round, rng) {
420                        return true;
421                    }
422                }
423
424                // Second hand attack
425                *round += 1;
426                if !target.will_take_attack(rng) {
427                    return false;
428                }
429
430                let second_weapon_damage = calculate_hit_damage(
431                    &secondary_damage,
432                    *round,
433                    self.crit_chance,
434                    self.crit_dmg_multi,
435                    rng,
436                );
437
438                target.take_attack_dmg(second_weapon_damage, round, rng)
439            }
440            ClassData::Druid {
441                has_just_dodged,
442                rage_crit_chance,
443                is_in_bear_form,
444                swoop_chance,
445                swoop_dmg_multi,
446            } => {
447                if target.is_mage() {
448                    return self.attack_generic(target, round, rng);
449                }
450
451                if *has_just_dodged {
452                    // transform into a bear and attack with rage
453                    *is_in_bear_form = true;
454                    *has_just_dodged = false;
455
456                    *round += 1;
457
458                    if !target.will_take_attack(rng) {
459                        return false;
460                    }
461
462                    let rage_crit_multi = 6.0 * self.crit_dmg_multi / 2.0;
463                    let dmg = calculate_hit_damage(
464                        &self.damage,
465                        *round,
466                        *rage_crit_chance,
467                        rage_crit_multi,
468                        rng,
469                    );
470                    return target.take_attack_dmg(dmg, round, rng);
471                }
472
473                *is_in_bear_form = false;
474
475                // eagle form
476
477                let do_swoop_attack = rng.f64() < *swoop_chance;
478                if do_swoop_attack {
479                    *round += 1;
480                    *swoop_chance = (*swoop_chance + 0.05).min(0.5);
481
482                    if target.will_take_attack(rng) {
483                        let swoop_dmg_multi = *swoop_dmg_multi;
484                        let swoop_dmg = self.calc_basic_hit_damage(*round, rng)
485                            * swoop_dmg_multi;
486
487                        if target.take_attack_dmg(swoop_dmg, round, rng) {
488                            return true;
489                        }
490                    }
491                }
492
493                self.attack_generic(target, round, rng)
494            }
495            ClassData::Bard {
496                melody_remaining_rounds,
497                melody_cooldown_rounds,
498                melody_dmg_multi,
499            } => {
500                if target.is_mage() {
501                    return self.attack_generic(target, round, rng);
502                }
503
504                if *melody_remaining_rounds <= 0 && *melody_cooldown_rounds <= 0
505                {
506                    // Start playing a new melody
507                    let (length, multi) = match rng.u32(0..4) {
508                        0 | 1 => (3, 1.4),
509                        2 => (3, 1.2),
510                        _ => (4, 1.6),
511                    };
512                    *melody_remaining_rounds = length;
513                    *melody_dmg_multi = multi;
514                    *melody_cooldown_rounds = 4;
515                } else if *melody_remaining_rounds == 0 {
516                    // Stop a melody effect, that has elapsed
517                    *melody_dmg_multi = 1.0;
518                }
519
520                *melody_remaining_rounds -= 1;
521                *melody_cooldown_rounds -= 1;
522
523                if !target.will_take_attack(rng) {
524                    return false;
525                }
526
527                let dmg_multi = *melody_dmg_multi;
528                let dmg = self.calc_basic_hit_damage(*round, rng) * dmg_multi;
529                target.take_attack_dmg(dmg, round, rng)
530            }
531            ClassData::Necromancer {
532                minion,
533                minion_remaining_rounds: minion_rounds,
534                ..
535            } => {
536                if target.is_mage() {
537                    return self.attack_generic(target, round, rng);
538                }
539                *round += 1;
540
541                if minion.is_none() && rng.bool() {
542                    // Summon a new minion and have it attack
543                    let (new_type, new_rounds) = match rng.u8(0..3) {
544                        0 => (Minion::Skeleton, 3),
545                        1 => (Minion::Hound, 2),
546                        _ => (Minion::Golem, 4),
547                    };
548
549                    *minion = Some(new_type);
550                    *minion_rounds = new_rounds;
551                    return self.attack_with_minion(target, round, rng);
552                }
553
554                if target.will_take_attack(rng) {
555                    // Do a normal attack before minion attack
556                    let dmg = self.calc_basic_hit_damage(*round, rng);
557                    if target.take_attack_dmg(dmg, round, rng) {
558                        return true;
559                    }
560                }
561
562                self.attack_with_minion(target, round, rng)
563            }
564            ClassData::Paladin { stance, .. } => {
565                if target.is_mage() {
566                    return self.attack_generic(target, round, rng);
567                }
568
569                *round += 1;
570                if rng.bool() {
571                    // change stance
572                    *stance = match stance {
573                        Stance::Regular => Stance::Defensive,
574                        Stance::Defensive => Stance::Offensive,
575                        Stance::Offensive => Stance::Regular,
576                    };
577                }
578
579                if !target.will_take_attack(rng) {
580                    return false;
581                }
582
583                let dmg_multi = stance.damage_multiplier();
584                let dmg = self.calc_basic_hit_damage(*round, rng) * dmg_multi;
585                target.take_attack_dmg(dmg, round, rng)
586            }
587            ClassData::PlagueDoctor {
588                poison_remaining_round,
589                poison_dmg_multis,
590            } => {
591                if target.is_mage() {
592                    return self.attack_generic(target, round, rng);
593                }
594
595                if *poison_remaining_round == 0 && rng.bool() {
596                    // Throw a new tincture and attack
597                    *round += 1;
598                    if !target.will_take_attack(rng) {
599                        return false;
600                    }
601
602                    *poison_remaining_round = 3;
603
604                    let dmg_multi = poison_dmg_multis[2];
605                    let dmg =
606                        self.calc_basic_hit_damage(*round, rng) * dmg_multi;
607                    return target.take_attack_dmg(dmg, round, rng);
608                }
609
610                if *poison_remaining_round > 0 {
611                    // Apply damage tick from the tincture that we currently
612                    // have in effect
613                    *round += 1;
614                    *poison_remaining_round -= 1;
615
616                    #[allow(clippy::indexing_slicing)]
617                    let dmg_multi = poison_dmg_multis[*poison_remaining_round];
618                    let dmg =
619                        self.calc_basic_hit_damage(*round, rng) * dmg_multi;
620
621                    if target.class == Class::Paladin {
622                        // Paladin can not block this
623                        target.health -= dmg;
624                        if target.health <= 0.0 {
625                            return true;
626                        }
627                    } else if target.take_attack_dmg(dmg, round, rng) {
628                        return true;
629                    }
630                }
631                self.attack_generic(target, round, rng)
632            }
633            ClassData::Mage => {
634                // Mage attacks to not check will_take_attack
635                let dmg = self.calc_basic_hit_damage(*round, rng);
636                target.take_attack_dmg(dmg, round, rng)
637            }
638            _ => self.attack_generic(target, round, rng),
639        }
640    }
641
642    /// The most generic type of attack. Just a swing/stab/shot with the main
643    /// weapon. Increases turn timer and checks for target dodges.
644    fn attack_generic(
645        &mut self,
646        target: &mut InBattleFighter,
647        round: &mut u32,
648        rng: &mut Rng,
649    ) -> bool {
650        *round += 1;
651
652        if !target.will_take_attack(rng) {
653            return false;
654        }
655
656        let dmg = self.calc_basic_hit_damage(*round, rng);
657        target.take_attack_dmg(dmg, round, rng)
658    }
659
660    /// Any kind of attack, that happens at the start of a 1v1 fight
661    pub fn attack_before_fight(
662        &mut self,
663        target: &mut InBattleFighter,
664        round: &mut u32,
665        rng: &mut Rng,
666    ) -> bool {
667        match &mut self.class_data {
668            ClassData::BattleMage { fireball_dmg } => {
669                *round += 1;
670                target.take_attack_dmg(*fireball_dmg, round, rng)
671            }
672            _ => false,
673        }
674    }
675
676    /// Do we deny the opponents next turn?
677    pub fn will_skips_opponent_round(
678        &mut self,
679        target: &mut InBattleFighter,
680        _round: &mut u32,
681        rng: &mut Rng,
682    ) -> bool {
683        match &mut self.class_data {
684            ClassData::Berserker { frenzy_attacks } => {
685                if target.class == Class::Mage {
686                    return false;
687                }
688
689                if *frenzy_attacks < 14 && rng.bool() {
690                    *frenzy_attacks += 1;
691                    return true;
692                }
693
694                *frenzy_attacks = 0;
695                false
696            }
697            _ => false,
698        }
699    }
700
701    /// Applies the given damage to this fighter. The damage will be reduced,
702    /// if possible and if applicable this fighter may revive. If the fighter
703    /// ends up dead, this will return true.
704    pub fn take_attack_dmg(
705        &mut self,
706        damage: f64,
707        round: &mut u32,
708        rng: &mut Rng,
709    ) -> bool {
710        match &mut self.class_data {
711            ClassData::DemonHunter { revive_count } => {
712                let health = &mut self.health;
713                *health -= damage;
714                if *health > 0.0 {
715                    return false;
716                }
717                if self.opponent_is_mage {
718                    return true;
719                }
720
721                // revive logic
722                let revive_chance = 0.44 - (f64::from(*revive_count) * 0.11);
723                if revive_chance <= 0.0 || rng.f64() >= revive_chance {
724                    return true;
725                }
726
727                *round += 1;
728                *revive_count += 1;
729
730                true
731            }
732            ClassData::Paladin {
733                stance,
734                initial_armor_reduction,
735            } => {
736                let current_armor_reduction = match stance {
737                    Stance::Regular | Stance::Defensive => 1.0,
738                    Stance::Offensive => {
739                        1.0 / (1.0 - *initial_armor_reduction)
740                            * (1.0 - initial_armor_reduction.min(0.20))
741                    }
742                };
743                let actual_damage = damage * current_armor_reduction;
744                let health = &mut self.health;
745
746                if self.opponent_is_mage {
747                    *health -= actual_damage;
748                    return *health <= 0.0;
749                }
750
751                if *stance == Stance::Defensive
752                    && rng.u8(1..=100) <= stance.block_chance()
753                {
754                    let heal_cap = actual_damage * 0.3;
755                    *health += (self.max_health - *health).clamp(0.0, heal_cap);
756                    return false;
757                }
758
759                *health -= actual_damage;
760                *health <= 0.0
761            }
762            _ => {
763                let health = &mut self.health;
764                *health -= damage;
765                *health <= 0.0
766            }
767        }
768    }
769
770    /// Checks if this fighter manages to block/dodge the enemies attack
771    pub fn will_take_attack(&mut self, rng: &mut Rng) -> bool {
772        match &mut self.class_data {
773            ClassData::Warrior { block_chance } => {
774                rng.i32(1..=100) > *block_chance
775            }
776            ClassData::Assassin { .. } | ClassData::Scout => rng.bool(),
777            ClassData::Druid {
778                is_in_bear_form,
779                has_just_dodged,
780                ..
781            } => {
782                if !*is_in_bear_form && rng.u8(1..=100) <= 35 {
783                    // evade_chance hardcoded to 35 in original
784                    *has_just_dodged = true;
785                    return false;
786                }
787                true
788            }
789            ClassData::Necromancer { minion, .. } => {
790                if self.opponent_is_mage {
791                    return true;
792                }
793                if *minion != Some(Minion::Golem) {
794                    return true;
795                }
796                rng.u8(1..=100) > 25
797            }
798            ClassData::Paladin { stance, .. } => {
799                *stance == Stance::Defensive
800                    || rng.u8(1..=100) > stance.block_chance()
801            }
802            ClassData::PlagueDoctor {
803                poison_remaining_round,
804                ..
805            } => {
806                let chance = match poison_remaining_round {
807                    3 => 65,
808                    2 => 50,
809                    1 => 35,
810                    _ => 20,
811                };
812                rng.u8(1..=100) > chance
813            }
814            _ => true,
815        }
816    }
817
818    fn calc_basic_hit_damage(&self, round: u32, rng: &mut Rng) -> f64 {
819        calculate_hit_damage(
820            &self.damage,
821            round,
822            self.crit_chance,
823            self.crit_dmg_multi,
824            rng,
825        )
826    }
827
828    fn attack_with_minion(
829        &mut self,
830        target: &mut InBattleFighter,
831        round: &mut u32,
832        rng: &mut Rng,
833    ) -> bool {
834        let ClassData::Necromancer {
835            minion,
836            minion_remaining_rounds,
837            skeleton_revived,
838            damage_multi,
839        } = &mut self.class_data
840        else {
841            // Should not happen
842            return false;
843        };
844
845        if minion.is_none() {
846            return false;
847        }
848
849        *round += 1;
850
851        *minion_remaining_rounds -= 1;
852
853        // NOTE: Currently skeleton can revive only once per fight but this is
854        // a bug
855        if *minion_remaining_rounds == 0
856            && *minion == Some(Minion::Skeleton)
857            && *skeleton_revived < 1
858            && rng.bool()
859        {
860            *minion_remaining_rounds = 1;
861            *skeleton_revived += 1;
862        } else if *minion_remaining_rounds == 0 {
863            *minion = None;
864            *skeleton_revived = 0;
865        }
866
867        if !target.will_take_attack(rng) {
868            return false;
869        }
870
871        let mut crit_chance = self.crit_chance;
872        let mut crit_multi = self.crit_dmg_multi;
873        if *minion == Some(Minion::Hound) {
874            crit_chance = (crit_chance + 0.1).min(0.6);
875            crit_multi = 2.5 * (crit_multi / 2.0);
876        }
877
878        let mut dmg = calculate_hit_damage(
879            &self.damage,
880            *round,
881            crit_chance,
882            crit_multi,
883            rng,
884        );
885
886        let base_multi = *damage_multi;
887        let minion_dmg_multiplier = match minion {
888            Some(Minion::Skeleton) => (base_multi + 0.25) / base_multi,
889            Some(Minion::Hound) => (base_multi + 1.0) / base_multi,
890            Some(Minion::Golem) => 1.0,
891            None => 0.0,
892        };
893        dmg *= minion_dmg_multiplier;
894
895        target.take_attack_dmg(dmg, round, rng)
896    }
897}
898
899impl InBattleFighter {
900    pub(crate) fn new(
901        main: &Fighter,
902        opponent: &Fighter,
903        reduce_gladiator: bool,
904    ) -> InBattleFighter {
905        let class_data = ClassData::new(main, opponent);
906
907        let mut res = InBattleFighter {
908            name: main.name.clone(),
909            class: main.class,
910            health: main.max_health,
911            max_health: main.max_health,
912            reaction: u8::from(main.has_reaction_enchant),
913            damage: DamageRange::default(),
914            crit_chance: 0.0,
915            crit_dmg_multi: 0.0,
916            opponent_is_mage: false,
917            class_data,
918        };
919        res.update_opponent(main, opponent, reduce_gladiator);
920        res
921    }
922}
923
924impl ClassData {
925    pub(crate) fn update_opponent(
926        &mut self,
927        main: &Fighter,
928        opponent: &Fighter,
929    ) {
930        // TODO: Should we reset stuff like melody / druid form etc. when
931        // the opponent becomes a mage?
932        match self {
933            ClassData::Bard { .. }
934            | ClassData::DemonHunter { .. }
935            | ClassData::Mage
936            | ClassData::Scout
937            | ClassData::Warrior { .. } => {}
938            ClassData::Assassin { secondary_damage } => {
939                let range = calculate_damage(main, opponent, true);
940                *secondary_damage = range;
941            }
942            ClassData::BattleMage { fireball_dmg, .. } => {
943                *fireball_dmg = calculate_fire_ball_damage(main, opponent);
944            }
945            ClassData::Berserker {
946                frenzy_attacks: chain_attack_counter,
947            } => *chain_attack_counter = 0,
948            ClassData::Druid {
949                rage_crit_chance,
950                swoop_dmg_multi,
951                ..
952            } => {
953                *rage_crit_chance =
954                    calculate_crit_chance(main, opponent, 0.75, 0.1);
955                *swoop_dmg_multi = calculate_swoop_damage(main, opponent);
956            }
957
958            ClassData::Necromancer { damage_multi, .. } => {
959                *damage_multi = calculate_damage_multiplier(main, opponent);
960            }
961            ClassData::Paladin {
962                initial_armor_reduction,
963                ..
964            } => {
965                *initial_armor_reduction =
966                    calculate_damage_reduction(opponent, main);
967            }
968            ClassData::PlagueDoctor {
969                poison_dmg_multis, ..
970            } => {
971                let base_dmg_multi =
972                    calculate_damage_multiplier(main, opponent);
973
974                let dmg_multiplier = Class::PlagueDoctor.damage_multiplier();
975                let class_dmg_multi = base_dmg_multi / dmg_multiplier;
976
977                *poison_dmg_multis = [
978                    (base_dmg_multi - 0.9 * class_dmg_multi) / base_dmg_multi,
979                    (base_dmg_multi - 0.55 * class_dmg_multi) / base_dmg_multi,
980                    (base_dmg_multi - 0.2 * class_dmg_multi) / base_dmg_multi,
981                ];
982                // TODO: Do we reset poison round?
983            }
984        }
985    }
986
987    pub(crate) fn new(main: &Fighter, opponent: &Fighter) -> ClassData {
988        let mut res = match main.class {
989            Class::Warrior if main.is_companion => {
990                ClassData::Warrior { block_chance: 0 }
991            }
992            Class::Warrior => ClassData::Warrior { block_chance: 25 },
993            Class::Mage => ClassData::Mage,
994            Class::Scout => ClassData::Scout,
995            Class::Assassin => ClassData::Assassin {
996                secondary_damage: DamageRange::default(),
997            },
998            Class::BattleMage => ClassData::BattleMage { fireball_dmg: 0.0 },
999            Class::Berserker => ClassData::Berserker { frenzy_attacks: 0 },
1000            Class::DemonHunter => ClassData::DemonHunter { revive_count: 0 },
1001            Class::Druid => ClassData::Druid {
1002                rage_crit_chance: 0.0,
1003                is_in_bear_form: false,
1004                has_just_dodged: false,
1005                swoop_chance: 0.15,
1006                swoop_dmg_multi: 0.0,
1007            },
1008            Class::Bard => ClassData::Bard {
1009                melody_remaining_rounds: -1,
1010                melody_cooldown_rounds: 0,
1011                melody_dmg_multi: 1.0,
1012            },
1013            Class::Necromancer => ClassData::Necromancer {
1014                damage_multi: 0.0,
1015                minion: None,
1016                minion_remaining_rounds: 0,
1017                skeleton_revived: 0,
1018            },
1019            Class::Paladin => ClassData::Paladin {
1020                initial_armor_reduction: 0.0,
1021                stance: Stance::Regular,
1022            },
1023            Class::PlagueDoctor => ClassData::PlagueDoctor {
1024                poison_remaining_round: 0,
1025                poison_dmg_multis: [0.0, 0.0, 0.0],
1026            },
1027        };
1028        res.update_opponent(main, opponent);
1029        res
1030    }
1031}