Source code for DnD_5e.attack_class

import warnings
from typing import Optional, List
from copy import copy, deepcopy
from DnD_5e.utility_methods_dnd import TYPE_DICE_TUPLE, TYPE_ROLL_RESULT
from DnD_5e import weapons, dice

[docs] class Attack: """ This class represents attacks """ __hash__ = None # Tell the interpreter that instances of this class cannot be hashed def __init__(self, **kwargs): """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param name: what *self* is called. A unique name is recommended but not required :type name: str :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 :param attack_mod: the number to be added to attack rolls (ignored if attack_dice is passed in as a Dice) :type attack_mod: int :param damage_mod: the number to be added to damage rolls (ignored if damage_dice is passed in as a DamageDice) :type damage_mod: int :param attack_dice: the dice for making attack rolls :type attack_dice: dice.Dice :param damage_dice: the dice to determine damage points. If provided as a tuple or string, keyword arguments will be passed on to the DamageDice constructor, including damage_type :type damage_dice: dice.DamageDice or tuple or string :raise: ValueError if input is invalid """ name = kwargs.get("name") self._name = name if not isinstance(name, str): raise ValueError("Name should be a string") range_val = kwargs.get("range", 0) if isinstance(range_val, int): self._range = range_val else: warnings.warn("Non-integer value for range provided") self._range = range_val melee_range = kwargs.get("melee_range", 0) if isinstance(melee_range, int): self._melee_range = melee_range else: self._melee_range = 0 if not self._range and not self._melee_range: # assume this is a melee attack if not otherwise specified self._melee_range = 5 adv = kwargs.get("adv", 0) if not isinstance(adv, int): raise ValueError("Advantage must be an int") weapon = kwargs.get("weapon") if isinstance(weapon, weapons.Weapon): self._weapon = weapon elif weapon: raise ValueError("Weapon should be a Weapon object") else: self._weapon = None attack_dice = kwargs.get("attack_dice") if isinstance(attack_dice, dice.Dice): self._attack_dice = attack_dice else: if isinstance(attack_dice, tuple): kwargs.update(dice_tuple=attack_dice) elif isinstance(attack_dice, str): kwargs.update(str_val=attack_dice) else: kwargs.update(dice_tuple=(1, 20)) kwargs.update(modifier=kwargs.get("attack_mod", 0)) kwargs.update(critable=True) kwargs.update(adv=0) # don't add advantage twice self._attack_dice = dice.Dice(**kwargs) self._attack_dice.shift_adv(adv) damage_dice = kwargs.get("damage_dice") if not damage_dice: if not self._weapon: raise ValueError("Must provide damage dice or a weapon to get damage dice from") self._damage_dice = copy(self._weapon.get_damage_dice()) # adjusting damage and attack mods based on keyword arguments if kwargs.get("damage_mod"): self.shift_damage_mod(kwargs.get("damage_mod")) elif isinstance(damage_dice, dice.DamageDice): self._damage_dice = copy(damage_dice) # adjusting damage and attack mods based on keyword arguments if kwargs.get("damage_mod"): self.shift_damage_mod(kwargs.get("damage_mod")) else: if isinstance(damage_dice, tuple): kwargs.update(dice_tuple=damage_dice) elif isinstance(damage_dice, str): kwargs.update(str_val=damage_dice) kwargs.update(modifier=kwargs.get("damage_mod", 0)) kwargs.update(critable=False) kwargs.update(adv=0) # can't have advantage or disadvantage on damage self._damage_dice = dice.DamageDice(**kwargs)
[docs] def set_name(self, name: str): if not isinstance(name, str): raise ValueError("name must be a string") self._name = name
def __eq__(self, other) -> bool: """ Compare *self* and *other* to determine if they are equal based on the following characteristics: damage dice, range, melee range, adv, weapon Override the default __eq__ because it's useful to be able to tell if one Combatant's Attacks are the same as another's :param other: the Attack to be compared :type other: Attack :return: True if *self* __eq__ *other*, False otherwise :rtype: bool """ if other is self: return True if type(other) != type(self): # pylint: disable=unidiomatic-typecheck return False return self.get_attack_dice() == other.get_attack_dice() \ and self.get_damage_dice() == other.get_damage_dice() \ and self.get_range() == other.get_range() \ and self.get_melee_range() == other.get_melee_range() \ and self.get_weapon() == other.get_weapon()
[docs] def get_attack_dice(self) -> dice.Dice: """ :return: attack dice :rtype: dice.Dice """ return self._attack_dice
[docs] def get_damage_dice(self) -> dice.DamageDice: """ :return: damage dice :rtype: dice.DamageDice """ return self._damage_dice
[docs] def get_attack_mod(self) -> int: """ :return: attack mod :rtype: int """ return self.get_attack_dice().get_modifier()
[docs] def get_damage_mod(self) -> int: """ :return: damage mod :rtype: int """ try: return self.get_damage_dice().get_modifier() except NotImplementedError: # DamageDiceBag warnings.warn("Tried to get damage mod on a DamageDiceBag. Returning 0 instead") return 0
[docs] def get_damage_type(self) -> Optional[str]: """ :return: damage type :rtype: str """ return self.get_damage_dice().get_damage_type()
[docs] def has_damage_type(self, damage_type: str) -> bool: """ :param damage_type: the type of damage to look for :type damage_type: str :return: True if *self* has the specified damage type, False otherwise :rtype: bool """ return self.get_damage_dice().has_damage_type(damage_type)
[docs] def get_range(self) -> int: """ :return: range for ranged attacks :rtype: usually a positive integer, but currently other types are also supported """ return self._range
[docs] def get_melee_range(self) -> int: """ :return: range for melee attacks :rtype: positive integer """ return self._melee_range
[docs] def get_adv(self) -> int: """ :return: adv :rtype: int """ try: result = self.get_attack_dice().get_adv() except NotImplementedError: warnings.warn("Tried to get adv of a DiceBag, which is not supported. Returning 0.") result = 0 return result
[docs] def get_name(self) -> str: """ :return: name :rtype: str """ return self._name
[docs] def get_weapon(self) -> Optional[weapons.Weapon]: """ :return: weapon, if any :rtype: Weapon or None """ return self._weapon
[docs] def get_max_hit(self) -> int: """ :return: the maximum number that could result from an attack roll :rtype: int """ return self.get_attack_dice().get_max_value()
[docs] def get_min_hit(self) -> int: """ :return: the minimum number that could result from an attack roll :rtype: int """ return self.get_attack_dice().get_min_value()
[docs] def get_average_hit(self) -> float: """ :return: the average number that would result from an attack roll :rtype: float """ return self.get_attack_dice().get_average_value()
[docs] def get_max_damage(self) -> int: """ :return: the maximum number that could result from a damage roll (not counting extra damage from a critical hit) :rtype: int """ return self.get_damage_dice().get_max_value()
[docs] def get_min_damage(self) -> int: """ :return: the minimum number that could result from a damage roll (not counting extra damage from a critical hit) :rtype: int """ return self.get_damage_dice().get_min_value()
[docs] def get_average_damage(self) -> float: """ :return: the average number that would result from a damage roll :rtype: float """ return self.get_damage_dice().get_average_value()
[docs] def get_prob_hit(self, **kwargs) -> float: """ :param ac: the ac you're trying to hit :return: the probability of rolling greater than or equal to ac """ ac = kwargs.get("ac") return self.get_attack_dice().get_prob(ac, kind="ge")
[docs] def get_dpr(self, **kwargs) -> float: """ :param ac: the armor class you need to hit :type ac: int :return: the average damage per round (dpr) """ ac = kwargs.get("ac") if ac is None: ac = kwargs.get('target').get_ac() return self.get_prob_hit(ac=ac) * self.get_average_damage()
[docs] def set_attack_mod(self, attack_mod: int): """ Set *self._attack_mod* :param attack_mod: the new attack mod :type attack_mod: int :return: None :raise: ValueError if *attack_mod* is not an integer """ self.get_attack_dice().set_modifier(attack_mod)
[docs] def shift_attack_mod(self, attack_mod: int): """ Modify *self._attack_mod* by the given value :param attack_mod: the damage mod to shift by :type attack_mod: int :return: None :raise: ValueError if *attack_mod* is not an integer """ self.get_attack_dice().shift_modifier(attack_mod)
[docs] def set_damage_mod(self, damage_mod: int): """ Set *self._damage_mod* to the given value :param damage_mod: the new damage mod :type damage_mod: int :return: None :raise: ValueError if *damage_mod* is not an integer """ self.get_damage_dice().set_modifier(damage_mod)
[docs] def shift_damage_mod(self, damage_mod: int): """ Modify *self._damage_mod* by the given value :param damage_mod: the damage mod to shift by :type damage_mod: int :return: None :raise: ValueError if *damage_mod* is not an integer """ self.get_damage_dice().shift_modifier(damage_mod)
[docs] def roll_attack(self, adv: int = 0) -> TYPE_ROLL_RESULT: # adv is the additional advantage afforded by circumstance """ Roll a d20 (with advantage/disadvantage as computed with *adv* and :py:attr:`_adv`), adding :py:attr:`_attack_mod` :param adv: advantage, disadvantage, or neither on the attack roll :type adv: int :return: the roll and an indication of whether it was a critical hit, critical miss, or neither :rtype: TYPE_ROLL_RESULT """ result = self.get_attack_dice().roll_dice(adv=adv) return result
[docs] def roll_damage(self, crit: int = 0, crit_multiplier: int = 2): """ Roll :py:attr:`_damage_dice` (if crit == 1, multiply the number of dice to roll by *crit_multiplier*), then add :py:attr:`_damage_mod` :param crit: indicates whether the hit was a crit :type crit: one of these integers: -1, 0, 1 :param crit_multiplier: the multiplier for dice num if the hit is a crit :type crit_multiplier: positive integer :return: the damage rolled :rtype: namedtuple :raise: ValueError if *crit_multiplier* is invalid """ return self.get_damage_dice().roll_dice(crit=crit, crit_multiplier=crit_multiplier)
[docs] def make_attack(self, source, target, adv: int = 0) -> Optional[int]: """ 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 """ num, crit = self.roll_attack(adv=adv) if target.has_condition("unconscious") and crit != -1: # TODO: and source is within 5 feet of target crit = 1 # any attack that hits the target is a critical hit if the attacker is within 5 feet of the target try: source.get_logger().info(f"{source.get_name()} attacks ${target.get_name()} " f"with ${self.get_name()} and rolls a ${num}.") if target.take_attack((num, crit), source=source, attack=self): # take_attack returns True if attack hits if crit == 1: source.get_logger().info("Critical hit!") else: source.get_logger().info("Hit!") return self.on_hit(source, target, adv, crit) # if the attack failed to hit if crit == -1: source.get_logger().info("Critical miss.") else: source.get_logger().info("Miss.") return self.on_miss(source, target, adv, crit) # by default on_miss returns None so it's clear from the return value whether the attack hit except NameError: raise ValueError("Tried to attack with a source that can't send attacks or a target that can't take attacks")
[docs] def on_hit(self, source, target, adv=0, crit=0) -> int: # pylint: disable=unused-argument """ You hit the target, now do whatever happens on a hit :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 """ if source.has_feature("brutal critical"): crit_multiplier = 3 else: crit_multiplier = 2 damage_taken = self.send_damage(target, crit=crit, crit_multiplier=crit_multiplier) return damage_taken
[docs] def on_miss(self, source, target, adv=0, crit=0): """ You missed the target, now do whatever happens on a miss :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: None """ pass # pylint: disable=unnecessary-pass
[docs] def send_damage(self, target, crit: int = 0, crit_multiplier: int = 2) -> int: """ Roll damage using :py:meth:`roll_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 crit: indicates whether the hit was a crit :type crit: one of these integers: -1, 0, 1 :param crit_multiplier: the number to multiply dice num by if the hit is a crit :type crit_multiplier: positive integer :return: the damage taken, as returned by *target.take_damage* :rtype: non-negative integer or None """ roll_result = self.roll_damage(crit=crit, crit_multiplier=crit_multiplier) damage_taken = 0 try: damage_taken = target.take_damage(roll_result.roll_number, roll_result.damage_type, crit == 1) except AttributeError: # DamageDiceBag was rolled, so roll_list is list of namedtuples roll_list = roll_result[1] for result in roll_list: damage_taken += target.take_damage(result.roll_number, result.damage_type, crit == 1) return damage_taken
[docs] class MultiAttack(Attack): """ Container for multiple Attacks """ def __init__(self, **kwargs): # pylint: disable=super-init-not-called """ Validate the input and set the instance variables :param kwargs: keyword arguments. Valid arguments are as follows: :param name: what *self* is called. A unique name is recommended but not required :type name: str :param attack_list: list of Attacks, in the order in which they will be executed :type attack_list: list of Attacks :raise: ValueError if input is invalid """ self._name = kwargs.get("name", "multiattack") if not self._name or not isinstance(self._name, str): self._name = "multiattack" attack_list = kwargs.get("attacks") if not isinstance(attack_list, (list, tuple)): raise ValueError("Attacks must be a list or tuple") self._attack_list = [] for attack in attack_list: self.add_attack(attack) if len(self._attack_list) < 2: raise ValueError("MultiAttack must contain 2 or more Attacks") def __eq__(self, other) -> bool: """ Compare *self* and *other* to determine if they are equal based on the following characteristics: attack list :param other: the MultiAttack to be compared :type other: MultiAttack :return: True if *self* __eq__ *other*, False otherwise :rtype: bool """ if other is self: return True if type(other) != type(self): # pylint: disable=unidiomatic-typecheck return False return self.get_attacks() == other.get_attacks()
[docs] def current_eq(self, other) -> bool: """ For this class, it is the same as __eq__. :param other: the MultiAttack to be compared :type other: MultiAttack :return: True if *self* is identical to *other*, False otherwise :rtype: bool """ return self == other
[docs] def get_attacks(self) -> List[Attack]: """ :return: attacks :rtype: list of Attacks """ return self._attack_list
[docs] def get_attack_by_name(self, name) -> Optional[Attack]: """ Get an Attack from :py:attr:`_attack_list` with the given name :param name: the name of the Attack :type name: str :return: the first Attack with the given name (or None, if no match is found) :raise: ValueError if *name* is invalid """ if not isinstance(name, str): raise ValueError("Name must be a string") for attack in self.get_attacks(): if attack.get_name() == name: return attack return None
[docs] def get_max_damage(self) -> int: """ :return: The maximum damage combined across all attacks :rtype: non-negative integer """ max_damage = 0 for attack in self.get_attacks(): max_damage += attack.get_max_damage() return max_damage
[docs] def get_min_damage(self) -> int: """ :return: The minimum damage combined across all attacks :rtype: non-negative integer """ min_damage = 0 for attack in self.get_attacks(): min_damage += attack.get_min_damage() return min_damage
[docs] def get_average_damage(self) -> int: """ :return: The average damage combined across all attacks :rtype: non-negative integer """ average_damage = 0 for attack in self.get_attacks(): average_damage += attack.get_average_damage() return average_damage
[docs] def add_attack(self, attack: Attack): """ Add *attack* to the end of *self._attack_list* :param attack: the Attack to add :type attack: Attack :return: None :raise: ValueError if *attack* is not an Attack """ if not isinstance(attack, Attack): raise ValueError("Each attack must be an Attack object") self._attack_list.append(attack)
[docs] def remove_attack(self, attack): """ Remove *attack* from :py:attr:`_attack_list` :param attack: the Attack to remove (or the name of the Attack to remove) :type attack: Attack or str :return: None """ if isinstance(attack, str): attack = self.get_attack_by_name(attack) try: self._attack_list.remove(attack) except ValueError as error: warnings.warn(str(error))
[docs] def make_attack(self, source, target, adv: int = 0) -> Optional[int]: """ Convert *target* and *adv* to lists so that each attack has one target and one adv. If *target* or *adv* is too short of a sequence, the last value in the provided list will be copied as needed. For each attack in :py:meth:`get_attacks`, call the attack the appropriate target with the appropriate adv. :param source: the Combatant that is making the attack :type source: Combatant :param target: the Combatant(s) that *source* is attacking :type target: Combatant or list of Combatants :param adv: one or more integers that indicates advantage on the attack roll :return: the total damage taken :rtype: non-negative integer :raise: ValueError if *source* can't send attacks """ source.get_logger().info(f"{source.get_name()} attacks using {self.get_name()}") if not isinstance(target, (list, tuple)): targets = [target for attack in self.get_attacks()] elif len(target) < len(self.get_attacks()): targets = target for i in range(len(target), len(self.get_attacks())): targets.append(target[-1]) else: targets = target if not isinstance(adv, (list, tuple)): advs = [adv for _ in range(len(self.get_attacks()))] elif len(adv) < len(self.get_attacks()): advs = adv for i in range(len(adv), len(self.get_attacks())): advs.append(adv[-1]) else: advs = adv total_damage = 0 for i in range(len(self.get_attacks())): current_damage = self.get_attacks()[i].make_attack(source, target=targets[i], adv=advs[i]) if current_damage: # done to avoid errors from adding None total_damage += current_damage return total_damage
[docs] def get_damage_dice(self) -> TYPE_DICE_TUPLE: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_damage_dice on one of the attacks in this multiattack")
[docs] def get_attack_mod(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_attack_mod on one of the attacks in this multiattack")
[docs] def get_damage_mod(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_damage_mod on one of the attacks in this multiattack")
[docs] def get_damage_type(self) -> Optional[str]: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_damage_type on one of the attacks in this multiattack")
[docs] def get_range(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_range on one of the attacks in this multiattack")
[docs] def get_melee_range(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_melee_range on one of the attacks in this multiattack")
[docs] def get_adv(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_adv on one of the attacks in this multiattack")
[docs] def get_weapon(self) -> Optional[weapons.Weapon]: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_weapon on one of the attacks in this multiattack")
[docs] def get_max_hit(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_max_hit on one of the attacks in this multiattack")
[docs] def get_average_hit(self) -> int: """ :raise: NotImplementedError """ raise NotImplementedError("Call get_average_hit on one of the attacks in this multiattack")
[docs] def set_attack_mod(self, attack_mod: int): """ :raise: NotImplementedError """ raise NotImplementedError("Call set_attack_mod on one of the attacks in this multiattack")
[docs] def set_damage_mod(self, damage_mod: int): """ :raise: NotImplementedError """ raise NotImplementedError("Call set_damage_mod on one of the attacks in this multiattack")
[docs] def shift_damage_mod(self, damage_mod: int): """ :raise: NotImplementedError """ raise NotImplementedError("Call shift_damage_mod on one of the attacks in this multiattack")
[docs] def roll_attack(self, adv=0): """ :raise: NotImplementedError """ raise NotImplementedError("Call roll_attack on one of the attacks in this multiattack")
[docs] def roll_damage(self, crit=0, crit_multiplier=2): """ :raise: NotImplementedError """ raise NotImplementedError("Call roll_damage on one of the attacks in this multiattack")