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 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 portal_hp_bonus: u32,
38 portal_dmg_bonus: u32,
40}
41
42impl UpgradeableFighter {
43 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 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 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 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 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 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 pub rounds_started: u32,
182 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
213fn 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 bear: bool,
241 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 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 offhand: (u32, u32),
435
436 reaction_boost: bool,
438 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 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 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 match started {
581 _ if left.rounds_in_1v1 % 2 == 1 => started,
582 Left => Right,
583 Right => Left,
584 }
585 } else {
586 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 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 do_damage(attacker, defender, dmg, rng, logger);
644 }
645 }
646 attack(attacker, defender, rng, Weapon, turn, logger);
647 }
648 Druid => {
649 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 swoops: (swoops + 1).min(7),
665 }
666 }
667 }
668
669 attack(attacker, defender, rng, Weapon, turn, logger);
670 attacker.class_effect = ClassEffect::Druid {
673 bear: false,
674 swoops: attacker.class_effect.druid_swoops(),
675 };
676 }
677 Bard => {
678 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 *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
774fn 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 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 return;
821 }
822
823 logger.log(BE::Attack(attacker, defender, typ));
824 if attacker.class != Class::Mage {
826 if defender.class == Class::Druid && rng.f32() <= 0.35 {
828 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 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 logger.log(BE::Blocked(attacker, defender));
849 return;
850 }
851 }
852
853 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 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 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 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 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 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}