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