sf_api/simulate/
mod.rs

1#![allow(
2    clippy::cast_possible_wrap,
3    clippy::cast_sign_loss,
4    clippy::cast_precision_loss,
5    clippy::cast_possible_truncation
6)]
7use enum_map::{Enum, EnumMap};
8use fastrand::Rng;
9use strum::{EnumIter, IntoEnumIterator};
10
11use crate::{
12    command::AttributeType,
13    gamestate::{
14        character::Class, dungeons::CompanionClass, items::*, GameState,
15    },
16    misc::EnumMapGet,
17};
18
19pub mod constants;
20
21use BattleEvent as BE;
22
23#[derive(Debug, Clone)]
24pub struct UpgradeableFighter {
25    is_companion: bool,
26    level: u16,
27    class: Class,
28    /// The base attributes without any equipment, or other boosts
29    pub attribute_basis: EnumMap<AttributeType, u32>,
30    _attributes_bought: EnumMap<AttributeType, u32>,
31    pet_attribute_bonus_perc: EnumMap<AttributeType, f64>,
32
33    equipment: Equipment,
34    active_potions: [Option<Potion>; 3],
35    /// This should be the percentage bonus to skills from pets
36    /// The hp bonus in percent this player has from the personal demon portal
37    portal_hp_bonus: u32,
38    /// The damage bonus in percent this player has from the guild demon portal
39    portal_dmg_bonus: u32,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Minion {
44    Skeleton { revived: u8 },
45    Hound,
46    Golem,
47}
48
49#[derive(Debug, Clone)]
50pub struct BattleFighter {
51    pub is_companion: bool,
52    pub level: u16,
53    pub class: Class,
54    pub attributes: EnumMap<AttributeType, u32>,
55    pub max_hp: i64,
56    pub current_hp: i64,
57    pub equip: EquipmentEffects,
58    pub portal_dmg_bonus: f64,
59    pub rounds_in_battle: u32,
60    pub class_effect: ClassEffect,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum HarpQuality {
65    Bad,
66    Medium,
67    Good,
68}
69
70// Modified, but mostly copied from:
71// https://github.com/HafisCZ/sf-tools/blob/521c2773098d62fe21ae687de2047c05f84813b7/js/sim/base.js#L746C4-L765C6
72fn calc_unarmed_base_dmg(
73    slot: EquipmentSlot,
74    level: u16,
75    class: Class,
76) -> (u32, u32) {
77    if level <= 10 {
78        return (1, 2);
79    }
80    let dmg_level = f64::from(level - 9);
81    let multiplier = match class {
82        Class::Assassin if slot == EquipmentSlot::Weapon => 1.25,
83        Class::Assassin => 0.875,
84        _ => 0.7,
85    };
86
87    let base = dmg_level * multiplier * class.weapon_multiplier();
88    let min = ((base * 2.0) / 3.0).trunc().max(1.0);
89    let max = ((base * 4.0) / 3.0).trunc().max(2.0);
90    (min as u32, max as u32)
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum ClassEffect {
95    Druid {
96        /// Has the druid just dodged an attack?
97        bear: bool,
98        /// The amount of swoops the druid has done so far
99        swoops: u8,
100    },
101    Bard {
102        quality: HarpQuality,
103        remaining: u8,
104    },
105    Necromancer {
106        typ: Minion,
107        remaining: u8,
108    },
109    DemonHunter {
110        revived: u8,
111    },
112    Normal,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum AttackType {
117    Weapon,
118    Offhand,
119    Swoop,
120    Minion,
121}
122
123impl ClassEffect {
124    fn druid_swoops(self) -> u8 {
125        match self {
126            ClassEffect::Druid { swoops, .. } => swoops,
127            _ => 0,
128        }
129    }
130}
131
132impl BattleFighter {
133    #[must_use]
134    pub fn from_monster(monster: &Monster) -> Self {
135        // TODO: I assume this is unarmed damage, but I should check
136        let weapon = calc_unarmed_base_dmg(
137            EquipmentSlot::Weapon,
138            monster.level,
139            monster.class,
140        );
141
142        Self {
143            is_companion: false,
144            level: monster.level,
145            class: monster.class,
146            attributes: monster.attributes,
147            max_hp: monster.hp as i64,
148            current_hp: monster.hp as i64,
149            equip: EquipmentEffects {
150                element_res: EnumMap::default(),
151                element_dmg: EnumMap::default(),
152                weapon,
153                offhand: (0, 0),
154                reaction_boost: false,
155                extra_crit_dmg: false,
156                armor: 0,
157            },
158            portal_dmg_bonus: 1.0,
159            rounds_in_battle: 0,
160            class_effect: ClassEffect::Normal,
161        }
162    }
163
164    #[must_use]
165    pub fn from_upgradeable(char: &UpgradeableFighter) -> Self {
166        let attributes = char.attributes();
167        let hp = char.hit_points(&attributes);
168
169        let mut equip = EquipmentEffects {
170            element_res: EnumMap::default(),
171            element_dmg: EnumMap::default(),
172            reaction_boost: false,
173            extra_crit_dmg: false,
174            armor: 0,
175            weapon: (0, 0),
176            offhand: (0, 0),
177        };
178
179        for (slot, item) in &char.equipment.0 {
180            let Some(item) = item else {
181                match slot {
182                    EquipmentSlot::Weapon => {
183                        equip.weapon =
184                            calc_unarmed_base_dmg(slot, char.level, char.class);
185                    }
186                    EquipmentSlot::Shield if char.class == Class::Assassin => {
187                        equip.offhand =
188                            calc_unarmed_base_dmg(slot, char.level, char.class);
189                    }
190                    _ => {}
191                }
192                continue;
193            };
194            equip.armor += item.armor();
195            match item.enchantment {
196                Some(Enchantment::SwordOfVengeance) => {
197                    equip.extra_crit_dmg = true;
198                }
199                Some(Enchantment::ShadowOfTheCowboy) => {
200                    equip.reaction_boost = true;
201                }
202                _ => {}
203            };
204            if let Some(rune) = item.rune {
205                use RuneType as RT;
206
207                let mut apply = |is_res, element| {
208                    let target = if is_res {
209                        &mut equip.element_res
210                    } else {
211                        &mut equip.element_dmg
212                    };
213                    *target.get_mut(element) += f64::from(rune.value) / 100.0;
214                };
215                match rune.typ {
216                    RT::FireResistance => apply(true, Element::Fire),
217                    RT::ColdResistence => apply(true, Element::Cold),
218                    RT::LightningResistance => apply(true, Element::Lightning),
219                    RT::TotalResistence => {
220                        for (_, val) in &mut equip.element_res {
221                            *val += f64::from(rune.value) / 100.0;
222                        }
223                    }
224                    RT::FireDamage => apply(false, Element::Fire),
225                    RT::ColdDamage => apply(false, Element::Cold),
226                    RT::LightningDamage => apply(false, Element::Lightning),
227                    _ => {}
228                }
229            }
230
231            match item.typ {
232                ItemType::Weapon { min_dmg, max_dmg } => match slot {
233                    EquipmentSlot::Weapon => equip.weapon = (min_dmg, max_dmg),
234                    EquipmentSlot::Shield => equip.offhand = (min_dmg, max_dmg),
235                    _ => {}
236                },
237                ItemType::Shield { block_chance } => {
238                    equip.offhand = (block_chance, 0);
239                }
240                _ => (),
241            }
242        }
243
244        let portal_dmg_bonus = 1.0 + f64::from(char.portal_dmg_bonus) / 100.0;
245
246        BattleFighter {
247            is_companion: char.is_companion,
248            class: char.class,
249            attributes,
250            max_hp: hp,
251            current_hp: hp,
252            equip,
253            rounds_in_battle: 0,
254            class_effect: ClassEffect::Normal,
255            portal_dmg_bonus,
256            level: char.level,
257        }
258    }
259
260    #[must_use]
261    pub fn from_squad(squad: &PlayerFighterSquad) -> Vec<Self> {
262        let mut res = if let Some(comps) = &squad.companions {
263            let mut res = Vec::with_capacity(4);
264            for comp in comps.as_array() {
265                res.push(Self::from_upgradeable(comp));
266            }
267            res
268        } else {
269            Vec::with_capacity(1)
270        };
271        res.push(BattleFighter::from_upgradeable(&squad.character));
272        res
273    }
274
275    pub fn reset(&mut self) {
276        self.class_effect = ClassEffect::Normal;
277        self.current_hp = self.max_hp;
278        self.rounds_in_battle = 0;
279    }
280}
281
282#[derive(Debug, Clone)]
283pub struct EquipmentEffects {
284    element_res: EnumMap<Element, f64>,
285    element_dmg: EnumMap<Element, f64>,
286
287    weapon: (u32, u32),
288    /// min,max for weapons | blockchange, 0 for shields
289    offhand: (u32, u32),
290
291    /// Shadow of the cowboy
292    reaction_boost: bool,
293    /// Sword of Vengeance
294    extra_crit_dmg: bool,
295
296    armor: u32,
297}
298
299#[derive(Debug, Clone, Copy, Enum, EnumIter)]
300pub enum Element {
301    Lightning,
302    Cold,
303    Fire,
304}
305
306#[derive(Debug)]
307pub struct BattleTeam<'a> {
308    current_fighter: usize,
309    fighters: &'a mut [BattleFighter],
310}
311
312#[allow(clippy::extra_unused_lifetimes)]
313impl<'a> BattleTeam<'_> {
314    #[must_use]
315    pub fn current(&self) -> Option<&BattleFighter> {
316        self.fighters.get(self.current_fighter)
317    }
318    #[must_use]
319    pub fn current_mut(&mut self) -> Option<&mut BattleFighter> {
320        self.fighters.get_mut(self.current_fighter)
321    }
322
323    fn reset(&mut self) {
324        self.current_fighter = 0;
325        for fighter in self.fighters.iter_mut() {
326            fighter.reset();
327        }
328    }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq, Enum)]
332pub enum BattleSide {
333    Left,
334    Right,
335}
336
337#[derive(Debug)]
338pub struct Battle<'a> {
339    pub round: u32,
340    pub started: Option<BattleSide>,
341    pub left: BattleTeam<'a>,
342    pub right: BattleTeam<'a>,
343    pub rng: Rng,
344}
345
346impl<'a> Battle<'a> {
347    pub fn new(
348        left: &'a mut [BattleFighter],
349        right: &'a mut [BattleFighter],
350    ) -> Self {
351        Self {
352            round: 0,
353            started: None,
354            left: BattleTeam {
355                current_fighter: 0,
356                fighters: left,
357            },
358            right: BattleTeam {
359                current_fighter: 0,
360                fighters: right,
361            },
362            rng: fastrand::Rng::default(),
363        }
364    }
365
366    /// Simulates a battle between the two sides. Returns the winning side.
367    pub fn simulate(&mut self, logger: &mut impl BattleLogger) -> BattleSide {
368        self.reset();
369        loop {
370            if let Some(winner) = self.simulate_turn(logger) {
371                return winner;
372            }
373        }
374    }
375
376    pub fn reset(&mut self) {
377        self.round = 0;
378        self.left.reset();
379        self.right.reset();
380        self.started = None;
381    }
382
383    /// Simulates one turn (attack) in a battle. If one side is not able
384    /// to fight anymore, or is for another reason invalid, the other side is
385    /// returned as the winner
386    pub fn simulate_turn(
387        &mut self,
388        logger: &mut impl BattleLogger,
389    ) -> Option<BattleSide> {
390        use AttackType::{Offhand, Swoop, Weapon};
391        use BattleSide::{Left, Right};
392        use Class::{
393            Assassin, Bard, BattleMage, Berserker, DemonHunter, Druid, Mage,
394            Necromancer, Paladin, Scout, Warrior,
395        };
396
397        logger.log(BE::TurnUpdate(self));
398
399        let Some(left) = self.left.current_mut() else {
400            logger.log(BE::BattleEnd(self, Right));
401            return Some(Right);
402        };
403        let Some(right) = self.right.current_mut() else {
404            logger.log(BE::BattleEnd(self, Left));
405            return Some(Left);
406        };
407
408        self.round += 1;
409        left.rounds_in_battle += 1;
410        right.rounds_in_battle += 1;
411
412        let attacking_side = if let Some(started) = self.started {
413            let one_vs_one_round =
414                left.rounds_in_battle.min(right.rounds_in_battle);
415
416            // If We are at the same cycle, as the first turn, the one that
417            // started on the first turn starts here. Otherwise the other one
418            match started {
419                _ if one_vs_one_round % 2 == 1 => started,
420                Left => Right,
421                Right => Left,
422            }
423        } else {
424            // The battle has not yet started. Figure out who side starts
425            let attacking_side =
426                match (right.equip.reaction_boost, left.equip.reaction_boost) {
427                    (true, true) | (false, false) if self.rng.bool() => Right,
428                    (true, false) => Right,
429                    _ => Left,
430                };
431            self.started = Some(attacking_side);
432            attacking_side
433        };
434
435        let (attacker, defender) = match attacking_side {
436            Left => (left, right),
437            Right => (right, left),
438        };
439
440        let turn = self.round;
441        let rng = &mut self.rng;
442        match attacker.class {
443            Paladin => {
444                // TODO: Actually implement stances and stuff
445                attack(attacker, defender, rng, Weapon, turn, logger);
446            }
447            Warrior | Scout | Mage | DemonHunter => {
448                attack(attacker, defender, rng, Weapon, turn, logger);
449            }
450            Assassin => {
451                attack(attacker, defender, rng, Weapon, turn, logger);
452                attack(attacker, defender, rng, Offhand, turn, logger);
453            }
454            Berserker => {
455                for _ in 0..15 {
456                    attack(attacker, defender, rng, Weapon, turn, logger);
457                    if defender.current_hp <= 0 || rng.bool() {
458                        break;
459                    }
460                }
461            }
462            BattleMage => {
463                if attacker.rounds_in_battle == 1 {
464                    if defender.class == Mage {
465                        logger.log(BE::CometRepelled(attacker, defender));
466                    } else {
467                        let dmg = match defender.class {
468                            Mage => 0,
469                            Bard => attacker.max_hp / 10,
470                            Scout | Assassin | Berserker | Necromancer
471                            | DemonHunter => attacker.max_hp / 5,
472                            Warrior | BattleMage | Druid => attacker.max_hp / 4,
473                            Paladin => (attacker.max_hp as f64 / (10.0 / 3.0))
474                                .trunc()
475                                as i64,
476                        };
477                        let dmg = dmg.min(defender.max_hp / 3);
478                        logger.log(BE::CometAttack(attacker, defender));
479                        // TODO: Can you dodge this?
480                        do_damage(attacker, defender, dmg, rng, logger);
481                    }
482                }
483                attack(attacker, defender, rng, Weapon, turn, logger);
484            }
485            Druid => {
486                // Check if we do a sweep attack
487                if !matches!(
488                    attacker.class_effect,
489                    ClassEffect::Druid { bear: true, .. }
490                ) {
491                    let swoops = attacker.class_effect.druid_swoops();
492                    let swoop_chance =
493                        0.15 + ((f32::from(swoops) * 5.0) / 100.0);
494                    if defender.class != Class::Mage
495                        && rng.f32() <= swoop_chance
496                    {
497                        attack(attacker, defender, rng, Swoop, turn, logger);
498                        attacker.class_effect = ClassEffect::Druid {
499                            bear: false,
500                            // max 7 to limit chance to 50%
501                            swoops: (swoops + 1).min(7),
502                        }
503                    }
504                }
505
506                attack(attacker, defender, rng, Weapon, turn, logger);
507                // TODO: Does this reset here, or on the start of the next
508                // attack?
509                attacker.class_effect = ClassEffect::Druid {
510                    bear: false,
511                    swoops: attacker.class_effect.druid_swoops(),
512                };
513            }
514            Bard => {
515                // Start a new melody every 4 turns
516                if attacker.rounds_in_battle % 4 == 0 {
517                    let quality = rng.u8(0..4);
518                    let (quality, remaining) = match quality {
519                        0 => (HarpQuality::Bad, 3),
520                        1 | 2 => (HarpQuality::Medium, 3),
521                        _ => (HarpQuality::Good, 4),
522                    };
523                    attacker.class_effect =
524                        ClassEffect::Bard { quality, remaining };
525                    logger.log(BE::BardPlay(attacker, defender, quality));
526                }
527                attack(attacker, defender, rng, Weapon, turn, logger);
528                if let ClassEffect::Bard { remaining, .. } =
529                    &mut attacker.class_effect
530                {
531                    *remaining = remaining.saturating_sub(1);
532                }
533            }
534            Necromancer => {
535                let has_minion = matches!(
536                    attacker.class_effect,
537                    ClassEffect::Necromancer { remaining: 1.., .. }
538                );
539                if !has_minion && defender.class != Class::Mage && rng.bool() {
540                    let (typ, rem) = match rng.u8(0..3) {
541                        0 => (Minion::Skeleton { revived: 0 }, 3),
542                        1 => (Minion::Hound, 2),
543                        _ => (Minion::Golem, 4),
544                    };
545                    attacker.class_effect = ClassEffect::Necromancer {
546                        typ,
547                        remaining: rem,
548                    };
549                    logger.log(BE::MinionSpawned(attacker, defender, typ));
550                    attack(
551                        attacker,
552                        defender,
553                        rng,
554                        AttackType::Minion,
555                        turn,
556                        logger,
557                    );
558                } else {
559                    if has_minion {
560                        attack(
561                            attacker,
562                            defender,
563                            rng,
564                            AttackType::Minion,
565                            turn,
566                            logger,
567                        );
568                    }
569                    attack(attacker, defender, rng, Weapon, turn, logger);
570                }
571                if let ClassEffect::Necromancer { remaining, typ } =
572                    &mut attacker.class_effect
573                {
574                    if *remaining > 0 {
575                        let mut has_revived = false;
576                        if let Minion::Skeleton { revived } = typ {
577                            if *revived < 2 && self.rng.bool() {
578                                *revived += 1;
579                                has_revived = true;
580                            }
581                        }
582                        if has_revived {
583                            // TODO: this revives for one turn, right?
584                            *remaining = 1;
585                            logger.log(BE::MinionSkeletonRevived(
586                                attacker, defender,
587                            ));
588                        } else {
589                            *remaining -= 1;
590                        }
591                    }
592                }
593            }
594        }
595        if defender.current_hp <= 0 {
596            match attacking_side {
597                Left => {
598                    self.right.current_fighter += 1;
599                    logger.log(BE::FighterDefeat(self, Right));
600                }
601                Right => {
602                    self.left.current_fighter += 1;
603                    logger.log(BE::FighterDefeat(self, Left));
604                }
605            }
606        }
607        None
608    }
609}
610
611// Does the specified amount of damage to the target. The only special thing
612// this does is revive demon hunters
613fn do_damage(
614    from: &mut BattleFighter,
615    to: &mut BattleFighter,
616    damage: i64,
617    rng: &mut Rng,
618    logger: &mut impl BattleLogger,
619) {
620    to.current_hp -= damage;
621    logger.log(BE::DamageReceived(from, to, damage));
622
623    if to.current_hp > 0 {
624        return;
625    }
626    let ClassEffect::DemonHunter { revived } = &mut to.class_effect else {
627        return;
628    };
629    let (chance, hp_restore) = match revived {
630        0 => (0.44, 0.9),
631        1 => (0.33, 0.8),
632        2 => (0.22, 0.7),
633        3 => (0.11, 0.6),
634        _ => return,
635    };
636
637    if rng.f32() >= chance {
638        return;
639    }
640
641    // The demon hunter revived
642    to.current_hp = (hp_restore * to.max_hp as f64) as i64;
643    *revived += 1;
644    logger.log(BE::DemonHunterRevived(from, to));
645}
646
647fn attack(
648    attacker: &mut BattleFighter,
649    defender: &mut BattleFighter,
650    rng: &mut Rng,
651    typ: AttackType,
652    turn: u32,
653    logger: &mut impl BattleLogger,
654) {
655    if defender.current_hp <= 0 {
656        // Skip pointless attacks
657        return;
658    }
659
660    logger.log(BE::Attack(attacker, defender, typ));
661    // Check dodges
662    if attacker.class != Class::Mage {
663        // TODO: Different dodge rates (druid 35%)
664        if defender.class == Class::Scout && rng.bool() {
665            // defender dodged
666            if defender.class == Class::Druid {
667                // TODO: is this instant, or does this trigger on start of def.
668                // turn?
669                defender.class_effect = ClassEffect::Druid {
670                    bear: true,
671                    swoops: defender.class_effect.druid_swoops(),
672                };
673            }
674            logger.log(BE::Dodged(attacker, defender));
675            return;
676        }
677        if defender.class == Class::Warrior
678            && !defender.is_companion
679            && defender.equip.offhand.0 as f32 / 100.0 > rng.f32()
680        {
681            // defender blocked
682            logger.log(BE::Blocked(attacker, defender));
683            return;
684        }
685    }
686
687    // TODO: Most of this can be reused, as long as the opponent does not
688    // change. Should make sure this is correct first though
689    let char_damage_modifier = 1.0
690        + f64::from(*attacker.attributes.get(attacker.class.main_attribute()))
691            / 10.0;
692
693    let mut elemental_bonus = 1.0;
694    for element in Element::iter() {
695        let plus = attacker.equip.element_dmg.get(element);
696        let minus = defender.equip.element_dmg.get(element);
697
698        if plus > minus {
699            elemental_bonus += plus - minus;
700        }
701    }
702
703    let armor = f64::from(defender.equip.armor) * defender.class.armor_factor();
704    let max_dr = defender.class.max_damage_reduction();
705    // TODO: Is this how mage armor negate works?
706    let armor_damage_effect = if attacker.class == Class::Mage {
707        1.0
708    } else {
709        1.0 - (armor / f64::from(attacker.level)).min(max_dr)
710    };
711
712    // The damage bonus you get from some class specific gimmic
713    let class_effect_dmg_bonus = match attacker.class_effect {
714        ClassEffect::Bard { quality, .. } if defender.class != Class::Mage => {
715            match quality {
716                HarpQuality::Bad => 1.2,
717                HarpQuality::Medium => 1.4,
718                HarpQuality::Good => 1.6,
719            }
720        }
721        ClassEffect::Necromancer {
722            typ: minion_type,
723            remaining: 1..,
724        } if typ == AttackType::Minion => match minion_type {
725            Minion::Skeleton { .. } => 1.25,
726            Minion::Hound => 2.0,
727            Minion::Golem => 1.0,
728        },
729        ClassEffect::Druid { .. } if typ == AttackType::Swoop => 1.8,
730        _ => 1.0,
731    };
732
733    // TODO: Is this the correct formula
734    let rage_bonus = 1.0 * (f64::from(turn.saturating_sub(1)) / 6.0);
735
736    let damage_bonus = char_damage_modifier
737        * attacker.portal_dmg_bonus
738        * elemental_bonus
739        * armor_damage_effect
740        * attacker.class.damage_factor(defender.class)
741        * rage_bonus
742        * class_effect_dmg_bonus;
743
744    // FIXME: Is minion damage based on weapon, or unarmed damage?
745    let weapon = match typ {
746        AttackType::Offhand => attacker.equip.offhand,
747        _ => attacker.equip.weapon,
748    };
749
750    let calc_damage =
751        |weapon_dmg| (f64::from(weapon_dmg) * damage_bonus).trunc() as i64;
752
753    let min_base_damage = calc_damage(weapon.0);
754    let max_base_damage = calc_damage(weapon.1);
755
756    let mut damage = rng.i64(min_base_damage..=max_base_damage);
757
758    // Crits
759
760    let luck_mod = attacker.attributes.get(AttributeType::Luck) * 5;
761    let raw_crit_chance = f64::from(luck_mod) / f64::from(defender.level);
762    let mut crit_chance = raw_crit_chance.min(0.5);
763    let mut crit_dmg_factor = 2.0;
764
765    match attacker.class_effect {
766        ClassEffect::Druid { bear: true, .. } => {
767            crit_chance += 0.1;
768            crit_dmg_factor += 2.0;
769        }
770        ClassEffect::Necromancer {
771            typ: Minion::Hound, ..
772        } => {
773            crit_chance += 0.1;
774            crit_dmg_factor += 0.5;
775        }
776        _ => {}
777    }
778
779    if rng.f64() <= crit_chance {
780        if attacker.equip.extra_crit_dmg {
781            crit_dmg_factor += 0.05;
782        };
783        logger.log(BE::Crit(attacker, defender));
784        damage = (damage as f64 * crit_dmg_factor) as i64;
785    }
786
787    do_damage(attacker, defender, damage, rng, logger);
788}
789
790#[derive(Debug)]
791pub struct PlayerFighterSquad {
792    pub character: UpgradeableFighter,
793    pub companions: Option<EnumMap<CompanionClass, UpgradeableFighter>>,
794}
795
796impl PlayerFighterSquad {
797    #[must_use]
798    pub fn new(gs: &GameState) -> PlayerFighterSquad {
799        let mut pet_attribute_bonus_perc = EnumMap::default();
800        if let Some(pets) = &gs.pets {
801            for (typ, info) in &pets.habitats {
802                let mut total_bonus = 0;
803                for pet in &info.pets {
804                    total_bonus += match pet.level {
805                        0 => 0,
806                        1..100 => 100,
807                        100..150 => 150,
808                        150..200 => 175,
809                        200.. => 200,
810                    };
811                }
812                *pet_attribute_bonus_perc.get_mut(typ.into()) =
813                    f64::from(total_bonus / 100) / 100.0;
814            }
815        };
816        let portal_hp_bonus = gs
817            .dungeons
818            .portal
819            .as_ref()
820            .map(|a| a.player_hp_bonus)
821            .unwrap_or_default()
822            .into();
823        let portal_dmg_bonus = gs
824            .guild
825            .as_ref()
826            .map(|a| a.portal.damage_bonus)
827            .unwrap_or_default()
828            .into();
829
830        let char = &gs.character;
831        let character = UpgradeableFighter {
832            is_companion: false,
833            level: char.level,
834            class: char.class,
835            attribute_basis: char.attribute_basis,
836            _attributes_bought: char.attribute_times_bought,
837            equipment: char.equipment.clone(),
838            active_potions: char.active_potions,
839            pet_attribute_bonus_perc,
840            portal_hp_bonus,
841            portal_dmg_bonus,
842        };
843        let mut companions = None;
844        if let Some(comps) = &gs.dungeons.companions {
845            let classes = [
846                CompanionClass::Warrior,
847                CompanionClass::Mage,
848                CompanionClass::Scout,
849            ];
850
851            let res = classes.map(|class| {
852                let comp = comps.get(class);
853                UpgradeableFighter {
854                    is_companion: true,
855                    level: comp.level.try_into().unwrap_or(1),
856                    class: class.into(),
857                    attribute_basis: comp.attributes,
858                    _attributes_bought: EnumMap::default(),
859                    equipment: comp.equipment.clone(),
860                    active_potions: char.active_potions,
861                    pet_attribute_bonus_perc,
862                    portal_hp_bonus,
863                    portal_dmg_bonus,
864                }
865            });
866            companions = Some(EnumMap::from_array(res));
867        }
868
869        PlayerFighterSquad {
870            character,
871            companions,
872        }
873    }
874}
875
876impl UpgradeableFighter {
877    #[must_use]
878    pub fn attributes(&self) -> EnumMap<AttributeType, u32> {
879        let mut total = EnumMap::default();
880
881        for equip in self.equipment.0.iter().flat_map(|a| a.1) {
882            for (k, v) in &equip.attributes {
883                *total.get_mut(k) += v;
884            }
885
886            if let Some(GemSlot::Filled(gem)) = &equip.gem_slot {
887                use AttributeType as AT;
888                let mut value = gem.value;
889                if matches!(equip.typ, ItemType::Weapon { .. })
890                    && !self.is_companion
891                {
892                    value *= 2;
893                }
894
895                let mut add_atr = |at| *total.get_mut(at) += value;
896                match gem.typ {
897                    GemType::Strength => add_atr(AT::Strength),
898                    GemType::Dexterity => add_atr(AT::Dexterity),
899                    GemType::Intelligence => add_atr(AT::Intelligence),
900                    GemType::Constitution => add_atr(AT::Constitution),
901                    GemType::Luck => add_atr(AT::Luck),
902                    GemType::All => {
903                        total.iter_mut().for_each(|a| *a.1 += value);
904                    }
905                    GemType::Legendary => {
906                        add_atr(AT::Constitution);
907                        add_atr(self.class.main_attribute());
908                    }
909                }
910            }
911        }
912
913        let class_bonus: f64 = match self.class {
914            Class::BattleMage => 0.1111,
915            _ => 0.0,
916        };
917
918        let pet_boni = self.pet_attribute_bonus_perc;
919
920        for (k, v) in &mut total {
921            let class_bonus = (f64::from(*v) * class_bonus).trunc() as u32;
922            *v += class_bonus + self.attribute_basis.get(k);
923            if let Some(potion) = self
924                .active_potions
925                .iter()
926                .flatten()
927                .find(|a| a.typ == k.into())
928            {
929                *v += (f64::from(*v) * potion.size.effect()) as u32;
930            }
931
932            let pet_bonus = (f64::from(*v) * (*pet_boni.get(k))).trunc() as u32;
933            *v += pet_bonus;
934        }
935        total
936    }
937
938    #[must_use]
939    #[allow(clippy::enum_glob_use)]
940    pub fn hit_points(&self, attributes: &EnumMap<AttributeType, u32>) -> i64 {
941        use Class::*;
942
943        let mut total = i64::from(*attributes.get(AttributeType::Constitution));
944        total = (total as f64
945            * match self.class {
946                Warrior if self.is_companion => 6.1,
947                Paladin => 6.0,
948                Warrior | BattleMage | Druid => 5.0,
949                Scout | Assassin | Berserker | DemonHunter | Necromancer => 4.0,
950                Mage | Bard => 2.0,
951            })
952        .trunc() as i64;
953
954        total *= i64::from(self.level) + 1;
955
956        if self
957            .active_potions
958            .iter()
959            .flatten()
960            .any(|a| a.typ == PotionType::EternalLife)
961        {
962            total = (total as f64 * 1.25).trunc() as i64;
963        }
964
965        let portal_bonus = (total as f64
966            * (f64::from(self.portal_hp_bonus) / 100.0))
967            .trunc() as i64;
968
969        total += portal_bonus;
970
971        let mut rune_multi = 0;
972        for rune in self
973            .equipment
974            .0
975            .iter()
976            .flat_map(|a| a.1)
977            .filter_map(|a| a.rune)
978        {
979            if rune.typ == RuneType::ExtraHitPoints {
980                rune_multi += u32::from(rune.value);
981            }
982        }
983
984        let rune_bonus =
985            (total as f64 * (f64::from(rune_multi) / 100.0)).trunc() as i64;
986
987        total += rune_bonus;
988        total
989    }
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct Monster {
994    pub level: u16,
995    pub class: Class,
996    pub attributes: EnumMap<AttributeType, u32>,
997    pub hp: u64,
998    pub xp: u32,
999}
1000
1001impl Monster {
1002    #[must_use]
1003    pub const fn new(
1004        level: u16,
1005        class: Class,
1006        attribs: [u32; 5],
1007        hp: u64,
1008        xp: u32,
1009    ) -> Self {
1010        Monster {
1011            level,
1012            class,
1013            attributes: EnumMap::from_array(attribs),
1014            hp,
1015            xp,
1016        }
1017    }
1018}
1019
1020#[derive(Debug)]
1021#[non_exhaustive]
1022pub enum BattleEvent<'a, 'b> {
1023    TurnUpdate(&'a Battle<'b>),
1024    BattleEnd(&'a Battle<'b>, BattleSide),
1025    Attack(&'b BattleFighter, &'b BattleFighter, AttackType),
1026    Dodged(&'b BattleFighter, &'b BattleFighter),
1027    Blocked(&'b BattleFighter, &'b BattleFighter),
1028    Crit(&'b BattleFighter, &'b BattleFighter),
1029    DamageReceived(&'b BattleFighter, &'b BattleFighter, i64),
1030    DemonHunterRevived(&'b BattleFighter, &'b BattleFighter),
1031    CometRepelled(&'b BattleFighter, &'b BattleFighter),
1032    CometAttack(&'b BattleFighter, &'b BattleFighter),
1033    MinionSpawned(&'b BattleFighter, &'b BattleFighter, Minion),
1034    MinionSkeletonRevived(&'b BattleFighter, &'b BattleFighter),
1035    BardPlay(&'b BattleFighter, &'b BattleFighter, HarpQuality),
1036    FighterDefeat(&'a Battle<'b>, BattleSide),
1037}
1038
1039pub trait BattleLogger {
1040    fn log(&mut self, event: BattleEvent<'_, '_>);
1041}
1042
1043impl BattleLogger for () {
1044    fn log(&mut self, _event: BattleEvent<'_, '_>) {
1045    }
1046}