Source code for DnD_5e.attack_class.saving_throw_attacks

from typing import Optional

from DnD_5e import dice
from DnD_5e.attack_class import Attack


[docs] class SavingThrowMixin: def __init__(self, **kwargs): dc = kwargs.get("dc") if not dc or not isinstance(dc, int) or dc < 0: raise ValueError("DC must be a non-negative integer") self._dc = dc save_type = kwargs.get("save_type") if save_type in ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]: self._save_type = save_type else: raise ValueError("Save type must be strength, dexterity, constitution, intelligence, wisdom, or charisma") super().__init__(**kwargs) def __eq__(self, other): if other is self: return True if type(other) != type(self): # pylint: disable=unidiomatic-typecheck return False return super().__eq__(other) and self.get_dc() == other.get_dc() and self.get_save_type() == other.get_save_type()
[docs] def get_dc(self) -> int: """ :return: DC :rtype: non-negative integer """ return self._dc
[docs] def get_save_type(self) -> str: """ :return: ability score for saving throws :rtype: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma" """ return self._save_type
[docs] def get_prob_fail_save(self, **kwargs): """ :param target: the target you are forcing to make the saving throw :type target: :py:class:`Combatant` :param mod: the modifier for the target's saving throw. Use this or *target* :type mod: int :return: the probability that *target* (or somebody with a saving throw modifier of *mod*) fails the saving throw """ target = kwargs.get("target") if target: mod = target.get_saving_throw(self.get_save_type()) else: mod = kwargs.get("mod") return dice.Dice(dice_num=1, dice_type=20, modifier=mod).get_prob(self.get_dc(), kind="lt")
[docs] def do_saving_thow(self, source, target) -> bool: # pylint: disable=unused-argument return target.take_saving_throw(self.get_save_type(), self.get_dc(), self)
[docs] class SavingThrowAttack(SavingThrowMixin, Attack): """ This class represents Attacks that require a saving throw from the target instead of a roll to hit """ def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param dc: the difficulty class for the saving throw :type dc: non-negative integer :param save_type: the ability score to be used for the saving throw :type save_type: one of these strings: "strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma" :param damage_on_success: if this evaluates to True, the target takes half damage on a successful save :param name: what *self* is called. A unique name is recommended but not required :type name: str :param damage_dice: the dice to determine damage points :type damage_dice: TYPE_DICE_TUPLE or dice.DamageDice :param attack_mod: the number to be added to attack rolls :type attack_mod: int :param damage_mod: the number to be added to damage rolls :type damage_mod: int :param range_val: the range of the ranged attack :type range_val: usually an integer, but other types are allowed currently (a warning is issued) :param melee_range: the range of the melee attack :param adv: indicates whether the attack rolls should be made normally, with advantage, or with disadvanatage :type adv: one of these integers: -1, 0, 1 :param weapon: the Weapon this attack comes from, if any :type weapon: Weapon or None :raise: ValueError if input is invalid """ attack_dice = kwargs.get("attack_dice") if not attack_dice: kwargs.update(attack_dice=dice.NullDice) super().__init__(**kwargs) # irrelevant keyword arguments will be ignored damage_on_success = kwargs.get("damage_on_success") self._damage_on_success = bool(damage_on_success) # half damage on successful save def __eq__(self, other): """ Compare *self* and *other* to determine if they are equal based on what is checked in the superclass method as well as dc, save_type, and damage_on_success :param other: the SavingThrowAttack to be compared :type other: SavingThrowAttack :return: True if *self* __eq__ *other*, False otherwise :rtype: bool """ return super().__eq__(other) and self.get_damage_on_success() == other.get_damage_on_success()
[docs] def get_damage_on_success(self) -> bool: """ :return: damage on success :rtype: bool """ return self._damage_on_success
[docs] def get_prob_hit(self, **kwargs) -> float: """ :param target: the target who is making the saving throw :param mod: the target's modifier to the saving throw :return: the probability of target failing their saving throw """ return self.get_prob_fail_save(**kwargs)
[docs] def get_dpr(self, **kwargs) -> float: """ :param target: the target you are trying to hit :type target: :py:class:`Combatant` :return: the average damage per round (dpr) """ prob_hit = self.get_prob_hit(**kwargs) result = prob_hit * self.get_average_damage() if self.get_damage_on_success(): result += (1 - prob_hit) * (self.get_average_damage() // 2) return result
[docs] def make_attack(self, source, target, adv=0) -> Optional[int]: """ Call *target*. :py:meth:`take_saving_throw` to see if the target makes the save. Call :py:meth:`send_damage` (that method will handle whether to damage on a successful save). :param source: the Combatant that is making the attack :type source: Combatant :param target: the Combatant that *source* is attacking :type target: Combatant :param adv: advantage, disadvantage, or neither on the saving throw :type adv: one of these integers: -1, 0, 1 :return: the damage taken (if the attack misses, damage taken is None) :rtype: non-negative integer or None :raise: ValueError if *target* can't take attacks """ try: # pylint: disable=consider-using-f-string source.get_logger().info("%s attacks %s with %s." % (source.get_name(), target.get_name(), self.get_name())) if self.do_saving_thow(source, target): # pylint: disable=no-else-return # take_saving_throw returns True if target made the save source.get_logger().info("%s saves." % target.get_name()) return self.on_miss(source, target) else: source.get_logger().info("%s fails the saving throw!" % target.get_name()) return self.on_hit(source, target) except NameError: source.get_logger().error("Tried to attack something that can't take attacks", stack_info=True) raise ValueError(f"{source.get_name()} tried to attack something that can't take attacks")
[docs] def on_hit(self, source, target, adv=0, crit=0): """ The target failed the save, now do whatever happens on a failed save :param source: the Combatant that is making the attack :type source: Combatant :param target: the Combatant that *source* is attacking :type target: Combatant :param adv: advantage, disadvantage, or neither on the attack roll :type adv: one of these integers: -1, 0, 1 :type crit: whether or not the hit was a crit :return: the damage taken """ return self.send_damage(target)
[docs] def on_miss(self, source, target, adv=0, crit=0): """ The target made the save, now do whatever happens on a successful save :param source: the Combatant that is making the attack :type source: Combatant :param target: the Combatant that *source* is attacking :type target: Combatant :param adv: advantage, disadvantage, or neither on the attack roll :type adv: one of these integers: -1, 0, 1 :type crit: whether or not the hit was a crit :return: the damage taken """ return self.send_damage(target, saved=True)
[docs] def send_damage(self, target, saved: bool = False) -> Optional[int]: # pylint: disable=arguments-differ """ Roll damage using *self.roll_damage* and store the result in variable *damage*. Call *target*. :py:meth:`take_damage`, passing in *damage* and :py:attr:`_damage_type`. Arguments crit and crit_multiplier are not needed because crits are not possible :param target: the Combatant that is taking the damage :type target: Combatant :param saved: if this is True, damage is none if not :py:attr:`_damage_on_success` or half damage if :py:attr:`_damage_on_success` :type saved: bool :return: the damage taken (as returned by *target.take_damage*) :rtype: non-negative integer or None """ roll_result = self.roll_damage() try: roll_result.roll_number # pylint: disable=pointless-statement # stick namedtuple in list so that it can be processed the same way as output from DamageDiceBag roll_result = [roll_result] except AttributeError: # DamageDiceBag pass for result in roll_result: if not saved: if target.has_feature("evasion") and self._save_type == "dexterity": return target.take_damage(result.roll_number // 2, damage_type=result.damage_type) return target.take_damage(result.roll_number, damage_type=result.damage_type) if self._damage_on_success: if target.has_feature("evasion") and self._save_type == "dexterity": return 0 return target.take_damage(result.roll_number // 2, damage_type=result.damage_type) return None
[docs] class SaveOrDieAttack(SavingThrowMixin, Attack): """ Attacks where the target must make a saving throw and if it fails it dies. Does not work for attacks that have a saving throw and then a save or die. For that, you need a different approach (maybe a MultiAttack?) """ def __init__(self, **kwargs): """ Create the attack :param kwargs: see superclasses :param threshold: the hp below which the target must save or die :type threshold: int :param save_on_miss: whether the target still has to make the saving throw if the main attack missed :type save_on_miss: bool """ if not kwargs.get("damage_dice"): kwargs.update({"damage_dice": dice.NullDamageDice()}) self._threshold = kwargs.get("threshold") if not isinstance(self._threshold, int): raise ValueError("Must provide threshold (the hp below which the target must save or die)") self._save_on_miss = bool(kwargs.get("save_on_miss")) super().__init__(**kwargs)
[docs] def get_threshold(self) -> int: """ :return: the hp below which the target must save or die """ return self._threshold
[docs] def get_save_on_miss(self) -> bool: """ :return: True if the target still has to make the saving throw if the main attack missed, False otherwise """ return self._save_on_miss
[docs] def get_dpr(self, **kwargs) -> float: """ Calculate the damage per round, assuming that if the target fails the saving throw (and thus dies), they also take the damage of :param target: the target you are trying to hit :type target: :py:class:`Combatant` :return: the average damage per round (dpr) """ target = kwargs.get("target") prob_hit = self.get_prob_hit(target=target) # calculate the damage you need to take to get below the threshold damage_to_below_threshold = target.get_current_hp() - self.get_threshold() + 1 # possibility 1: failed attack. apply "save or die" if relevant. if self.get_save_on_miss() and damage_to_below_threshold < 1: miss_dpr = (1 - prob_hit) * target.get_current_hp() else: miss_dpr = 0 # possibility 2: successful attack. hp cannot possibly get low enough to trigger "save or die". if damage_to_below_threshold > self.get_damage_dice().get_max_value(): hit_dpr = prob_hit * self.get_average_damage() else: # TODO: double check hit_dpr = prob_hit * self.get_damage_dice().get_prob(target.get_current_hp(), kind="ge") * target.get_current_hp() # possibility 3: successful attack. hp might get low enough to trigger "save or die". if damage_to_below_threshold > 0: prob_below_threshold = self.get_damage_dice().get_prob(damage_to_below_threshold, kind="lt") else: prob_below_threshold = 1 prob_fail_save = self.get_prob_fail_save(**kwargs) hit_dpr = prob_hit * (self.get_average_damage() + prob_below_threshold * prob_fail_save * (self.get_threshold() - 1)) return miss_dpr + hit_dpr
[docs] def make_attack(self, source, target, adv: int = 0): """ Roll attack using :py:meth:`roll_attack` and store the result in variable *result*. Call *target*. :py:meth:`take_attack` to see if the attack hits. If the attack hits, call :py:meth:`send_damage`. :param source: the Combatant that is making the attack :type source: Combatant :param target: the Combatant that *source* is attacking :type target: Combatant :param adv: advantage, disadvantage, or neither on the attack roll :type adv: one of these integers: -1, 0, 1 :return: the damage taken (if the attack misses, damage taken is None) :rtype: non-negative integer or None :raise: ValueError if *source* can't send attacks or *target* can't take attacks """ damage_taken = super().make_attack(source, target, adv) if damage_taken is not None or self.get_save_on_miss(): if target.get_current_hp() < self.get_threshold(): if self.do_saving_thow(source, target): target.get_logger().info("%s saves and avoids death.", target.get_name()) else: target.get_logger().info("%s failed the save and will now die.", target.get_name()) target.die()
[docs] class HitAndSaveAttack(SavingThrowMixin, Attack): """ Attacks where the attacker makes a regular (roll to hit) attack and then, if that hits, makes a saving throw attack """ def __init__(self, **kwargs): super().__init__(**kwargs) save_damage_dice = kwargs.get("save_damage_dice") if not save_damage_dice: if not self._weapon: raise ValueError("Must provide damage dice or a weapon to get damage dice from") self._save_damage_dice = self._weapon.get_damage_dice().get_copy() elif isinstance(save_damage_dice, dice.DamageDice): self._save_damage_dice = save_damage_dice.get_copy() else: if isinstance(save_damage_dice, tuple): kwargs.update(dice_tuple=save_damage_dice) elif isinstance(save_damage_dice, str): kwargs.update(str_val=save_damage_dice) kwargs.update(modifier=kwargs.get("save_damage_mod", 0)) kwargs.update(critable=False) kwargs.update(adv=0) # can't have advantage or disadvantage on damage kwargs.update(damage_type=kwargs.get("save_damage_type")) self._save_damage_dice = dice.DamageDice(**kwargs) self._damage_on_success = bool(kwargs.get("damage_on_success")) def __eq__(self, other): return super().__eq__(other) and self.get_save_damage_dice() == other.get_save_damage_dice() \ and self.get_damage_on_success() == other.get_damage_on_success()
[docs] def get_save_damage_dice(self): """ :return: saving throw damage dice """ return self._save_damage_dice
[docs] def get_save_damage_type(self) -> str: """ :return: damage type for saving throw damage """ return self.get_save_damage_dice().get_damage_type()
[docs] def get_damage_on_success(self) -> bool: """ :return: whether the target takes damage on a successful save """ return self._damage_on_success
[docs] def on_hit(self, source, target, adv=0, crit=0): """ The attack hit! Do all the normal Attack stuff (i.e., send damage), then have the target make a saving throw. :param source: the Combatant that is making the attack :type source: Combatant :param target: the Combatant that *source* is attacking :type target: Combatant :param adv: advantage, disadvantage, or neither on the attack roll :type adv: one of these integers: -1, 0, 1 :type crit: whether or not the hit was a crit :return: the total damage taken """ damage = super().on_hit(source, target, crit) save_result = self.do_saving_thow(source, target) save_damage = self.send_save_damage(target, saved=save_result) if save_damage: damage += save_damage return damage
[docs] def roll_save_damage(self): """ Roll :py:attr:`_damage_dice` (if crit == 1, multiply the number of dice to roll by *crit_multiplier*), then add :py:attr:`_damage_mod` :return: the damage rolled :rtype: namedtuple :raise: ValueError if *crit_multiplier* is invalid """ return self.get_damage_dice().roll_dice()
[docs] def send_save_damage(self, target, saved: bool = False) -> Optional[int]: # pylint: disable=arguments-differ """ Roll damage using *self.roll_save_damage* and store the result in variable *damage*. Call *target*. :py:meth:`take_damage`, passing in *damage* and :py:attr:`_damage_type`. :param target: the Combatant that is taking the damage :type target: Combatant :param saved: if this is True, damage is none if not :py:attr:`_damage_on_success` or half damage if :py:attr:`_damage_on_success` :type saved: bool :return: the damage taken (as returned by *target.take_damage*) :rtype: non-negative integer or None """ roll_result = self.roll_save_damage() try: roll_result.roll_number # pylint: disable=pointless-statement # stick namedtuple in list so that it can be processed the same way as output from DamageDiceBag roll_result = [roll_result] except AttributeError: # DamageDiceBag pass for result in roll_result: if not saved: if target.has_feature("evasion") and self._save_type == "dexterity": return target.take_damage(result.roll_number // 2, damage_type=result.damage_type) return target.take_damage(result.roll_number, damage_type=result.damage_type) if self._damage_on_success: if target.has_feature("evasion") and self._save_type == "dexterity": return 0 return target.take_damage(result.roll_number // 2, damage_type=result.damage_type) return None