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        GameState, character::Class, dungeons::CompanionClass, items::*,
15        social::OtherPlayer,
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, PlagueDoctor, 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 | PlagueDoctor => {
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 | PlagueDoctor => 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                    && *remaining > 0
737                {
738                    let mut has_revived = false;
739                    if let Minion::Skeleton { revived } = typ
740                        && *revived < 2
741                        && self.rng.bool()
742                    {
743                        *revived += 1;
744                        has_revived = true;
745                    }
746                    if has_revived {
747                        // TODO: this revives for one turn, right?
748                        *remaining = 1;
749                        logger
750                            .log(BE::MinionSkeletonRevived(attacker, defender));
751                    } else {
752                        *remaining -= 1;
753                    }
754                }
755            }
756        }
757        if defender.current_hp <= 0 {
758            match attacking_side {
759                Left => {
760                    self.right.current_fighter += 1;
761                    logger.log(BE::FighterDefeat(self, Right));
762                }
763                Right => {
764                    self.left.current_fighter += 1;
765                    logger.log(BE::FighterDefeat(self, Left));
766                }
767            }
768        }
769        None
770    }
771}
772
773// Does the specified amount of damage to the target. The only special thing
774// this does is revive demon hunters
775fn do_damage(
776    from: &mut BattleFighter,
777    to: &mut BattleFighter,
778    damage: i64,
779    rng: &mut Rng,
780    logger: &mut impl BattleLogger,
781) {
782    to.current_hp -= damage;
783    logger.log(BE::DamageReceived(from, to, damage));
784
785    if to.current_hp > 0 {
786        return;
787    }
788    let ClassEffect::DemonHunter { revived } = &mut to.class_effect else {
789        return;
790    };
791    let (chance, hp_restore) = match revived {
792        0 => (0.44, 0.9),
793        1 => (0.33, 0.8),
794        2 => (0.22, 0.7),
795        3 => (0.11, 0.6),
796        _ => return,
797    };
798
799    if rng.f32() >= chance {
800        return;
801    }
802
803    // The demon hunter revived
804    to.current_hp = (hp_restore * to.max_hp as f64) as i64;
805    *revived += 1;
806    logger.log(BE::DemonHunterRevived(from, to));
807}
808
809fn attack(
810    attacker: &mut BattleFighter,
811    defender: &mut BattleFighter,
812    rng: &mut Rng,
813    typ: AttackType,
814    turn: u32,
815    logger: &mut impl BattleLogger,
816) {
817    if defender.current_hp <= 0 {
818        // Skip pointless attacks
819        return;
820    }
821
822    logger.log(BE::Attack(attacker, defender, typ));
823    // Check dodges
824    if attacker.class != Class::Mage {
825        // Druid has 35% dodge chance
826        if defender.class == Class::Druid && rng.f32() <= 0.35 {
827            // TODO: is this instant, or does this trigger on start of def.
828            // turn?
829            defender.class_effect = ClassEffect::Druid {
830                bear: true,
831                swoops: defender.class_effect.druid_swoops(),
832            };
833            logger.log(BE::Dodged(attacker, defender));
834        }
835        // Scout and assassin have 50% dodge chance
836        if (defender.class == Class::Scout || defender.class == Class::Assassin)
837            && rng.bool()
838        {
839            logger.log(BE::Dodged(attacker, defender));
840            return;
841        }
842        if defender.class == Class::Warrior
843            && !defender.is_companion
844            && defender.equip.offhand.0 as f32 / 100.0 > rng.f32()
845        {
846            // defender blocked
847            logger.log(BE::Blocked(attacker, defender));
848            return;
849        }
850    }
851
852    // TODO: Most of this can be reused, as long as the opponent does not
853    // change. Should make sure this is correct first though
854    let char_damage_modifier = 1.0
855        + f64::from(*attacker.attributes.get(attacker.class.main_attribute()))
856            / 10.0;
857
858    let mut elemental_bonus = 1.0;
859    for element in Element::iter() {
860        let plus = attacker.equip.element_dmg.get(element);
861        let minus = defender.equip.element_dmg.get(element);
862
863        if plus > minus {
864            elemental_bonus += plus - minus;
865        }
866    }
867
868    let armor = f64::from(defender.equip.armor) * defender.class.armor_factor();
869    let max_dr = defender.class.max_damage_reduction();
870    // TODO: Is this how mage armor negate works?
871    let armor_damage_effect = if attacker.class == Class::Mage {
872        1.0
873    } else {
874        1.0 - (armor / f64::from(attacker.level)).min(max_dr)
875    };
876
877    // The damage bonus you get from some class specific gimmic
878    let class_effect_dmg_bonus = match attacker.class_effect {
879        ClassEffect::Bard { quality, .. } if defender.class != Class::Mage => {
880            match quality {
881                HarpQuality::Bad => 1.2,
882                HarpQuality::Medium => 1.4,
883                HarpQuality::Good => 1.6,
884            }
885        }
886        ClassEffect::Necromancer {
887            typ: minion_type,
888            remaining: 1..,
889        } if typ == AttackType::Minion => match minion_type {
890            Minion::Skeleton { .. } => 1.25,
891            Minion::Hound => 2.0,
892            Minion::Golem => 1.0,
893        },
894        ClassEffect::Druid { .. } if typ == AttackType::Swoop => 1.8,
895        _ => 1.0,
896    };
897
898    // TODO: Is this the correct formula
899    let rage_bonus = 1.0 + (f64::from(turn.saturating_sub(1)) / 6.0);
900
901    let damage_bonus = char_damage_modifier
902        * attacker.portal_dmg_bonus
903        * elemental_bonus
904        * armor_damage_effect
905        * attacker.class.damage_factor(defender.class)
906        * rage_bonus
907        * class_effect_dmg_bonus;
908
909    // FIXME: Is minion damage based on weapon, or unarmed damage?
910    let weapon = match typ {
911        AttackType::Offhand => attacker.equip.offhand,
912        _ => attacker.equip.weapon,
913    };
914
915    let calc_damage =
916        |weapon_dmg| (f64::from(weapon_dmg) * damage_bonus).trunc() as i64;
917
918    let min_base_damage = calc_damage(weapon.0);
919    let max_base_damage = calc_damage(weapon.1);
920
921    let mut damage = rng.i64(min_base_damage..=max_base_damage);
922
923    // Crits
924
925    let luck_mod = attacker.attributes.get(AttributeType::Luck) * 5;
926    let raw_crit_chance = f64::from(luck_mod) / f64::from(defender.level);
927    let mut crit_chance = raw_crit_chance.min(0.5);
928    let mut crit_dmg_factor = 2.0;
929
930    match attacker.class_effect {
931        ClassEffect::Druid { bear: true, .. } => {
932            crit_chance += 0.1;
933            crit_dmg_factor += 2.0;
934        }
935        ClassEffect::Necromancer {
936            typ: Minion::Hound, ..
937        } => {
938            crit_chance += 0.1;
939            crit_dmg_factor += 0.5;
940        }
941        _ => {}
942    }
943
944    if rng.f64() <= crit_chance {
945        if attacker.equip.extra_crit_dmg {
946            crit_dmg_factor += 0.05;
947        }
948        logger.log(BE::Crit(attacker, defender));
949        damage = (damage as f64 * crit_dmg_factor) as i64;
950    }
951
952    do_damage(attacker, defender, damage, rng, logger);
953}
954
955#[derive(Debug)]
956pub struct PlayerFighterSquad {
957    pub character: UpgradeableFighter,
958    pub companions: Option<EnumMap<CompanionClass, UpgradeableFighter>>,
959}
960
961impl PlayerFighterSquad {
962    #[must_use]
963    pub fn new(gs: &GameState) -> PlayerFighterSquad {
964        let mut pet_attribute_bonus_perc = EnumMap::default();
965        if let Some(pets) = &gs.pets {
966            for (typ, info) in &pets.habitats {
967                let mut total_bonus = 0;
968                for pet in &info.pets {
969                    total_bonus += match pet.level {
970                        0 => 0,
971                        1..100 => 100,
972                        100..150 => 150,
973                        150..200 => 175,
974                        200.. => 200,
975                    };
976                }
977                *pet_attribute_bonus_perc.get_mut(typ.into()) =
978                    f64::from(total_bonus / 100) / 100.0;
979            }
980        }
981        let portal_hp_bonus = gs
982            .dungeons
983            .portal
984            .as_ref()
985            .map(|a| a.player_hp_bonus)
986            .unwrap_or_default()
987            .into();
988        let portal_dmg_bonus = gs
989            .guild
990            .as_ref()
991            .map(|a| a.portal.damage_bonus)
992            .unwrap_or_default()
993            .into();
994
995        let char = &gs.character;
996        let character = UpgradeableFighter {
997            is_companion: false,
998            level: char.level,
999            class: char.class,
1000            attribute_basis: char.attribute_basis,
1001            equipment: char.equipment.clone(),
1002            active_potions: char.active_potions,
1003            pet_attribute_bonus_perc,
1004            portal_hp_bonus,
1005            portal_dmg_bonus,
1006        };
1007        let mut companions = None;
1008        if let Some(comps) = &gs.dungeons.companions {
1009            let classes = [
1010                CompanionClass::Warrior,
1011                CompanionClass::Mage,
1012                CompanionClass::Scout,
1013            ];
1014
1015            let res = classes.map(|class| {
1016                let comp = comps.get(class);
1017                UpgradeableFighter {
1018                    is_companion: true,
1019                    level: comp.level.try_into().unwrap_or(1),
1020                    class: class.into(),
1021                    attribute_basis: comp.attributes,
1022                    equipment: comp.equipment.clone(),
1023                    active_potions: char.active_potions,
1024                    pet_attribute_bonus_perc,
1025                    portal_hp_bonus,
1026                    portal_dmg_bonus,
1027                }
1028            });
1029            companions = Some(EnumMap::from_array(res));
1030        }
1031
1032        PlayerFighterSquad {
1033            character,
1034            companions,
1035        }
1036    }
1037}
1038
1039impl UpgradeableFighter {
1040    #[must_use]
1041    pub fn attributes(&self) -> EnumMap<AttributeType, u32> {
1042        let mut total = EnumMap::default();
1043
1044        for equip in self.equipment.0.iter().flat_map(|a| a.1) {
1045            for (k, v) in &equip.attributes {
1046                *total.get_mut(k) += v;
1047            }
1048
1049            if let Some(GemSlot::Filled(gem)) = &equip.gem_slot {
1050                use AttributeType as AT;
1051                let mut value = gem.value;
1052                if matches!(equip.typ, ItemType::Weapon { .. })
1053                    && !self.is_companion
1054                {
1055                    value *= 2;
1056                }
1057
1058                let mut add_atr = |at| *total.get_mut(at) += value;
1059                match gem.typ {
1060                    GemType::Strength => add_atr(AT::Strength),
1061                    GemType::Dexterity => add_atr(AT::Dexterity),
1062                    GemType::Intelligence => add_atr(AT::Intelligence),
1063                    GemType::Constitution => add_atr(AT::Constitution),
1064                    GemType::Luck => add_atr(AT::Luck),
1065                    GemType::All => {
1066                        total.iter_mut().for_each(|a| *a.1 += value);
1067                    }
1068                    GemType::Legendary => {
1069                        add_atr(AT::Constitution);
1070                        add_atr(self.class.main_attribute());
1071                    }
1072                }
1073            }
1074        }
1075
1076        let class_bonus: f64 = match self.class {
1077            Class::BattleMage => 0.1111,
1078            _ => 0.0,
1079        };
1080
1081        let pet_boni = self.pet_attribute_bonus_perc;
1082
1083        for (k, v) in &mut total {
1084            let class_bonus = (f64::from(*v) * class_bonus).trunc() as u32;
1085            *v += class_bonus + self.attribute_basis.get(k);
1086            if let Some(potion) = self
1087                .active_potions
1088                .iter()
1089                .flatten()
1090                .find(|a| a.typ == k.into())
1091            {
1092                *v += (f64::from(*v) * potion.size.effect()) as u32;
1093            }
1094
1095            let pet_bonus = (f64::from(*v) * (*pet_boni.get(k))).trunc() as u32;
1096            *v += pet_bonus;
1097        }
1098        total
1099    }
1100
1101    #[must_use]
1102    #[allow(clippy::enum_glob_use)]
1103    pub fn hit_points(&self, attributes: &EnumMap<AttributeType, u32>) -> i64 {
1104        let mut total = i64::from(*attributes.get(AttributeType::Constitution));
1105        total = (total as f64 * self.class.life_multiplier(self.is_companion))
1106            .trunc() as i64;
1107
1108        total *= i64::from(self.level) + 1;
1109
1110        if self
1111            .active_potions
1112            .iter()
1113            .flatten()
1114            .any(|a| a.typ == PotionType::EternalLife)
1115        {
1116            total = (total as f64 * 1.25).trunc() as i64;
1117        }
1118
1119        let portal_bonus = (total as f64
1120            * (f64::from(self.portal_hp_bonus) / 100.0))
1121            .trunc() as i64;
1122
1123        total += portal_bonus;
1124
1125        let mut rune_multi = 0;
1126        for rune in self
1127            .equipment
1128            .0
1129            .iter()
1130            .flat_map(|a| a.1)
1131            .filter_map(|a| a.rune)
1132        {
1133            if rune.typ == RuneType::ExtraHitPoints {
1134                rune_multi += u32::from(rune.value);
1135            }
1136        }
1137
1138        let rune_bonus =
1139            (total as f64 * (f64::from(rune_multi) / 100.0)).trunc() as i64;
1140
1141        total += rune_bonus;
1142        total
1143    }
1144}
1145
1146#[derive(Debug, Clone, PartialEq, Eq)]
1147pub struct Monster {
1148    pub level: u16,
1149    pub class: Class,
1150    pub attributes: EnumMap<AttributeType, u32>,
1151    pub hp: u64,
1152    pub xp: u32,
1153}
1154
1155impl Monster {
1156    #[must_use]
1157    pub const fn new(
1158        level: u16,
1159        class: Class,
1160        attribs: [u32; 5],
1161        hp: u64,
1162        xp: u32,
1163    ) -> Self {
1164        Monster {
1165            level,
1166            class,
1167            attributes: EnumMap::from_array(attribs),
1168            hp,
1169            xp,
1170        }
1171    }
1172}
1173
1174#[derive(Debug)]
1175#[non_exhaustive]
1176pub enum BattleEvent<'a, 'b> {
1177    TurnUpdate(&'a Battle<'b>),
1178    BattleEnd(&'a Battle<'b>, BattleSide),
1179    Attack(&'b BattleFighter, &'b BattleFighter, AttackType),
1180    Dodged(&'b BattleFighter, &'b BattleFighter),
1181    Blocked(&'b BattleFighter, &'b BattleFighter),
1182    Crit(&'b BattleFighter, &'b BattleFighter),
1183    DamageReceived(&'b BattleFighter, &'b BattleFighter, i64),
1184    DemonHunterRevived(&'b BattleFighter, &'b BattleFighter),
1185    CometRepelled(&'b BattleFighter, &'b BattleFighter),
1186    CometAttack(&'b BattleFighter, &'b BattleFighter),
1187    MinionSpawned(&'b BattleFighter, &'b BattleFighter, Minion),
1188    MinionSkeletonRevived(&'b BattleFighter, &'b BattleFighter),
1189    BardPlay(&'b BattleFighter, &'b BattleFighter, HarpQuality),
1190    FighterDefeat(&'a Battle<'b>, BattleSide),
1191}
1192
1193pub trait BattleLogger {
1194    fn log(&mut self, event: BattleEvent<'_, '_>);
1195}
1196
1197impl BattleLogger for () {
1198    fn log(&mut self, _event: BattleEvent<'_, '_>) {
1199    }
1200}